mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6087b8b8e8 |
No files matched your search
Generated
+35
-44
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,7 +4075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4090,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.32"
|
||||
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.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4115,7 +4115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4129,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4147,7 +4147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4169,7 +4169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4183,19 +4183,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4209,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4254,7 +4253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4272,7 +4271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4294,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4310,7 +4309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4365,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.32"
|
||||
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.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4413,7 +4411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4431,7 +4429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4444,7 +4442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4461,7 +4459,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4484,7 +4482,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4503,7 +4501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4523,7 +4521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4536,7 +4534,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4555,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4570,7 +4568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4583,7 +4581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4595,7 +4593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4616,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4635,7 +4633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4657,7 +4655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4673,7 +4671,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.32"
|
||||
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.32"
|
||||
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
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.32"
|
||||
version = "0.1.29"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+1
-48
@@ -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
|
||||
|
||||
@@ -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"]})));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t).map_err(|e| IdeError::internal(e.to_string()))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_resources(root: &Path, from: &Path, to: &Path, copied: &mut Vec<String>) -> IdeResult<()> {
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if kind.is_dir() {
|
||||
copy_resources(root, &path, to, copied)?;
|
||||
} else if kind.is_file() && path.extension().is_none_or(|e| e != "class") {
|
||||
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let dest = to.join(rel);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(&path, dest)?;
|
||||
copied.push(rel.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_plans_use_the_selected_module_and_build_configuration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
fs::create_dir(root.join("app")).unwrap();
|
||||
let mut model = BuildModel {
|
||||
maven: Some(crate::MavenModel {
|
||||
projects: vec![crate::MavenProject {
|
||||
dir: "app".into(),
|
||||
command: "/tools/mvnw".into(),
|
||||
options: vec!["-Pdev".into()],
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let cache = root.join("cache");
|
||||
let maven = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(maven.command, "/tools/mvnw");
|
||||
assert_eq!(maven.cwd, "app");
|
||||
assert_eq!(maven.args[0..2], ["-Pdev", "process-resources"]);
|
||||
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
|
||||
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
|
||||
model.maven = None;
|
||||
model.gradle = Some(crate::GradleModel {
|
||||
command: "/tools/gradlew".into(),
|
||||
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
});
|
||||
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(gradle.command, "/tools/gradlew");
|
||||
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
|
||||
let script = fs::read_to_string(cache.join("resources.init.gradle")).unwrap();
|
||||
assert!(script.contains("processResources") && script.contains("destinationDir.absolutePath"));
|
||||
assert!(!script.contains("build/resources/main")); // The task owns custom output locations.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_maven_and_gradle_resource_outputs_to_the_runtime_classpath() {
|
||||
for layout in ["target/classes", "build/resources/main", "custom resource output"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let cache = root.join("cache");
|
||||
let output = root.join(layout);
|
||||
let runtime = if layout == "target/classes" { output.clone() } else { root.join("bin/main") };
|
||||
fs::create_dir_all(output.join("static")).unwrap();
|
||||
fs::create_dir_all(runtime.join("example")).unwrap();
|
||||
fs::create_dir_all(&cache).unwrap();
|
||||
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
|
||||
fs::write(output.join("static/index.html"), "after").unwrap();
|
||||
fs::write(output.join("unrelated.class"), "do not publish").unwrap();
|
||||
fs::write(runtime.join("example/Application.class"), "running").unwrap();
|
||||
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
|
||||
assert_eq!(fs::read_to_string(runtime.join("static/index.html")).unwrap(), "after");
|
||||
assert_eq!(fs::read_to_string(runtime.join("example/Application.class")).unwrap(), "running");
|
||||
if output != runtime {
|
||||
assert!(!runtime.join("unrelated.class").exists());
|
||||
fs::remove_file(output.join("static/index.html")).unwrap();
|
||||
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
|
||||
assert!(!runtime.join("static/index.html").exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_separate_runtime_resource_directories_in_place_and_rejects_jar_only_runs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let output = root.join("build/resources/main");
|
||||
let cache = root.join("cache");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
fs::create_dir(&cache).unwrap();
|
||||
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
|
||||
publish(root, &cache, &[output.to_string_lossy().into_owned()], "a.Main").unwrap();
|
||||
assert!(publish(root, &cache, &["app.jar".into()], "a.Main").is_err());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -294,7 +292,6 @@ pub struct AppearanceSettings {
|
||||
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 +325,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,
|
||||
@@ -342,7 +338,6 @@ impl Default for AppearanceSettings {
|
||||
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,
|
||||
@@ -508,50 +503,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,35 +719,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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(®ular, [1, 2, 3]).unwrap();
|
||||
std::fs::write(&bold, [4, 5]).unwrap();
|
||||
let entries = [
|
||||
UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false },
|
||||
UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false },
|
||||
// A non-selected face is never opened, even if its file has disappeared.
|
||||
UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false },
|
||||
];
|
||||
let result = bundle_family("test nerd font", &entries).unwrap();
|
||||
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
|
||||
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
|
||||
assert_eq!(faces[0]["weight"], 400);
|
||||
assert_eq!(faces[1]["weight"], 700);
|
||||
assert_eq!(faces.as_array().unwrap().len(), 2);
|
||||
assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]);
|
||||
// Existing files are not inputs: neither paths nor unenumerated families grant access.
|
||||
assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err());
|
||||
assert!(bundle_family("Unenumerated", &entries).is_err());
|
||||
assert!(user_font_path(®ular, &[]).is_none());
|
||||
assert!(user_font_path(®ular, std::slice::from_ref(&root)).is_some());
|
||||
// Multiple selected descriptors in a collection still transfer the file only once.
|
||||
let shared = [
|
||||
UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false },
|
||||
UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false },
|
||||
];
|
||||
let result = bundle_family("Collection", &shared).unwrap();
|
||||
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
|
||||
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
|
||||
assert_eq!(faces[0]["offset"], faces[1]["offset"]);
|
||||
assert_eq!(&result[4 + len..], &[1, 2, 3]);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = root.join("outside.ttf");
|
||||
std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap();
|
||||
assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none());
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn fam(name: &str, monospaced: bool) -> FontFamily {
|
||||
FontFamily { name: name.into(), monospaced }
|
||||
}
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -150,15 +148,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 +158,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 ¬ebooks {
|
||||
service.stop_admission();
|
||||
}
|
||||
@@ -181,9 +166,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 +186,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 +197,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 {
|
||||
|
||||
@@ -97,7 +97,6 @@ pub async fn build_sync(
|
||||
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 result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
@@ -138,9 +137,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)`.
|
||||
@@ -41,46 +39,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,26 +103,6 @@ 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)?;
|
||||
@@ -180,40 +132,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -172,16 +172,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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -206,12 +134,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 });
|
||||
|
||||
@@ -16,15 +16,11 @@ pub fn spawn(
|
||||
options.args.clear();
|
||||
options.env = global.env;
|
||||
}
|
||||
let (shell, args) = ide_terminal::resolve_shell(options.shell.as_deref(), &options.args);
|
||||
options.shell = Some(shell.clone());
|
||||
options.args = args;
|
||||
// Python › "Activate virtualenv": typed into the same shell the PTY starts.
|
||||
let activation = host
|
||||
.project(window)
|
||||
.ok()
|
||||
.filter(|p| p.is_trusted())
|
||||
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
|
||||
// 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(), 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"));
|
||||
|
||||
@@ -100,11 +100,11 @@ pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec<JdkVendor> {
|
||||
/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`.
|
||||
pub async fn jdk_download_releases(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
vendor: String,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JdkReleases> {
|
||||
let tools = Tools::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 +123,14 @@ pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn jdk_download(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
vendor: String,
|
||||
version: u32,
|
||||
location: Option<PathBuf>,
|
||||
request: RequestId,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let tools = Tools::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 || {
|
||||
|
||||
@@ -746,33 +746,6 @@ pub async fn vcs_remove_worktree(
|
||||
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,
|
||||
@@ -1067,18 +1040,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
|
||||
});
|
||||
|
||||
@@ -42,8 +42,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 +50,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)>>,
|
||||
@@ -129,8 +126,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 +139,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(
|
||||
@@ -253,22 +216,11 @@ impl Host {
|
||||
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();
|
||||
@@ -289,7 +241,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);
|
||||
@@ -369,7 +320,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();
|
||||
@@ -405,14 +356,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 +378,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());
|
||||
}
|
||||
@@ -527,14 +471,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,
|
||||
@@ -828,15 +768,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 +902,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 +911,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);
|
||||
}),
|
||||
|
||||
@@ -179,7 +179,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 +190,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")?) })
|
||||
|
||||
@@ -106,9 +106,6 @@ impl crate::Host {
|
||||
if previous == trusted {
|
||||
continue;
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
if !trusted {
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
@@ -178,30 +175,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
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"
|
||||
@@ -262,7 +235,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "agent_registry_remove"
|
||||
| "coverage_import"
|
||||
| "system_font_families"
|
||||
| "user_font_data"
|
||||
| "plugin_subscribe"
|
||||
| "plugin_list"
|
||||
| "plugin_set_active_file"
|
||||
@@ -304,8 +276,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_remotes"
|
||||
| "vcs_worktrees"
|
||||
| "vcs_diff_with_worktree"
|
||||
| "vcs_compare_scope"
|
||||
| "vcs_compare_paths"
|
||||
| "vcs_diff_revisions"
|
||||
| "vcs_merge_base"
|
||||
| "vcs_shelves"
|
||||
|
||||
@@ -104,7 +104,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 +129,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 +169,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 +181,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");
|
||||
}
|
||||
@@ -94,39 +94,6 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
use ide_core::NullSink;
|
||||
#[cfg(unix)]
|
||||
use ide_host::ops;
|
||||
use ide_host::{Host, HostConfig, trust::TrustStore};
|
||||
use ide_host::{Host, HostConfig, ops, 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> {
|
||||
@@ -20,94 +17,6 @@ fn host(base: &Path, environment: bool) -> Arc<Host> {
|
||||
}))
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+9
-238
@@ -129,7 +129,6 @@ impl Target {
|
||||
}
|
||||
|
||||
struct DocSnap {
|
||||
version: i32,
|
||||
path: PathBuf,
|
||||
uri: lsp::Uri,
|
||||
language: LanguageId,
|
||||
@@ -169,7 +168,6 @@ 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>>>,
|
||||
@@ -177,7 +175,6 @@ pub struct LspManager {
|
||||
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,7 +300,6 @@ 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),
|
||||
@@ -311,7 +307,6 @@ impl LspManager {
|
||||
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()),
|
||||
@@ -518,19 +513,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 +656,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 +672,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 +888,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 +1156,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 +1420,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 +1665,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 +1945,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 +1953,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 +1963,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 {
|
||||
@@ -2309,7 +2081,6 @@ impl LspManager {
|
||||
let without_settings = |c: &LanguageServerConfig| LanguageServerConfig { settings: None, ..c.clone() };
|
||||
if let Some(run) = s.running()
|
||||
&& without_settings(&next) == without_settings(¤t)
|
||||
&& !jdtls::source_paths_removed(¤t, &next)
|
||||
{
|
||||
s.set_effective_config(next);
|
||||
let params = json!({ "settings": handler::all_settings(&s.effective_config()) });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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, ¶ms)) {
|
||||
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());
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
//! configurations on run targets ([`target`]).
|
||||
|
||||
pub mod deploy;
|
||||
pub mod import;
|
||||
pub mod mapping;
|
||||
pub mod service;
|
||||
pub mod target;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"\\?\"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -497,7 +497,7 @@ pub fn available_shells() -> Vec<String> {
|
||||
|
||||
/// Picks the shell binary and its arguments. A default (or explicitly chosen but
|
||||
/// argument-less) POSIX shell is started as a login shell, like Terminal.app/IDEA.
|
||||
pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec<String>) {
|
||||
fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec<String>) {
|
||||
let shell = shell.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).unwrap_or_else(default_shell);
|
||||
if !args.is_empty() {
|
||||
return (shell, args.to_vec());
|
||||
@@ -808,12 +808,8 @@ mod tests {
|
||||
assert_eq!((shell.as_str(), args), ("/bin/zsh", vec!["-l".to_owned()]));
|
||||
let (_, args) = resolve_shell(Some("/usr/bin/python3"), &[]);
|
||||
assert!(args.is_empty());
|
||||
for configured in [None, Some(""), Some(" \t ")] {
|
||||
let (shell, args) = resolve_shell(configured, &[]);
|
||||
assert_eq!(shell, default_shell_path());
|
||||
assert_eq!(args, vec!["-l".to_owned()]);
|
||||
}
|
||||
assert_eq!(resolve_shell(Some(" /bin/bash "), &["-i".into()]), ("/bin/bash".into(), vec!["-i".into()]));
|
||||
let (_, args) = resolve_shell(None, &[]);
|
||||
assert_eq!(args, vec!["-l".to_owned()]);
|
||||
assert_eq!(shell_title("C:\\Program Files\\PowerShell\\7\\pwsh.exe"), "pwsh");
|
||||
let env = terminal_env(&BTreeMap::from([("TERM".into(), "dumb".into())]));
|
||||
assert_eq!(env["TERM"], "dumb");
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
//! Versioned app-owned plugin jars, shared by tool detection and language-server launches.
|
||||
|
||||
use crate::{Method, ToolSpec};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn install(root: &Path, spec: &ToolSpec) -> std::io::Result<PathBuf> {
|
||||
let Method::Bundled { filename, bytes, .. } = spec.method else { unreachable!("bundled tool") };
|
||||
let dir = root.join(spec.name).join("bin");
|
||||
// The agent path participates in the JDT LS workspace key: upgrades must
|
||||
// discard incremental state compiled with the previous agent.
|
||||
let path = dir.join(filename);
|
||||
if std::fs::read(&path).is_ok_and(|old| old == bytes) {
|
||||
return Ok(path);
|
||||
}
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
|
||||
let temporary = dir.join(format!(".{filename}-{}-{nonce}.tmp", std::process::id()));
|
||||
let result = std::fs::write(&temporary, bytes).and_then(|_| std::fs::rename(&temporary, &path));
|
||||
if let Err(error) = result {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
// Another window may have installed the same bytes concurrently.
|
||||
if !std::fs::read(&path).is_ok_and(|old| old == bytes) {
|
||||
tracing::warn!(tool = spec.name, %error, "Bundled plugin unavailable");
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! External formatting: resolve a tool, pass unsaved text on stdin, return text without writing files.
|
||||
|
||||
use crate::Tools;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_language::FormatterConfig;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Walk from the file's directory upward, including workspace parents (monorepos).
|
||||
pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
|
||||
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
|
||||
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
|
||||
let bin = json["bin"]
|
||||
.as_str()
|
||||
.or_else(|| json["bin"]["prettier"].as_str())
|
||||
.ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?;
|
||||
let path = package.join(bin);
|
||||
if !path.is_file() {
|
||||
return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display())));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
/// Explicit executable/script, then the nearest project package, then managed tools and PATH.
|
||||
pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
self.resolve_formatter_command(tool, file, custom).map_err(|e| e.with_tool(tool))
|
||||
}
|
||||
|
||||
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
if tool != "prettier" {
|
||||
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
|
||||
}
|
||||
let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) {
|
||||
(package_bin(custom)?, true)
|
||||
} else if let Some(custom) = custom {
|
||||
(custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs")))
|
||||
} else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) {
|
||||
(package_bin(manifest.parent().unwrap())?, true)
|
||||
} else if self.root.join("node_modules/prettier/package.json").is_file() {
|
||||
(package_bin(&self.root.join("node_modules/prettier"))?, true)
|
||||
} else {
|
||||
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
|
||||
};
|
||||
if node {
|
||||
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
|
||||
let mut cmd = self.command(node);
|
||||
cmd.arg(path);
|
||||
Ok(cmd)
|
||||
} else {
|
||||
Ok(self.command(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
|
||||
pub fn arguments(
|
||||
config: &FormatterConfig,
|
||||
file: &Path,
|
||||
tab_size: u32,
|
||||
insert_spaces: bool,
|
||||
range: Option<(u32, u32)>,
|
||||
) -> Vec<OsString> {
|
||||
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
|
||||
"mdx"
|
||||
} else {
|
||||
&config.parser
|
||||
};
|
||||
let mut args: Vec<OsString> = [
|
||||
"--parser",
|
||||
parser,
|
||||
"--config-precedence",
|
||||
"file-override",
|
||||
"--tab-width",
|
||||
&tab_size.max(1).to_string(),
|
||||
"--use-tabs",
|
||||
if insert_spaces { "false" } else { "true" },
|
||||
]
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
|
||||
if config.range
|
||||
&& let Some((start, end)) = range
|
||||
{
|
||||
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
|
||||
}
|
||||
// CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files.
|
||||
for name in [".prettierignore", ".gitignore"] {
|
||||
if let Some(path) = find_up(file, Path::new(name)) {
|
||||
args.extend(["--ignore-path".into(), path.into_os_string()]);
|
||||
}
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_formatter_is_structured_but_invalid_custom_path_is_not_an_install_prompt() {
|
||||
use ide_core::error::{ErrorData, ErrorKind};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tools = Tools::with_paths(dir.path().join("tools"), OsString::new(), crate::platform::Platform::current());
|
||||
let file = dir.path().join("file.md");
|
||||
let missing = tools.formatter_command("prettier", &file, None).unwrap_err();
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap();
|
||||
let failure = run(custom, "text", &Default::default()).unwrap_err();
|
||||
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]);
|
||||
let failure = run(command, "", &Default::default()).unwrap_err();
|
||||
assert_eq!(failure.message, "error\n".repeat(1000));
|
||||
assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_nearest_package_and_ignore_above_nested_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("packages/web/src")).unwrap();
|
||||
std::fs::write(root.join(".prettierignore"), "ignored.md").unwrap();
|
||||
std::fs::create_dir_all(root.join("node_modules/prettier")).unwrap();
|
||||
std::fs::write(root.join("node_modules/prettier/package.json"), "{}").unwrap();
|
||||
let file = root.join("packages/web/src/readme.md");
|
||||
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join(".prettierignore")));
|
||||
std::fs::write(root.join("packages/web/.prettierignore"), "src/ignored.md").unwrap();
|
||||
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join("packages/web/.prettierignore")));
|
||||
assert_eq!(
|
||||
find_up(&file, Path::new("node_modules/prettier/package.json")),
|
||||
Some(root.join("node_modules/prettier/package.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_use_stdin_config_precedence_and_only_supported_ranges() {
|
||||
for (parser, supports_range) in [
|
||||
("markdown", false),
|
||||
("css", false),
|
||||
("yaml", false),
|
||||
("typescript", true),
|
||||
("graphql", true),
|
||||
("vue", true),
|
||||
] {
|
||||
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
|
||||
let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
|
||||
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
|
||||
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--tab-width", "4"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--use-tabs", "true"]));
|
||||
assert_eq!(args.contains(&"--range-start".into()), supports_range);
|
||||
assert!(!args.contains(&"--write".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@
|
||||
//! [`tools_bin_dirs`] on the process `PATH` at startup.
|
||||
|
||||
pub mod archive;
|
||||
mod bundled;
|
||||
pub mod formatter;
|
||||
pub mod jdk;
|
||||
pub mod platform;
|
||||
mod process;
|
||||
@@ -148,7 +146,7 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
|
||||
Method::PipVenv { .. } => {
|
||||
Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" }))
|
||||
}
|
||||
Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
|
||||
Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,9 +322,6 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
|
||||
}
|
||||
};
|
||||
for l in langs {
|
||||
if let Some(f) = &l.formatter {
|
||||
add(&f.tool, &f.tool, ToolKind::Formatter, None, &l.name);
|
||||
}
|
||||
for s in &l.language_servers {
|
||||
add(&s.name, &s.command, ToolKind::LanguageServer, s.install_hint.as_ref(), &l.name);
|
||||
}
|
||||
@@ -394,12 +389,9 @@ fn is_bundle(command: &str) -> bool {
|
||||
command.ends_with(".jar")
|
||||
}
|
||||
|
||||
/// Path of an app-provided or installed plugin jar. Embedded jars are materialized on demand.
|
||||
/// Path of an installed plugin bundle (`<tools>/<name>/bin/<jar>`), if present.
|
||||
pub fn managed_bundle(app_data: &Path, name: &str) -> Option<PathBuf> {
|
||||
let spec = registry::find(name)?;
|
||||
if matches!(spec.method, Method::Bundled { .. }) {
|
||||
return bundled::install(&tools_dir(app_data), spec).ok();
|
||||
}
|
||||
let jar = spec.commands.first().filter(|c| is_bundle(c))?;
|
||||
let path = tools_dir(app_data).join(spec.name).join("bin").join(jar);
|
||||
path.is_file().then_some(path)
|
||||
@@ -550,7 +542,6 @@ impl Tools {
|
||||
fn method_missing(&self, method: Method) -> Option<String> {
|
||||
let need = |ok: bool, msg: &str| (!ok).then(|| msg.to_string());
|
||||
match method {
|
||||
Method::Bundled { .. } => None,
|
||||
Method::Rustup { .. } => need(self.which("rustup").is_some(), "rustup is required (https://rustup.rs)"),
|
||||
Method::Npm { .. } => need(
|
||||
self.which_any(&["npm", "pnpm"]).is_some(),
|
||||
@@ -613,8 +604,7 @@ impl Tools {
|
||||
};
|
||||
let (status, path, version) = self.detect(spec);
|
||||
let unavailable_reason = self.unavailable_reason(spec);
|
||||
let bundled = matches!(spec.method, Method::Bundled { .. });
|
||||
let can_uninstall = status == ToolStatus::Installed && !bundled;
|
||||
let can_uninstall = status == ToolStatus::Installed;
|
||||
Some(ToolInfo {
|
||||
name: spec.name.to_string(),
|
||||
display_name: spec.display.to_string(),
|
||||
@@ -625,7 +615,7 @@ impl Tools {
|
||||
path: path.map(Into::into),
|
||||
version,
|
||||
install_method: Some(self.install_method(spec).label().to_string()),
|
||||
can_install: unavailable_reason.is_none() && !bundled,
|
||||
can_install: unavailable_reason.is_none(),
|
||||
unavailable_reason,
|
||||
can_uninstall,
|
||||
install_hint,
|
||||
@@ -664,12 +654,6 @@ impl Tools {
|
||||
}
|
||||
|
||||
fn detect(&self, spec: &ToolSpec) -> (ToolStatus, Option<PathBuf>, Option<String>) {
|
||||
if let Method::Bundled { version, .. } = spec.method {
|
||||
return match bundled::install(&self.root, spec) {
|
||||
Ok(path) => (ToolStatus::Installed, Some(path), Some(version.to_owned())),
|
||||
Err(_) => (ToolStatus::Missing, None, None),
|
||||
};
|
||||
}
|
||||
let main = spec.commands[0];
|
||||
let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main));
|
||||
if let Some(path) = managed {
|
||||
@@ -798,9 +782,6 @@ impl Tools {
|
||||
let method = self.install_method(spec);
|
||||
emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) });
|
||||
match method {
|
||||
Method::Bundled { .. } => {
|
||||
bundled::install(&self.root, spec)?;
|
||||
}
|
||||
Method::Rustup { component } => {
|
||||
let mut c = self.command(self.which("rustup").expect("checked"));
|
||||
c.args(["component", "add", component]);
|
||||
@@ -1207,8 +1188,6 @@ impl Tools {
|
||||
let cancel = CancellationToken::new();
|
||||
let quiet = |_: InstallEvent| {};
|
||||
match self.install_method(spec) {
|
||||
// App-owned resources remain available for every project/window.
|
||||
Method::Bundled { .. } => {}
|
||||
Method::Rustup { component } => {
|
||||
let rustup = self.which("rustup").ok_or_else(|| IdeError::unavailable("rustup not found"))?;
|
||||
let mut c = self.command(rustup);
|
||||
|
||||
@@ -348,89 +348,6 @@ pub fn progress_event(bytes: u64, size: Option<u64>) -> InstallEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a formatter with separate pipes so diagnostics can never become document text.
|
||||
pub(crate) fn run_input(
|
||||
cmd: &mut Command,
|
||||
input: &str,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
use std::io::Write;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let input = input.to_owned();
|
||||
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
|
||||
enum Pipe {
|
||||
Written(std::io::Result<()>),
|
||||
Read(bool, std::io::Result<Vec<u8>>),
|
||||
}
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
|
||||
});
|
||||
for (is_stderr, mut pipe) in [
|
||||
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
|
||||
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
|
||||
] {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
let started = Instant::now();
|
||||
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
|
||||
let result = loop {
|
||||
if cancel.is_cancelled() {
|
||||
break Err(IdeError::cancelled());
|
||||
}
|
||||
if started.elapsed() >= timeout {
|
||||
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
|
||||
}
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(s) => status = s,
|
||||
Err(e) => break Err(e.into()),
|
||||
}
|
||||
}
|
||||
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
|
||||
break Ok(());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(Pipe::Written(result)) => written = Some(result),
|
||||
Ok(Pipe::Read(true, result)) => errors = Some(result),
|
||||
Ok(Pipe::Read(false, result)) => output = Some(result),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
};
|
||||
if result.is_err() {
|
||||
kill(&mut child);
|
||||
}
|
||||
result?;
|
||||
let output = output.unwrap()?;
|
||||
let errors = errors.unwrap()?;
|
||||
let status = status.unwrap();
|
||||
if !status.success() {
|
||||
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
|
||||
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
|
||||
format!("Formatter exited with {status}")
|
||||
} else {
|
||||
diagnostics
|
||||
}));
|
||||
}
|
||||
written.unwrap()?;
|
||||
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -547,37 +464,3 @@ mod tests {
|
||||
assert_eq!(run_quiet(cmd, Duration::from_secs(5)), Some((true, "v1.2.3\n".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod input_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn separate_diagnostics_and_never_return_failed_output() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "cat; printf warning >&2"]);
|
||||
assert_eq!(run_input(&mut command, "a😀\n", Duration::from_secs(2), &Default::default()).unwrap(), "a😀\n");
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "printf broken; printf 'invalid syntax' >&2; exit 1"]);
|
||||
let e = run_input(&mut command, "", Duration::from_secs(2), &Default::default()).unwrap_err();
|
||||
assert_eq!(e.message, "invalid syntax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_kills_formatter_and_cancellation_prevents_start() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "sleep 10 &"]);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
run_input(&mut command, "", Duration::from_millis(50), &Default::default()).unwrap_err().kind,
|
||||
ErrorKind::TimedOut
|
||||
);
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
run_input(&mut Command::new("does-not-exist"), "", Duration::from_secs(2), &cancel).unwrap_err().kind,
|
||||
ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ pub enum ToolKind {
|
||||
Debugger,
|
||||
/// Loaded into another tool's process, e.g. a jdtls agent.
|
||||
Plugin,
|
||||
Formatter,
|
||||
}
|
||||
|
||||
/// Where release archives come from.
|
||||
@@ -32,8 +31,6 @@ pub enum ReleaseSource {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Method {
|
||||
/// An app-owned plugin jar, materialized without a network download.
|
||||
Bundled { filename: &'static str, version: &'static str, bytes: &'static [u8] },
|
||||
/// `rustup component add <component>`.
|
||||
Rustup { component: &'static str },
|
||||
/// npm / pnpm packages installed into `<tools>/node_modules`; the first is the primary one.
|
||||
@@ -59,7 +56,6 @@ pub enum Method {
|
||||
impl Method {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Method::Bundled { .. } => "Bundled",
|
||||
Method::Rustup { .. } => "rustup",
|
||||
Method::Npm { .. } => "npm",
|
||||
Method::Go { .. } => "go install",
|
||||
@@ -106,7 +102,7 @@ impl ToolSpec {
|
||||
|
||||
use Arch::{Arm64, X64};
|
||||
use Os::{Linux, Mac, Windows};
|
||||
use ToolKind::{Debugger, Formatter, LanguageServer, Plugin};
|
||||
use ToolKind::{Debugger, LanguageServer, Plugin};
|
||||
|
||||
const V: Option<(&str, &[&str])> = None;
|
||||
const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")];
|
||||
@@ -123,17 +119,6 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str]
|
||||
pub const TYPESCRIPT: &str = "typescript@6";
|
||||
|
||||
pub static TOOLS: &[ToolSpec] = &[
|
||||
ToolSpec {
|
||||
name: "prettier",
|
||||
display: "Prettier",
|
||||
kind: Formatter,
|
||||
commands: &["prettier"],
|
||||
method: Method::Npm { packages: &["prettier"] },
|
||||
fallback: None,
|
||||
version: ver("prettier"),
|
||||
needs: NODE,
|
||||
homepage: "https://prettier.io",
|
||||
},
|
||||
ToolSpec {
|
||||
name: "rust-analyzer",
|
||||
display: "rust-analyzer",
|
||||
@@ -593,13 +578,15 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
display: "Lombok (jdtls agent)",
|
||||
kind: Plugin,
|
||||
commands: &["lombok.jar"],
|
||||
// Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup.
|
||||
// Return to Maven Central once an official release includes lombok#4059.
|
||||
// Source commit, build instructions and checksum: plugins/lombok/README.md.
|
||||
method: Method::Bundled {
|
||||
filename: "lombok-1.18.48-openidea.1.jar",
|
||||
version: "1.18.48-openidea.1",
|
||||
bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.1.jar"),
|
||||
// Newer than the Lombok a project pins: old ones fail inside current ECJ.
|
||||
method: Method::Release {
|
||||
source: ReleaseSource::MavenCentral { group: "org.projectlombok", artifact: "lombok" },
|
||||
assets: &[
|
||||
any_arch(Mac, "lombok-*.jar"),
|
||||
any_arch(Linux, "lombok-*.jar"),
|
||||
any_arch(Windows, "lombok-*.jar"),
|
||||
],
|
||||
locate: &["lombok.jar"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
@@ -955,11 +942,6 @@ mod tests {
|
||||
known.insert((d.adapter.clone(), d.command.clone()));
|
||||
}
|
||||
}
|
||||
for l in &langs {
|
||||
if let Some(f) = &l.formatter {
|
||||
known.insert((f.tool.clone(), f.tool.clone()));
|
||||
}
|
||||
}
|
||||
for t in TOOLS.iter().filter(|t| t.kind != Plugin) {
|
||||
assert!(
|
||||
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
|
||||
|
||||
@@ -434,6 +434,7 @@ mod tests {
|
||||
assert_eq!(d.version, "1.18.48");
|
||||
assert_eq!(d.name, "lombok-1.18.48.jar");
|
||||
assert_eq!(d.url, "https://repo1.maven.org/maven2/org/projectlombok/lombok/1.18.48/lombok-1.18.48.jar");
|
||||
assert!(select_asset(&[d.name.as_str()], find("lombok").unwrap().asset_rules(), LINUX).is_some());
|
||||
assert!(maven_pick("g", "a", "<metadata/>").is_err());
|
||||
assert!(maven_pick("g", "a", "<release>../x</release>").is_err());
|
||||
}
|
||||
|
||||
@@ -43,61 +43,6 @@ fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo {
|
||||
list.iter().find(|t| t.name == name).unwrap_or_else(|| panic!("{name} not listed"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_lombok_replaces_legacy_installs_on_every_platform_without_downloads() {
|
||||
for os in [Os::Mac, Os::Linux, Os::Windows] {
|
||||
for arch in [platform::Arch::Arm64, platform::Arch::X64] {
|
||||
let env = Env::new();
|
||||
let legacy = env.root.join("lombok/bin/lombok.jar");
|
||||
fs::create_dir_all(legacy.parent().unwrap()).unwrap();
|
||||
fs::write(&legacy, b"old official agent").unwrap();
|
||||
fs::write(env.root.join("lombok").join(MANIFEST), r#"{"version":"1.18.48"}"#).unwrap();
|
||||
let tools = Tools::with_paths(env.root.clone(), OsString::new(), Platform { os, arch });
|
||||
let info = tools.info("lombok", &[]).unwrap();
|
||||
let path = managed_bundle(env.root.parent().unwrap(), "lombok").unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Installed);
|
||||
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.1"));
|
||||
assert_eq!(info.path, Some(path.clone().into()));
|
||||
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.1.jar");
|
||||
assert!(!info.can_install && !info.can_uninstall);
|
||||
assert_eq!(tools.unavailable_reason(registry::find("lombok").unwrap()), None);
|
||||
assert_eq!(fs::read(&legacy).unwrap(), b"old official agent", "other windows may still use it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_lombok_is_available_offline_and_repairs_missing_or_corrupt_files() {
|
||||
let env = Env::new();
|
||||
let data = env.root.parent().unwrap();
|
||||
let agent = managed_bundle(data, "lombok").unwrap();
|
||||
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
|
||||
assert_eq!(sources::sha256_file(&agent).unwrap(), checksum);
|
||||
for contents in [None, Some(b"incomplete jar".as_slice())] {
|
||||
if let Some(contents) = contents {
|
||||
fs::write(&agent, contents).unwrap();
|
||||
} else {
|
||||
fs::remove_file(&agent).unwrap();
|
||||
}
|
||||
assert_eq!(managed_bundle(data, "lombok"), Some(agent.clone()));
|
||||
assert_eq!(sources::sha256_file(&agent).unwrap(), checksum);
|
||||
}
|
||||
assert_eq!(fs::read_dir(agent.parent().unwrap()).unwrap().count(), 1, "no temporary jars left behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_windows_get_the_same_complete_bundled_lombok() {
|
||||
let env = Env::new();
|
||||
let data = env.root.parent().unwrap();
|
||||
std::thread::scope(|scope| {
|
||||
let windows: Vec<_> = (0..8).map(|_| scope.spawn(|| managed_bundle(data, "lombok").unwrap())).collect();
|
||||
let paths: Vec<_> = windows.into_iter().map(|window| window.join().unwrap()).collect();
|
||||
assert!(paths.iter().all(|path| path == &paths[0]));
|
||||
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
|
||||
assert_eq!(sources::sha256_file(&paths[0]).unwrap(), checksum);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_parsing() {
|
||||
assert_eq!(parse_version("rust-analyzer 1.85.0 (4d91de4e 2025-02-17)").as_deref(), Some("1.85.0"));
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//! `cargo test -p ide-tools --test prettier -- --ignored --nocapture`
|
||||
//! Installs into a temporary managed-tools directory; no repository dependency is added.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_language::FormatterConfig;
|
||||
use ide_tools::{Tools, formatter};
|
||||
use std::path::Path;
|
||||
|
||||
fn format(
|
||||
tools: &Tools,
|
||||
path: &Path,
|
||||
parser: &str,
|
||||
text: &str,
|
||||
range: Option<(u32, u32)>,
|
||||
) -> ide_core::IdeResult<String> {
|
||||
let config = FormatterConfig {
|
||||
tool: "prettier".into(),
|
||||
parser: parser.into(),
|
||||
range: matches!(parser, "typescript" | "json" | "graphql"),
|
||||
};
|
||||
let mut cmd = tools.formatter_command("prettier", path, None)?;
|
||||
cmd.current_dir(path.parent().unwrap());
|
||||
cmd.args(formatter::arguments(&config, path, 4, true, range));
|
||||
formatter::run(cmd, text, &CancellationToken::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Node.js/npm and network; installs real Prettier into a temporary managed directory"]
|
||||
fn real_prettier_config_ignore_languages_ranges_and_errors() {
|
||||
if std::env::var_os("OPENIDEA_TEST_OFFLINE").is_some() {
|
||||
eprintln!("SKIP real Prettier: OPENIDEA_TEST_OFFLINE is set; npm network access is disabled");
|
||||
return;
|
||||
}
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let tools = Tools::new(&temp.path().join("app-data"));
|
||||
let info = tools.info("prettier", &[]).unwrap();
|
||||
if !info.can_install {
|
||||
eprintln!("SKIP real Prettier: {}", info.unavailable_reason.unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
tools.install("prettier", &[], &Default::default(), &|_| {}).expect(
|
||||
"Prettier managed install failed; offline environments may set OPENIDEA_TEST_OFFLINE=1 (reported as SKIP)",
|
||||
);
|
||||
let project = temp.path().join("project with spaces");
|
||||
std::fs::create_dir_all(project.join("docs/nested")).unwrap();
|
||||
let md = project.join("docs/nested/readme.md");
|
||||
let input = "# Title\ntext\n\n| a | b |\n|---|---|\n|1|2|\n\n- a\n - b\n";
|
||||
let output = format(&tools, &md, "markdown", input, None).unwrap();
|
||||
assert!(output.starts_with("# Title\n\ntext\n"), "{output}");
|
||||
assert!(output.contains("| 1 | 2 |"), "{output}");
|
||||
assert!(!md.exists(), "formatting must never write the file");
|
||||
std::fs::write(project.join(".prettierrc"), r#"{"proseWrap":"always","printWidth":24,"tabWidth":2}"#).unwrap();
|
||||
let prose =
|
||||
"This is a long paragraph with several words that should wrap according to the project configuration.\n";
|
||||
assert!(format(&tools, &md, "markdown", prose, None).unwrap().lines().count() > 2);
|
||||
std::fs::write(project.join(".prettierignore"), "docs/nested/readme.md\n").unwrap();
|
||||
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), input);
|
||||
std::fs::remove_file(project.join(".prettierignore")).unwrap();
|
||||
std::fs::remove_file(project.join(".prettierrc")).unwrap();
|
||||
assert_eq!(
|
||||
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
|
||||
"a {\n color: red;\n}\n"
|
||||
);
|
||||
std::fs::write(project.join("package.json"), r#"{"prettier":{"tabWidth":3,"proseWrap":"never"}}"#).unwrap();
|
||||
assert_eq!(
|
||||
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
|
||||
"a {\n color: red;\n}\n"
|
||||
);
|
||||
assert_eq!(format(&tools, &project.join("a.yaml"), "yaml", "a: [1,2]\n", None).unwrap(), "a: [1, 2]\n");
|
||||
let js = "const a={x:1};\nconst b={y:2};\n";
|
||||
let formatted = format(&tools, &project.join("a.ts"), "typescript", js, Some((0, 14))).unwrap();
|
||||
assert!(formatted.starts_with("const a = { x: 1 };"), "{formatted}");
|
||||
assert!(formatted.contains("const b={y:2};"), "{formatted}");
|
||||
assert!(format(&tools, &project.join("bad.css"), "css", "a {", None).is_err());
|
||||
// Nearest project version must win over the managed install, including npm's object-style bin.
|
||||
let package = project.join("node_modules/prettier");
|
||||
std::fs::create_dir_all(&package).unwrap();
|
||||
std::fs::write(package.join("package.json"), r#"{"bin":{"prettier":"cli.cjs"}}"#).unwrap();
|
||||
std::fs::write(
|
||||
package.join("cli.cjs"),
|
||||
"process.stdin.resume(); process.stdin.on('end', () => process.stdout.write('project version\\n')); ",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), "project version\n");
|
||||
std::fs::write(package.join("cli.cjs"), "setInterval(() => {}, 1000)").unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
assert!(format(&tools, &md, "markdown", input, None).unwrap_err().message.contains("timed out"));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(20));
|
||||
eprintln!(
|
||||
"PASS: real managed Prettier, config/ignore discovery, editor defaults, YAML/CSS/Markdown, ranges, errors, project precedence, timeout; no disk writes"
|
||||
);
|
||||
}
|
||||
@@ -348,55 +348,6 @@ fn run_raw_env_once(
|
||||
Ok(Output { stdout: out.stdout, stderr: out.stderr, code: out.status.code() })
|
||||
}
|
||||
|
||||
/// Captures binary/NUL-delimited read output while allowing cancellation to kill git.
|
||||
pub(crate) fn run_cancellable(root: &Path, args: &[&str], cancel: &CancellationToken) -> IdeResult<Output> {
|
||||
let out = run_cancellable_raw(root, args, cancel)?;
|
||||
if out.code == Some(0) { Ok(out) } else { Err(failure(args, &out.stderr, &out.stdout, out.code)) }
|
||||
}
|
||||
|
||||
/// Cancellable capture preserving exit codes for commands with documented non-error states.
|
||||
pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &CancellationToken) -> IdeResult<Output> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut cmd = command(root)?;
|
||||
cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
let mut session = CommandSession::start(&mut cmd);
|
||||
let mut child = cmd.spawn().map_err(spawn_error)?;
|
||||
let capture = |mut pipe: Box<dyn Read + Send>| {
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
pipe.read_to_end(&mut bytes).map(|_| bytes)
|
||||
})
|
||||
};
|
||||
let stdout = capture(Box::new(child.stdout.take().expect("piped stdout")));
|
||||
let stderr = capture(Box::new(child.stderr.take().expect("piped stderr")));
|
||||
let status = loop {
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
let _ = child.wait();
|
||||
let _ = stdout.join();
|
||||
let _ = stderr.join();
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
let join = |thread: std::thread::JoinHandle<std::io::Result<Vec<u8>>>| {
|
||||
thread.join().map_err(|_| IdeError::internal("git output reader panicked"))?.map_err(IdeError::from)
|
||||
};
|
||||
let out = Output { stdout: join(stdout)?, stderr: join(stderr)?, code: status.code() };
|
||||
session.finish(status.success(), &String::from_utf8_lossy(&out.stderr));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Runs git and maps a non-zero exit status to an error carrying git's message.
|
||||
pub(crate) fn run(root: &Path, args: &[&str], stdin: Option<&[u8]>) -> IdeResult<Output> {
|
||||
let out = run_raw(root, args, stdin)?;
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
//! Selection-scoped revision → working-tree comparison over the existing root registry.
|
||||
use crate::{ChangeKind, Repos, cli, ops};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, IpcPath};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompareScope {
|
||||
pub repositories: Vec<IpcPath>,
|
||||
/// Selected content outside registered repositories; workspace parents expand to their skipped children.
|
||||
pub skipped_paths: Vec<IpcPath>,
|
||||
pub single_file: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkingTreeChange {
|
||||
pub path: String,
|
||||
pub kind: ChangeKind,
|
||||
pub old_path: Option<String>,
|
||||
pub added: u32,
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RepositoryComparison {
|
||||
pub root: IpcPath,
|
||||
/// Resolved commit, so opening a diff later uses the same left side as the listing.
|
||||
pub revision: Option<String>,
|
||||
pub files: Vec<WorkingTreeChange>,
|
||||
pub error: Option<String>,
|
||||
/// The requested revision does not resolve to a commit in this repository.
|
||||
#[serde(default)]
|
||||
pub missing_revision: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompareResult {
|
||||
pub repositories: Vec<RepositoryComparison>,
|
||||
pub skipped_paths: Vec<IpcPath>,
|
||||
}
|
||||
|
||||
fn normalized(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn comparison_children(path: &Path) -> Vec<PathBuf> {
|
||||
std::fs::read_dir(path)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_name() != ".git" && entry.file_name() != ".openidea")
|
||||
.map(|entry| entry.path())
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Repos {
|
||||
pub fn compare_scope(&self, paths: &[PathBuf]) -> CompareScope {
|
||||
let paths: Vec<_> = paths.iter().map(|p| normalized(p)).collect();
|
||||
let repositories = self
|
||||
.all()
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
let root = normalized(r.root());
|
||||
paths.iter().any(|p| {
|
||||
root.starts_with(p)
|
||||
|| self.at_root(p).or_else(|| self.for_path(p)).is_some_and(|owner| owner.root() == r.root())
|
||||
})
|
||||
})
|
||||
.map(|r| IpcPath(r.root().to_path_buf()))
|
||||
.collect();
|
||||
let mut skipped = std::collections::BTreeSet::new();
|
||||
for path in &paths {
|
||||
if self.at_root(path).or_else(|| self.for_path(path)).is_some() {
|
||||
continue;
|
||||
}
|
||||
if self.all().iter().any(|r| r.root().starts_with(path)) {
|
||||
for child in comparison_children(path) {
|
||||
if self.has_outside_content(&child) {
|
||||
skipped.insert(child);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
skipped.insert(path.clone());
|
||||
}
|
||||
}
|
||||
let skipped_paths = skipped
|
||||
.into_iter()
|
||||
.map(|mut path| {
|
||||
if path.is_dir() {
|
||||
path.push("");
|
||||
}
|
||||
IpcPath(path)
|
||||
})
|
||||
.collect();
|
||||
CompareScope { repositories, skipped_paths, single_file: paths.len() == 1 && !paths[0].is_dir() }
|
||||
}
|
||||
|
||||
fn has_outside_content(&self, path: &Path) -> bool {
|
||||
if self.at_root(path).or_else(|| self.for_path(path)).is_some() {
|
||||
return false;
|
||||
}
|
||||
!self.all().iter().any(|r| r.root().starts_with(path))
|
||||
|| comparison_children(path).iter().any(|child| self.has_outside_content(child))
|
||||
}
|
||||
|
||||
pub fn compare_paths(
|
||||
&self,
|
||||
paths: &[PathBuf],
|
||||
revision: &str,
|
||||
repositories: Option<&[PathBuf]>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<CompareResult> {
|
||||
if revision.trim().is_empty() || revision.starts_with('-') {
|
||||
return Err(IdeError::invalid("invalid revision"));
|
||||
}
|
||||
let scope = self.compare_scope(paths);
|
||||
let paths: Vec<_> = paths.iter().map(|p| normalized(p)).collect();
|
||||
let mut result = CompareResult { repositories: Vec::new(), skipped_paths: scope.skipped_paths };
|
||||
for root in scope.repositories {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if repositories.is_some_and(|roots| !roots.iter().any(|r| normalized(r) == normalized(&root.0))) {
|
||||
continue;
|
||||
}
|
||||
let repo = self.at_root(&root.0).expect("scope comes from registry");
|
||||
let _scope =
|
||||
crate::ExecutionScope::enter(repo.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let mut missing_revision = false;
|
||||
let mut compare = || -> IdeResult<(String, Vec<WorkingTreeChange>)> {
|
||||
// Worktree comparisons must not run repository-defined clean/process filters
|
||||
// in Restricted Mode. External diff and textconv are disabled below as well.
|
||||
let mut overrides = Vec::new();
|
||||
if !crate::execution_allowed() {
|
||||
let config = cli::run_cancellable(repo.root(), &["config", "--null", "--list"], cancel)?;
|
||||
for record in config.stdout_str().split('\0') {
|
||||
let key = record.split('\n').next().unwrap_or_default();
|
||||
if key.starts_with("filter.")
|
||||
&& (key.ends_with(".clean") || key.ends_with(".process") || key.ends_with(".required"))
|
||||
{
|
||||
overrides.push("-c".to_owned());
|
||||
overrides.push(format!("{key}={}", if key.ends_with(".required") { "false" } else { "" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
let run = |args: &[&str]| {
|
||||
let mut full: Vec<&str> = overrides.iter().map(String::as_str).collect();
|
||||
full.extend_from_slice(args);
|
||||
cli::run_cancellable(repo.root(), &full, cancel)
|
||||
};
|
||||
// --quiet documents exit 1 for an unresolved revision; other failures keep their diagnostics.
|
||||
let resolve_args =
|
||||
["rev-parse", "--verify", "--quiet", "--end-of-options", &format!("{revision}^{{commit}}")];
|
||||
let resolved = cli::run_cancellable_raw(repo.root(), &resolve_args, cancel)?;
|
||||
if resolved.code != Some(0) {
|
||||
missing_revision = resolved.code == Some(1);
|
||||
return Err(cli::failure(&resolve_args, &resolved.stderr, &resolved.stdout, resolved.code));
|
||||
}
|
||||
let resolved = resolved.stdout_str().trim().to_owned();
|
||||
// Disable textconv/external diff; these are also safe in untrusted projects.
|
||||
let names =
|
||||
run(&["diff", "--name-status", "-z", "-M", "--no-ext-diff", "--no-textconv", &resolved, "--"])?;
|
||||
let stats = run(&[
|
||||
"diff",
|
||||
"--numstat",
|
||||
"-z",
|
||||
"--no-renames",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
&resolved,
|
||||
"--",
|
||||
])?;
|
||||
let stats: std::collections::HashMap<_, _> = ops::parse_numstat(&stats.stdout_str())
|
||||
.into_iter()
|
||||
.map(|(path, added, removed)| (path, (added, removed)))
|
||||
.collect();
|
||||
let mut files = Vec::new();
|
||||
let contains = |rel: &str| {
|
||||
let absolute = repo.root().join(rel);
|
||||
// Nested clones contribute their own files, never the enclosing repo's entries.
|
||||
let own = self.at_root(&absolute).or_else(|| self.for_path(&absolute));
|
||||
own.is_some_and(|r| r.root() == repo.root()) && paths.iter().any(|p| absolute.starts_with(p))
|
||||
};
|
||||
for mut file in ops::parse_name_status(&names.stdout_str()) {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let new_in = contains(&file.path);
|
||||
let old_in = file.old_path.as_deref().is_some_and(contains);
|
||||
if !new_in && !old_in {
|
||||
continue;
|
||||
}
|
||||
let original = file.path.clone();
|
||||
if file.old_path.is_some() && new_in != old_in {
|
||||
if old_in {
|
||||
file.path = file.old_path.take().unwrap();
|
||||
file.kind = ChangeKind::Deleted;
|
||||
} else {
|
||||
file.old_path = None;
|
||||
file.kind = ChangeKind::Added;
|
||||
}
|
||||
}
|
||||
let count = |path: &str| stats.get(path).copied().unwrap_or_default();
|
||||
let (added, removed) = match &file.old_path {
|
||||
Some(old) => (count(&original).0, count(old).1),
|
||||
None => count(&file.path),
|
||||
};
|
||||
files.push(WorkingTreeChange {
|
||||
path: file.path,
|
||||
kind: file.kind,
|
||||
old_path: file.old_path,
|
||||
added,
|
||||
removed,
|
||||
});
|
||||
}
|
||||
if files.iter().any(|f| f.kind == ChangeKind::Renamed) {
|
||||
let renamed =
|
||||
run(&["diff", "--numstat", "-z", "-M", "--no-ext-diff", "--no-textconv", &resolved, "--"])?;
|
||||
let indexes: std::collections::HashMap<_, _> = files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| f.kind == ChangeKind::Renamed)
|
||||
.map(|(index, f)| (f.path.clone(), index))
|
||||
.collect();
|
||||
let mut records =
|
||||
renamed.stdout_str().split('\0').map(str::to_owned).collect::<Vec<_>>().into_iter();
|
||||
while let Some(record) = records.next() {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let fields: Vec<_> = record.splitn(3, '\t').collect();
|
||||
if fields.len() == 3 && fields[2].is_empty() {
|
||||
let _old = records.next();
|
||||
if let Some(path) = records.next()
|
||||
&& let Some(index) = indexes.get(&path)
|
||||
{
|
||||
let file = &mut files[*index];
|
||||
file.added = fields[0].parse().unwrap_or(0);
|
||||
file.removed = fields[1].parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let untracked = run(&["ls-files", "--others", "--exclude-standard", "-z"])?;
|
||||
for path in untracked.stdout_str().split('\0').filter(|p| !p.is_empty() && contains(p)) {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let absolute = repo.root().join(path);
|
||||
// Match the existing diff viewer's large/binary-file policy without loading large files for counts.
|
||||
let added = if std::fs::metadata(&absolute).is_ok_and(|m| m.len() <= 10 * 1024 * 1024) {
|
||||
let bytes = std::fs::read(&absolute).unwrap_or_default();
|
||||
if bytes.contains(&0) {
|
||||
0
|
||||
} else {
|
||||
(bytes.iter().filter(|b| **b == b'\n').count()
|
||||
+ usize::from(!bytes.is_empty() && bytes.last() != Some(&b'\n')))
|
||||
as u32
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
files.push(WorkingTreeChange {
|
||||
path: path.to_owned(),
|
||||
kind: ChangeKind::Untracked,
|
||||
old_path: None,
|
||||
added,
|
||||
removed: 0,
|
||||
});
|
||||
}
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok((resolved, files))
|
||||
};
|
||||
let comparison = match compare() {
|
||||
Ok((revision, files)) => {
|
||||
RepositoryComparison { root, revision: Some(revision), files, error: None, missing_revision: false }
|
||||
}
|
||||
Err(_) if cancel.is_cancelled() => return Err(IdeError::cancelled()),
|
||||
Err(e) => RepositoryComparison {
|
||||
root,
|
||||
revision: None,
|
||||
files: Vec::new(),
|
||||
error: (!missing_revision).then_some(e.message),
|
||||
missing_revision,
|
||||
},
|
||||
};
|
||||
result.repositories.push(comparison);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
fn write(root: &Path, path: &str, text: &str) {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
fn init(root: &Path, branch: &str) {
|
||||
std::fs::create_dir_all(root).unwrap();
|
||||
git(root, &["init", "-q", "-b", branch]);
|
||||
git(root, &["config", "user.name", "Test"]);
|
||||
git(root, &["config", "user.email", "[email protected]"]);
|
||||
write(root, "src/文件 name.txt", "old\n");
|
||||
write(root, "src/move.txt", "move\n");
|
||||
write(root, "other/stable.txt", "stable\n");
|
||||
git(root, &["add", "."]);
|
||||
git(root, &["commit", "-qm", "initial"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtree_union_worktree_untracked_and_rename_boundaries() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = normalized(temp.path());
|
||||
init(&root, "main");
|
||||
git(&root, &["switch", "-qc", "feature"]);
|
||||
git(&root, &["mv", "src/move.txt", "other/moved.txt"]);
|
||||
git(&root, &["commit", "-qm", "move out"]);
|
||||
write(&root, "src/文件 name.txt", "old\nnew\n");
|
||||
write(&root, "src/untracked space 中文.txt", "new\nfile\n");
|
||||
write(&root, "other/stable.txt", "changed\n");
|
||||
let repos = Repos::discover(&root, &|_| false).unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let result = repos.compare_paths(&[root.join("src")], "main", None, &cancel).unwrap();
|
||||
assert_eq!(result.repositories.len(), 1);
|
||||
let files = &result.repositories[0].files;
|
||||
assert_eq!(files.len(), 3, "{files:?}");
|
||||
let moved = files.iter().find(|f| f.path == "src/move.txt").unwrap();
|
||||
assert_eq!(moved.kind, ChangeKind::Deleted);
|
||||
assert_eq!((moved.added, moved.removed), (0, 1));
|
||||
assert!(moved.old_path.is_none());
|
||||
let changed = files.iter().find(|f| f.path == "src/文件 name.txt").unwrap();
|
||||
assert_eq!((changed.added, changed.removed), (1, 0));
|
||||
let untracked = files.iter().find(|f| f.path == "src/untracked space 中文.txt").unwrap();
|
||||
assert_eq!((untracked.kind, untracked.added), (ChangeKind::Untracked, 2));
|
||||
let union = repos
|
||||
.compare_paths(&[root.join("src"), root.join("other"), root.join("src")], "main", None, &cancel)
|
||||
.unwrap();
|
||||
assert_eq!(union.repositories[0].files.len(), 4);
|
||||
let rename = union.repositories[0].files.iter().find(|f| f.path == "other/moved.txt").unwrap();
|
||||
assert_eq!(rename.kind, ChangeKind::Renamed);
|
||||
assert_eq!(rename.old_path.as_deref(), Some("src/move.txt"));
|
||||
assert_eq!((rename.added, rename.removed), (0, 0));
|
||||
let into = repos.compare_paths(&[root.join("other")], "main", None, &cancel).unwrap();
|
||||
assert_eq!(
|
||||
into.repositories[0].files.iter().find(|f| f.path == "other/moved.txt").unwrap().kind,
|
||||
ChangeKind::Added
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_roots_missing_revision_loose_paths_and_empty_results() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = normalized(temp.path());
|
||||
for name in ["client", "server", "admin"] {
|
||||
init(&root.join(name), if name == "admin" { "trunk" } else { "main" });
|
||||
}
|
||||
write(&root, "loose/readme.txt", "not git\n");
|
||||
write(&root.join("client"), "src/new.txt", "client\n");
|
||||
write(&root.join("server"), "src/文件 name.txt", "server\n");
|
||||
let repos = Repos::discover(&root, &|_| false).unwrap();
|
||||
let scope = repos.compare_scope(std::slice::from_ref(&root));
|
||||
assert_eq!(scope.repositories.len(), 3);
|
||||
assert_eq!(scope.skipped_paths, [IpcPath(root.join("loose"))]);
|
||||
assert!(!scope.single_file);
|
||||
assert!(repos.compare_scope(&[root.join("client/src/文件 name.txt")]).single_file);
|
||||
let cancel = CancellationToken::new();
|
||||
let result = repos.compare_paths(&[root.clone(), root.join("loose")], "main", None, &cancel).unwrap();
|
||||
assert_eq!(result.repositories.len(), 3);
|
||||
assert_eq!(result.repositories.iter().filter(|r| r.missing_revision).count(), 1);
|
||||
assert!(result.repositories[0].missing_revision, "admin lacks main");
|
||||
assert!(result.repositories[0].error.is_none());
|
||||
assert_eq!(result.repositories[1].files.len(), 1);
|
||||
assert_eq!(result.repositories[2].files.len(), 1);
|
||||
assert_eq!(result.skipped_paths, [IpcPath(root.join("loose"))]);
|
||||
let filtered =
|
||||
repos.compare_paths(std::slice::from_ref(&root), "main", Some(&[root.join("server")]), &cancel).unwrap();
|
||||
assert_eq!(filtered.repositories.len(), 1);
|
||||
assert_eq!(filtered.repositories[0].root.0, root.join("server"));
|
||||
let empty = repos.compare_paths(&[root.join("server/other")], "main", None, &cancel).unwrap();
|
||||
assert!(empty.repositories[0].files.is_empty());
|
||||
let loose = repos.compare_paths(&[root.join("loose")], "main", None, &cancel).unwrap();
|
||||
assert!(loose.repositories.is_empty());
|
||||
assert_eq!(loose.skipped_paths, [IpcPath(root.join("loose"))]);
|
||||
cancel.cancel();
|
||||
assert!(repos.compare_paths(&[root], "main", None, &cancel).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_repositories_are_not_reported_twice_and_binary_counts_are_zero() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = normalized(temp.path());
|
||||
init(&root, "main");
|
||||
init(&root.join("nested"), "main");
|
||||
write(&root.join("nested"), "src/new.txt", "nested\n");
|
||||
std::fs::write(root.join("src/image.bin"), [0, 1, 2]).unwrap();
|
||||
let repos = Repos::discover(&root, &|_| false).unwrap();
|
||||
let result = repos.compare_paths(std::slice::from_ref(&root), "main", None, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(result.repositories.len(), 2);
|
||||
assert_eq!(result.repositories[0].files.len(), 1);
|
||||
assert_eq!(result.repositories[0].files[0].added, 0);
|
||||
assert_eq!(result.repositories[1].files.len(), 1);
|
||||
let nested = repos.compare_paths(&[root.join("nested")], "main", None, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(nested.repositories.len(), 1);
|
||||
assert_eq!(nested.repositories.iter().map(|r| r.files.len()).sum::<usize>(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn skipped_children_only_report_content_outside_repositories() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = normalized(temp.path());
|
||||
init(&root.join("services/server"), "main");
|
||||
init(&root.join("client"), "main");
|
||||
write(&root, ".openidea/state.json", "{}");
|
||||
write(&root, ".git/config", "ignored metadata");
|
||||
let repos = Repos::discover(&root, &|_| false).unwrap();
|
||||
assert!(repos.compare_scope(std::slice::from_ref(&root)).skipped_paths.is_empty());
|
||||
write(&root, "services/notes.txt", "loose nested content");
|
||||
write(&root, "loose/readme.txt", "loose");
|
||||
write(&root, "readme.txt", "loose file");
|
||||
let scope = repos.compare_scope(&[root.clone(), root.join("loose")]);
|
||||
assert_eq!(
|
||||
scope.skipped_paths,
|
||||
[IpcPath(root.join("loose")), IpcPath(root.join("readme.txt")), IpcPath(root.join("services")),]
|
||||
);
|
||||
assert!(scope.skipped_paths[0].0.to_string_lossy().ends_with(std::path::MAIN_SEPARATOR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_revision_is_typed_and_other_errors_keep_only_the_message() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = normalized(temp.path());
|
||||
init(&root, "main");
|
||||
let repos = Repos::discover(&root, &|_| false).unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let result = repos.compare_paths(std::slice::from_ref(&root), "missing-revision", None, &cancel).unwrap();
|
||||
assert!(result.repositories[0].missing_revision);
|
||||
assert!(result.repositories[0].error.is_none());
|
||||
std::fs::write(root.join(".git/config"), "[broken").unwrap();
|
||||
let result = repos.compare_paths(&[root], "main", None, &cancel).unwrap();
|
||||
assert!(!result.repositories[0].missing_revision);
|
||||
let error = result.repositories[0].error.as_deref().unwrap();
|
||||
assert!(error.contains("bad config"), "{error}");
|
||||
assert!(!error.starts_with("Internal:"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn untracked_read_and_metadata_failures_leave_files_with_zero_counts() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = normalized(temp.path());
|
||||
init(&root, "main");
|
||||
// Reading a directory fails after successful metadata; a dangling link fails metadata.
|
||||
symlink(root.join("src"), root.join("unreadable")).unwrap();
|
||||
symlink(root.join("vanished"), root.join("missing")).unwrap();
|
||||
write(&root, "new.txt", "still counted\n");
|
||||
let repos = Repos::discover(&root, &|_| false).unwrap();
|
||||
let result = repos.compare_paths(&[root], "main", None, &CancellationToken::new()).unwrap();
|
||||
let repo = &result.repositories[0];
|
||||
assert!(repo.error.is_none(), "{repo:?}");
|
||||
assert_eq!(repo.files.len(), 3);
|
||||
for name in ["unreadable", "missing"] {
|
||||
let file = repo.files.iter().find(|f| f.path == name).unwrap();
|
||||
assert_eq!((file.kind, file.added, file.removed), (ChangeKind::Untracked, 0, 0));
|
||||
}
|
||||
assert_eq!(repo.files.iter().find(|f| f.path == "new.txt").unwrap().added, 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn restricted_comparison_does_not_execute_repository_commands() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = normalized(temp.path());
|
||||
init(&root, "main");
|
||||
let script = root.join(".git/marker.sh");
|
||||
let marker = root.join(".git/executed");
|
||||
std::fs::write(&script, "#!/bin/sh\ntouch .git/executed\n").unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let script = script.to_str().unwrap();
|
||||
for key in [
|
||||
"core.fsmonitor",
|
||||
"filter.marker.clean",
|
||||
"filter.marker.process",
|
||||
"diff.marker.command",
|
||||
"diff.marker.textconv",
|
||||
] {
|
||||
git(&root, &["config", key, script]);
|
||||
}
|
||||
git(&root, &["config", "filter.marker.required", "true"]);
|
||||
write(&root, ".gitattributes", "*.txt filter=marker diff=marker\n");
|
||||
write(&root, "src/文件 name.txt", "changed\n");
|
||||
write(&root, "new.txt", "untracked\n");
|
||||
let repos = Repos::discover(&root, &|_| false).unwrap();
|
||||
for repo in repos.all() {
|
||||
repo.set_execution_allowed(false);
|
||||
}
|
||||
let result = repos.compare_paths(std::slice::from_ref(&root), "main", None, &CancellationToken::new()).unwrap();
|
||||
assert!(result.repositories[0].error.is_none(), "{result:?}");
|
||||
assert_eq!(result.repositories[0].files.len(), 3);
|
||||
assert!(!marker.exists(), "Restricted Mode executed a repository command");
|
||||
// Prove the fixture's fsmonitor hook is active when the shared wrapper is bypassed.
|
||||
git(&root, &["ls-files"]);
|
||||
assert!(marker.exists(), "control invocation did not run fsmonitor");
|
||||
}
|
||||
}
|
||||
@@ -453,7 +453,6 @@ pub struct ChangelistState {
|
||||
mod blame;
|
||||
pub mod changelists;
|
||||
mod cli;
|
||||
mod compare;
|
||||
mod diff;
|
||||
mod history;
|
||||
mod ignore;
|
||||
@@ -465,7 +464,6 @@ mod refs;
|
||||
mod remotes;
|
||||
mod repo;
|
||||
mod roots;
|
||||
pub use compare::{CompareResult, CompareScope, RepositoryComparison, WorkingTreeChange};
|
||||
pub mod shelf;
|
||||
mod status;
|
||||
pub use index_writes::is_own_index_write;
|
||||
|
||||
@@ -3,7 +3,7 @@ use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// File charset plus an optional ASCII representation (for properties).
|
||||
/// File charset plus an optional reversible ASCII representation (for properties).
|
||||
/// The host supplies conversion functions so Git and documents share one codec.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TextEncoding {
|
||||
|
||||
@@ -1555,14 +1555,6 @@ fn restricted_reads_ignore_executable_repository_configuration() {
|
||||
assert!(!repo.log(&LogQuery::default()).unwrap().is_empty());
|
||||
repo.merge_versions(&f.path("a.txt"), encoding_rs::UTF_8).unwrap();
|
||||
assert!(!f.path("execution.marker").exists());
|
||||
let repos = ide_vcs::Repos::discover(&f.dir, &|_| false).unwrap();
|
||||
for r in repos.all() {
|
||||
r.set_execution_allowed(false);
|
||||
}
|
||||
let compared =
|
||||
repos.compare_paths(std::slice::from_ref(&f.dir), "HEAD", None, &ide_core::CancellationToken::new()).unwrap();
|
||||
assert!(compared.repositories.iter().all(|r| r.error.is_none()));
|
||||
assert!(!f.path("execution.marker").exists());
|
||||
// Positive control: the same filter really executes without restriction.
|
||||
f.git(&["-c", "core.fsmonitor=false", "diff", "--no-ext-diff", "--no-textconv"]);
|
||||
assert!(f.path("execution.marker").exists());
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
//! Disk comparisons use the same excludes and layered ignore files as the project tree.
|
||||
use crate::{EntryKind, ExcludeMatcher, rules::GitignoreStack};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DirectoryCompareStatus {
|
||||
LeftOnly,
|
||||
RightOnly,
|
||||
Different,
|
||||
Equal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryCompareEntry {
|
||||
pub path: String,
|
||||
pub status: DirectoryCompareStatus,
|
||||
pub left_kind: Option<EntryKind>,
|
||||
pub right_kind: Option<EntryKind>,
|
||||
#[specta(optional)]
|
||||
pub left_link: Option<String>,
|
||||
#[specta(optional)]
|
||||
pub right_link: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryCompareProgress {
|
||||
pub scanned: u32,
|
||||
pub compared: u32,
|
||||
pub total: Option<u32>,
|
||||
pub bytes_read: f64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
kind: EntryKind,
|
||||
size: u64,
|
||||
link: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn check(cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
struct Rules {
|
||||
base: PathBuf,
|
||||
excludes: ExcludeMatcher,
|
||||
ignore: GitignoreStack,
|
||||
}
|
||||
|
||||
impl Rules {
|
||||
fn new(root: &Path, project: Option<&Path>, excludes: &[String]) -> Self {
|
||||
Self {
|
||||
base: project.filter(|p| root.starts_with(p)).unwrap_or(root).to_path_buf(),
|
||||
excludes: ExcludeMatcher::new(excludes),
|
||||
ignore: GitignoreStack::new(root),
|
||||
}
|
||||
}
|
||||
|
||||
fn excluded(&self, path: &Path, dir: bool) -> bool {
|
||||
path.file_name().is_some_and(|n| n == ".git")
|
||||
|| self.excludes.is_excluded(path.strip_prefix(&self.base).unwrap_or(path))
|
||||
|| self.ignore.is_ignored(path, dir)
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(
|
||||
root: &Path,
|
||||
rules: &Rules,
|
||||
cancel: &CancellationToken,
|
||||
progress: &mut DirectoryCompareProgress,
|
||||
emit: &impl Fn(DirectoryCompareProgress),
|
||||
) -> IdeResult<BTreeMap<String, Entry>> {
|
||||
if !fs::metadata(root)?.is_dir() {
|
||||
return Err(IdeError::invalid(format!("{} is not a directory", root.display())));
|
||||
}
|
||||
let mut result = BTreeMap::new();
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending.pop() {
|
||||
check(cancel)?;
|
||||
for item in fs::read_dir(dir)? {
|
||||
check(cancel)?;
|
||||
let path = item?.path();
|
||||
let meta = fs::symlink_metadata(&path)?;
|
||||
if rules.excluded(&path, meta.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let kind = if meta.file_type().is_symlink() {
|
||||
EntryKind::Symlink
|
||||
} else if meta.is_dir() {
|
||||
pending.push(path.clone());
|
||||
EntryKind::Dir
|
||||
} else if meta.is_file() {
|
||||
EntryKind::File
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let rel = ide_core::ipc_path::to_ipc(rel);
|
||||
let link = if kind == EntryKind::Symlink { Some(fs::read_link(&path)?) } else { None };
|
||||
result.insert(rel.clone(), Entry { kind, size: meta.len(), link });
|
||||
progress.scanned += 1;
|
||||
progress.path = rel;
|
||||
emit(progress.clone());
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn hash(
|
||||
path: &Path,
|
||||
cancel: &CancellationToken,
|
||||
progress: &mut DirectoryCompareProgress,
|
||||
emit: &impl Fn(DirectoryCompareProgress),
|
||||
) -> IdeResult<gix::hash::ObjectId> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hash = gix::hash::hasher(gix::hash::Kind::Sha256);
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
check(cancel)?;
|
||||
let len = file.read(&mut buffer)?;
|
||||
if len == 0 {
|
||||
break;
|
||||
}
|
||||
hash.update(&buffer[..len]);
|
||||
progress.bytes_read += len as f64;
|
||||
emit(progress.clone());
|
||||
}
|
||||
hash.try_finalize().map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Size checks avoid reading unequal files. Equal-sized files are hashed in bounded,
|
||||
/// cancellable chunks; symlinks compare their link text and are never traversed.
|
||||
pub fn compare_directories(
|
||||
left: &Path,
|
||||
right: &Path,
|
||||
project: Option<&Path>,
|
||||
excludes: &[String],
|
||||
cancel: &CancellationToken,
|
||||
emit: impl Fn(DirectoryCompareProgress),
|
||||
) -> IdeResult<Vec<DirectoryCompareEntry>> {
|
||||
check(cancel)?;
|
||||
let mut progress = DirectoryCompareProgress::default();
|
||||
emit(progress.clone());
|
||||
let l = scan(left, &Rules::new(left, project, excludes), cancel, &mut progress, &emit)?;
|
||||
let r = scan(right, &Rules::new(right, project, excludes), cancel, &mut progress, &emit)?;
|
||||
let mut paths: Vec<_> = l.keys().chain(r.keys()).cloned().collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
progress.total = Some(paths.len() as u32);
|
||||
let mut rows = BTreeMap::new();
|
||||
for path in paths {
|
||||
check(cancel)?;
|
||||
progress.path.clone_from(&path);
|
||||
let status = match (l.get(&path), r.get(&path)) {
|
||||
(Some(_), None) => DirectoryCompareStatus::LeftOnly,
|
||||
(None, Some(_)) => DirectoryCompareStatus::RightOnly,
|
||||
(Some(a), Some(b)) => {
|
||||
let equal = a.kind == b.kind
|
||||
&& match a.kind {
|
||||
EntryKind::Dir => true,
|
||||
EntryKind::Symlink => a.link == b.link,
|
||||
EntryKind::File => {
|
||||
a.size == b.size
|
||||
&& hash(&left.join(&path), cancel, &mut progress, &emit)?
|
||||
== hash(&right.join(&path), cancel, &mut progress, &emit)?
|
||||
}
|
||||
};
|
||||
if equal { DirectoryCompareStatus::Equal } else { DirectoryCompareStatus::Different }
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
rows.insert(
|
||||
path.clone(),
|
||||
DirectoryCompareEntry {
|
||||
path: path.clone(),
|
||||
status,
|
||||
left_kind: l.get(&path).map(|e| e.kind),
|
||||
right_kind: r.get(&path).map(|e| e.kind),
|
||||
left_link: l.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()),
|
||||
right_link: r.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()),
|
||||
},
|
||||
);
|
||||
progress.compared += 1;
|
||||
emit(progress.clone());
|
||||
}
|
||||
let changed: Vec<_> =
|
||||
rows.values().filter(|r| r.status != DirectoryCompareStatus::Equal).map(|r| r.path.clone()).collect();
|
||||
for path in changed {
|
||||
for parent in Path::new(&path).ancestors().skip(1) {
|
||||
if let Some(row) = rows.get_mut(&ide_core::ipc_path::to_ipc(parent))
|
||||
&& row.status == DirectoryCompareStatus::Equal
|
||||
{
|
||||
row.status = DirectoryCompareStatus::Different;
|
||||
}
|
||||
}
|
||||
}
|
||||
check(cancel)?;
|
||||
Ok(rows.into_values().collect())
|
||||
}
|
||||
|
||||
/// Merges a subtree without following links inside the comparison roots. Existing files
|
||||
/// require explicit overwrite; directory merges preserve destination-only entries.
|
||||
pub fn copy_merge(
|
||||
from: &Path,
|
||||
to: &Path,
|
||||
overwrite: bool,
|
||||
project: Option<&Path>,
|
||||
excludes: &[String],
|
||||
roots: (&Path, &Path),
|
||||
) -> IdeResult<()> {
|
||||
fn resolve(path: &Path) -> IdeResult<PathBuf> {
|
||||
if path.exists() {
|
||||
return Ok(dunce::canonicalize(path)?);
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| IdeError::invalid("copy requires an absolute path"))?;
|
||||
Ok(resolve(parent)?.join(path.file_name().ok_or_else(|| IdeError::invalid("missing file name"))?))
|
||||
}
|
||||
if !from.is_absolute() || !to.is_absolute() {
|
||||
return Err(IdeError::invalid("copy requires absolute paths"));
|
||||
}
|
||||
if !from.starts_with(roots.0) || !to.starts_with(roots.1) {
|
||||
return Err(IdeError::invalid("copy paths must belong to the comparison roots"));
|
||||
}
|
||||
let source = resolve(from)?;
|
||||
let target = resolve(to)?;
|
||||
if source.starts_with(&target) || target.starts_with(&source) {
|
||||
return Err(IdeError::invalid("cannot copy overlapping paths"));
|
||||
}
|
||||
for parent in to.ancestors().skip(1) {
|
||||
// The user explicitly selected this root; aliases above it (e.g. /tmp
|
||||
// on macOS) are valid. Links inside the copied subtree are not followed.
|
||||
if to == roots.1 || parent == roots.1 {
|
||||
break;
|
||||
}
|
||||
if fs::symlink_metadata(parent).is_ok_and(|m| m.file_type().is_symlink()) {
|
||||
return Err(IdeError::invalid("cannot copy through a destination symlink"));
|
||||
}
|
||||
}
|
||||
let source_base = project.filter(|p| from.starts_with(p)).or(Some(roots.0));
|
||||
let target_base = project.filter(|p| to.starts_with(p)).or(Some(roots.1));
|
||||
let rules = Rules::new(from, source_base, excludes);
|
||||
let target_rules = Rules::new(to, target_base, excludes);
|
||||
let mut pending = vec![(from.to_path_buf(), to.to_path_buf())];
|
||||
while let Some((src, dst)) = pending.pop() {
|
||||
let meta = fs::symlink_metadata(&src)?;
|
||||
if rules.excluded(&src, meta.is_dir()) || target_rules.excluded(&dst, meta.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let existing = fs::symlink_metadata(&dst).ok();
|
||||
let merge = meta.is_dir() && existing.as_ref().is_some_and(|m| m.is_dir());
|
||||
if existing.is_some() && !merge && !overwrite {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into());
|
||||
}
|
||||
if meta.is_dir() {
|
||||
if existing.is_some() && !merge {
|
||||
crate::delete(&dst)?;
|
||||
}
|
||||
fs::create_dir_all(&dst)?;
|
||||
for entry in fs::read_dir(&src)? {
|
||||
let entry = entry?;
|
||||
pending.push((entry.path(), dst.join(entry.file_name())));
|
||||
}
|
||||
} else {
|
||||
if let Some(parent) = dst.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Stage the complete file before touching its destination.
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let temp = dst.with_file_name(format!(".openidea-copy-{}-{id}", std::process::id()));
|
||||
let mut staged = false;
|
||||
let result: IdeResult<()> = (|| {
|
||||
#[cfg(windows)]
|
||||
if meta.file_type().is_symlink() {
|
||||
use std::os::windows::fs::{FileTypeExt, symlink_dir, symlink_file};
|
||||
let target = fs::read_link(&src)?;
|
||||
if meta.file_type().is_symlink_dir() {
|
||||
symlink_dir(target, &temp)?;
|
||||
} else {
|
||||
symlink_file(target, &temp)?;
|
||||
}
|
||||
} else {
|
||||
crate::copy(&src, &temp)?;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
crate::copy(&src, &temp)?;
|
||||
staged = true;
|
||||
// Recheck after staging: another writer may have created the target.
|
||||
if fs::symlink_metadata(&dst).is_ok() {
|
||||
if !overwrite {
|
||||
return Err(
|
||||
std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into()
|
||||
);
|
||||
}
|
||||
crate::delete(&dst)?;
|
||||
}
|
||||
fs::rename(&temp, &dst)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = &result
|
||||
&& (staged || error.kind != ide_core::error::ErrorKind::AlreadyExists)
|
||||
{
|
||||
let _ = crate::delete_permanently(&temp);
|
||||
}
|
||||
result?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::error::ErrorKind;
|
||||
|
||||
fn put(root: &Path, path: &str, content: &[u8]) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_add_delete_modify_equal_binary_unicode_empty_and_type_conflicts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "子目录/仅左.txt", b"left");
|
||||
put(&right, "only-right", b"right");
|
||||
for (path, a, b) in [
|
||||
("same", b"same".as_slice(), b"same".as_slice()),
|
||||
("binary", &[0, 255, 10], &[0, 254, 10]),
|
||||
("size", b"a", b"bb"),
|
||||
("conflict", b"file", b""),
|
||||
] {
|
||||
put(&left, path, a);
|
||||
if path == "conflict" {
|
||||
put(&right, "conflict/child", b"child");
|
||||
} else {
|
||||
put(&right, path, b);
|
||||
}
|
||||
}
|
||||
fs::create_dir(left.join("empty")).unwrap();
|
||||
fs::create_dir(right.join("empty")).unwrap();
|
||||
let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap();
|
||||
let status = |path| rows.iter().find(|r| r.path == path).unwrap().status;
|
||||
assert_eq!(status("same"), DirectoryCompareStatus::Equal);
|
||||
assert_eq!(status("empty"), DirectoryCompareStatus::Equal);
|
||||
for path in ["binary", "size", "conflict"] {
|
||||
assert_eq!(status(path), DirectoryCompareStatus::Different);
|
||||
}
|
||||
assert_eq!(status("子目录/仅左.txt"), DirectoryCompareStatus::LeftOnly);
|
||||
assert_eq!(status("only-right"), DirectoryCompareStatus::RightOnly);
|
||||
assert_eq!(status("conflict/child"), DirectoryCompareStatus::RightOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_project_anchored_excludes_and_layered_ignores() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
for root in [&left, &right] {
|
||||
put(root, "nested/node_modules/x", b"ignored");
|
||||
put(root, "target/x", b"ignored");
|
||||
put(root, ".git/config", b"ignored");
|
||||
put(root, "generated/x", b"ignored");
|
||||
put(root, ".gitignore", b"*.log\n");
|
||||
put(root, "nested/debug.log", b"ignored");
|
||||
put(root, "kept", b"kept");
|
||||
}
|
||||
let excludes = ["**/node_modules", "**/target", "/left/generated", "/right/generated"].map(String::from);
|
||||
let rows =
|
||||
compare_directories(&left, &right, Some(tmp.path()), &excludes, &CancellationToken::new(), |_| {}).unwrap();
|
||||
assert!(
|
||||
rows.iter()
|
||||
.all(|r| !["node_modules", "target", ".git/", "generated", ".log"].iter().any(|p| r.path.contains(p)))
|
||||
);
|
||||
assert!(rows.iter().any(|r| r.path == "kept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_stops_before_scan_during_scan_and_during_hashing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "big", &vec![1; 1024 * 1024]);
|
||||
put(&right, "big", &vec![1; 1024 * 1024]);
|
||||
for phase in 0..3 {
|
||||
let cancel = CancellationToken::new();
|
||||
if phase == 0 {
|
||||
cancel.cancel();
|
||||
}
|
||||
let result = compare_directories(&left, &right, None, &[], &cancel, |p| {
|
||||
if phase == 1 && p.scanned > 0 || phase == 2 && p.bytes_read > 0.0 {
|
||||
cancel.cancel();
|
||||
}
|
||||
});
|
||||
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_mismatches_are_not_hashed_and_directory_status_tracks_children() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "nested/file", b"a");
|
||||
put(&right, "nested/file", b"ab");
|
||||
let bytes = std::cell::Cell::new(0.0);
|
||||
let rows =
|
||||
compare_directories(&left, &right, None, &[], &CancellationToken::new(), |p| bytes.set(p.bytes_read))
|
||||
.unwrap();
|
||||
assert_eq!(bytes.get(), 0.0);
|
||||
assert!(rows.iter().all(|r| r.status == DirectoryCompareStatus::Different));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_copies_with_explicit_overwrite_and_keeps_destination_only_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "file", b"new");
|
||||
put(&left, "target/ignored", b"ignored");
|
||||
put(&right, "file", b"old");
|
||||
put(&right, "keep", b"keep");
|
||||
assert_eq!(
|
||||
copy_merge(&left, &right, false, None, &["target".into()], (&left, &right)).unwrap_err().kind,
|
||||
ErrorKind::AlreadyExists
|
||||
);
|
||||
assert_eq!(fs::read(right.join("file")).unwrap(), b"old");
|
||||
copy_merge(&left, &right, true, None, &["target".into()], (&left, &right)).unwrap();
|
||||
assert_eq!(fs::read(right.join("file")).unwrap(), b"new");
|
||||
assert_eq!(fs::read(right.join("keep")).unwrap(), b"keep");
|
||||
assert!(!right.join("target").exists());
|
||||
for (from, to) in [(&left, left.clone()), (&left, left.join("child")), (&right, tmp.path().to_path_buf())] {
|
||||
assert_eq!(
|
||||
copy_merge(from, &to, true, None, &[], (tmp.path(), tmp.path())).unwrap_err().kind,
|
||||
ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtree_copy_keeps_excludes_anchored_to_each_comparison_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "sub/build/keep", b"included below sub");
|
||||
put(&left, "sub/protected", b"do not overwrite");
|
||||
put(&right, "sub/protected", b"keep destination");
|
||||
put(&right, ".gitignore", b"sub/protected\n");
|
||||
copy_merge(&left.join("sub"), &right.join("sub"), true, None, &["/build".into()], (&left, &right)).unwrap();
|
||||
assert_eq!(fs::read(right.join("sub/build/keep")).unwrap(), b"included below sub");
|
||||
assert_eq!(fs::read(right.join("sub/protected")).unwrap(), b"keep destination");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_copy_replaces_file_directory_type_conflicts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "file", b"file");
|
||||
put(&right, "file/child", b"old directory");
|
||||
put(&left, "tree/child", b"new directory");
|
||||
put(&right, "tree", b"old file");
|
||||
for path in ["file", "tree"] {
|
||||
copy_merge(&left.join(path), &right.join(path), true, None, &[], (&left, &right)).unwrap();
|
||||
}
|
||||
assert_eq!(fs::read(right.join("file")).unwrap(), b"file");
|
||||
assert_eq!(fs::read(right.join("tree/child")).unwrap(), b"new directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_compare_link_text_and_are_not_followed_when_copying() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
fs::create_dir(&left).unwrap();
|
||||
fs::create_dir(&right).unwrap();
|
||||
symlink(".", left.join("loop")).unwrap();
|
||||
symlink("missing", right.join("loop")).unwrap();
|
||||
let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].status, DirectoryCompareStatus::Different);
|
||||
copy_merge(&left.join("loop"), &right.join("copied"), false, None, &[], (&left, &right)).unwrap();
|
||||
assert_eq!(fs::read_link(right.join("copied")).unwrap(), PathBuf::from("."));
|
||||
symlink(&right, tmp.path().join("alias")).unwrap();
|
||||
assert_eq!(
|
||||
copy_merge(&left.join("loop"), &tmp.path().join("alias/new"), true, None, &[], (tmp.path(), tmp.path()))
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -62,19 +62,20 @@ pub fn decode_properties_escapes(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// Serializes displayed properties text as ASCII, using uppercase UTF-16 units.
|
||||
/// Existing ASCII Unicode escapes retain their spelling and meaning on disk.
|
||||
pub fn encode_properties_ascii(text: &str) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
let current = rest;
|
||||
rest = &rest[c.len_utf8()..];
|
||||
if c == '\\' {
|
||||
if rest.starts_with('\\') {
|
||||
out.push_str("\\\\");
|
||||
rest = &rest[1..];
|
||||
} else if rest.chars().next().is_some_and(|next| !next.is_ascii()) {
|
||||
// Keep a literal backslash from swallowing a generated escape.
|
||||
} else if unicode_character(current).is_some() || rest.chars().next().is_some_and(|next| !next.is_ascii()) {
|
||||
// Keep a literal backslash from swallowing a generated escape,
|
||||
// or becoming a Unicode escape on the next load.
|
||||
out.push_str("\\u005C");
|
||||
} else {
|
||||
out.push(c);
|
||||
@@ -386,32 +387,13 @@ mod tests {
|
||||
for text in ["plain=ASCII\r\n", r"escaped=\\u0041", r"bad=\u12xz", r"bad=\uD800"] {
|
||||
assert_eq!(encode_properties_ascii(text), text);
|
||||
}
|
||||
for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\\u0041"] {
|
||||
for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\u0041", r"\\u0041", r"\uD83D\uDE00"] {
|
||||
let ascii = encode_properties_ascii(text);
|
||||
assert!(ascii.is_ascii());
|
||||
assert_eq!(decode_properties_escapes(&ascii), text, "{ascii}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_ascii_escapes_are_not_escaped_again() {
|
||||
for text in [
|
||||
r"test.key2=\u4F60\u597D",
|
||||
r"test.key3=\u4f60\u597d",
|
||||
r"ascii=\u0041\u005C\u000A",
|
||||
r"emoji=\uD83D\uDE00",
|
||||
r"slashes=\\u4F60 \\\u4F60 \\\\u4F60",
|
||||
r"bad=\u123 \u12xz \uu0041 \uD800 \uDC00",
|
||||
] {
|
||||
assert_eq!(encode_properties_ascii(text), text, "{text}");
|
||||
}
|
||||
let mixed = "raw=\\u4f60\\u597d; native=你好😀";
|
||||
let expected = r"raw=\u4f60\u597d; native=\u4F60\u597D\uD83D\uDE00";
|
||||
assert_eq!(encode_properties_ascii(mixed), expected);
|
||||
assert_eq!(encode_properties_ascii(expected), expected);
|
||||
assert_eq!(decode_properties_escapes(expected), "raw=你好; native=你好😀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_line_terminator_escapes_survive_an_edit() {
|
||||
let source = r"key=one\u000Atwo\u000Dthree";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
mod archive;
|
||||
mod bytes;
|
||||
mod compare;
|
||||
mod encoding;
|
||||
mod ops;
|
||||
mod rules;
|
||||
@@ -21,9 +20,6 @@ pub use archive::{
|
||||
read_archive_entry, split_archive_path,
|
||||
};
|
||||
pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range};
|
||||
pub use compare::{
|
||||
DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus, compare_directories, copy_merge,
|
||||
};
|
||||
use encoding::SNIFF_LEN;
|
||||
pub use encoding::{
|
||||
LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode,
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
use super::{ProjectRoots, RootKind};
|
||||
use ide_core::settings::ProjectStructureSettings;
|
||||
use std::path::{Component, Path};
|
||||
|
||||
/// Source paths for jdtls's unmanaged project, relative to its workspace folder.
|
||||
/// Maven, Gradle and Eclipse own their source model; other layouts retain jdtls inference.
|
||||
pub fn unmanaged_java_source_paths(
|
||||
project: &Path,
|
||||
server_root: &Path,
|
||||
settings: &ProjectStructureSettings,
|
||||
) -> Option<Vec<String>> {
|
||||
if ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", ".project"]
|
||||
.iter()
|
||||
.any(|name| server_root.join(name).is_file())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let roots = ProjectRoots::compute(Vec::new(), settings);
|
||||
let mut paths: Vec<String> = roots
|
||||
.roots
|
||||
.iter()
|
||||
.filter(|r| matches!(r.kind, RootKind::Source | RootKind::Test))
|
||||
.filter(|r| Path::new(&r.path).components().all(|c| matches!(c, Component::Normal(_))))
|
||||
.filter_map(|r| {
|
||||
let absolute = project.join(&r.path);
|
||||
let relative = absolute.strip_prefix(server_root).ok()?;
|
||||
let path = relative.to_string_lossy().replace('\\', "/");
|
||||
Some(if path.is_empty() { ".".into() } else { path })
|
||||
})
|
||||
.collect();
|
||||
if paths.is_empty() {
|
||||
// jdtls can infer the package directory itself as a source root. Give
|
||||
// conventional layouts an unambiguous base without guessing other layouts.
|
||||
paths = ["src/main/java", "src/test/java"]
|
||||
.into_iter()
|
||||
.filter(|p| server_root.join(p).is_dir())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
if paths.is_empty() && server_root.join("src").is_dir() {
|
||||
paths.push("src".into());
|
||||
}
|
||||
}
|
||||
(!paths.is_empty()).then_some(paths)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marked_sources_and_tests_are_relative_to_the_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = ProjectStructureSettings {
|
||||
source_roots: vec!["./app/generated/".into(), "other/src".into(), "../outside".into()],
|
||||
test_roots: vec!["app/tests".into()],
|
||||
resource_roots: vec!["app/resources".into()],
|
||||
excluded: vec!["app/out".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
unmanaged_java_source_paths(dir.path(), &dir.path().join("app"), &settings),
|
||||
Some(vec!["generated".into(), "tests".into()])
|
||||
);
|
||||
let settings = ProjectStructureSettings { source_roots: vec![".".into()], ..Default::default() };
|
||||
assert_eq!(unmanaged_java_source_paths(dir.path(), dir.path(), &settings), Some(vec![".".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conventional_layouts_and_other_unmarked_projects() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let settings = ProjectStructureSettings::default();
|
||||
assert_eq!(unmanaged_java_source_paths(root, root, &settings), None);
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
assert_eq!(unmanaged_java_source_paths(root, root, &settings), Some(vec!["src".into()]));
|
||||
std::fs::create_dir_all(root.join("src/main/java")).unwrap();
|
||||
std::fs::create_dir_all(root.join("src/test/java")).unwrap();
|
||||
assert_eq!(
|
||||
unmanaged_java_source_paths(root, root, &settings),
|
||||
Some(vec!["src/main/java".into(), "src/test/java".into()])
|
||||
);
|
||||
let settings = ProjectStructureSettings { source_roots: vec!["custom".into()], ..settings };
|
||||
assert_eq!(unmanaged_java_source_paths(root, root, &settings), Some(vec!["custom".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ant_projects_use_unmanaged_source_roots() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::write(root.join("build.xml"), "<project/>\n").unwrap();
|
||||
std::fs::create_dir_all(root.join("src/com/acme")).unwrap();
|
||||
let settings = ProjectStructureSettings::default();
|
||||
assert_eq!(unmanaged_java_source_paths(root, root, &settings), Some(vec!["src".into()]));
|
||||
let settings = ProjectStructureSettings { source_roots: vec!["src".into(), "generated".into()], ..settings };
|
||||
assert_eq!(unmanaged_java_source_paths(root, root, &settings), Some(vec!["generated".into(), "src".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_projects_keep_their_build_model() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let settings = ProjectStructureSettings { source_roots: vec!["custom".into()], ..Default::default() };
|
||||
for marker in
|
||||
["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", ".project"]
|
||||
{
|
||||
std::fs::write(root.join(marker), "").unwrap();
|
||||
assert_eq!(unmanaged_java_source_paths(root, root, &settings), None, "{marker}");
|
||||
std::fs::remove_file(root.join(marker)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,9 @@
|
||||
|
||||
mod detect;
|
||||
mod gradle;
|
||||
mod java;
|
||||
mod maven;
|
||||
|
||||
pub use detect::{affects_roots, detect};
|
||||
pub use java::unmanaged_java_source_paths;
|
||||
|
||||
use ide_core::settings::ProjectStructureSettings;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -641,57 +641,6 @@ fn properties_conversion_uses_displayed_utf16_and_survives_reload() {
|
||||
assert!(!f.docs.is_dirty(doc.id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_pasted_escapes_survive_save_and_decode_on_reload() {
|
||||
let pasted = "test.key1=Hello\ntest.key2=\\u4F60\\u597D\ntest.key3=\\u4f60\\u597d\n";
|
||||
let displayed = "test.key1=Hello\ntest.key2=你好\ntest.key3=你好\n";
|
||||
for encoding in ["utf-8", "iso-8859-1", "utf-16le"] {
|
||||
for eol in ["\n", "\r\n"] {
|
||||
let f = fixture();
|
||||
let path = f.root.join("test_zh_CN.properties");
|
||||
let source = pasted.replace('\n', eol);
|
||||
fs::write(&path, ide_vfs::encode(&source, encoding, encoding == "utf-16le").unwrap()).unwrap();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
defaults.properties_fallback = encoding.into();
|
||||
f.docs.set_file_defaults(defaults.clone());
|
||||
let doc = f.docs.open(&path).unwrap();
|
||||
assert_eq!(doc.text, displayed);
|
||||
|
||||
f.docs.replace_all(doc.id, pasted).unwrap();
|
||||
f.docs.save(doc.id).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), source.as_bytes(), "{encoding} {eol:?}");
|
||||
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted);
|
||||
assert!(!f.docs.is_dirty(doc.id).unwrap());
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted, "own saves do not reload");
|
||||
|
||||
f.docs.close(doc.id);
|
||||
let reopened = f.docs.open(&path).unwrap();
|
||||
assert_eq!(reopened.text, displayed);
|
||||
// An edit followed by its inverse preserves the original escape casing.
|
||||
f.docs.replace_all(reopened.id, pasted).unwrap();
|
||||
f.docs.replace_all(reopened.id, displayed).unwrap();
|
||||
f.docs.save(reopened.id).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), source.as_bytes());
|
||||
|
||||
let external = format!("{source}test.key4=\\uD83D\\uDE00{eol}");
|
||||
fs::write(&path, &external).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n"));
|
||||
defaults.native_to_ascii = false;
|
||||
f.docs.set_file_defaults(defaults.clone());
|
||||
f.docs.reload_properties();
|
||||
assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, external.replace("\r\n", "\n"));
|
||||
defaults.native_to_ascii = true;
|
||||
f.docs.set_file_defaults(defaults);
|
||||
f.docs.reload_properties();
|
||||
assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n"));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), external);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_toggle_reloads_only_clean_properties_and_pins_dirty_codec() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/** Docking agent conversations between the editor and the Agents tool window by drag & drop. */
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const TASK = {
|
||||
id: "t1",
|
||||
title: "Repro task",
|
||||
status: "ready" as const,
|
||||
isolation: "local" as const,
|
||||
provider: "codex",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
/** Seeds one task in the agents store (no backend in plain-browser mode) and docks it. */
|
||||
async function seedAndDock(page: Page) {
|
||||
await page.evaluate(async (task) => {
|
||||
// Paths go through variables: knip can't resolve these browser-absolute URLs.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const store = await import(storePath);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
store.dockTask(task.id);
|
||||
}, TASK);
|
||||
await page.locator("span[title='Repro task']").first().waitFor();
|
||||
}
|
||||
|
||||
test("drags the docked conversation back onto the editor", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
// An editor pane, so the session tab has a content drop target to land on.
|
||||
await page.getByRole("treeitem", { name: "README.md", exact: true }).dblclick();
|
||||
await page.getByRole("tab", { name: "README.md" }).waitFor();
|
||||
await seedAndDock(page);
|
||||
|
||||
const title = page.locator("span[title='Repro task']").first();
|
||||
const to = await page.getByRole("tabpanel").first().boundingBox();
|
||||
const from = await title.boundingBox();
|
||||
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.getByRole("tab", { name: "Repro task" })).toBeVisible();
|
||||
await expect(page.getByRole("tree", { name: "Agent tasks" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("drags the docked conversation back onto the empty editor", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
// Docking closes the session tab; with no other tabs the editor is empty and
|
||||
// must still take the drop (regression: the empty state had no drop target).
|
||||
await seedAndDock(page);
|
||||
await expect(page.locator("[data-editor-area] [role='tabpanel']")).toHaveCount(0);
|
||||
|
||||
const title = page.locator("span[title='Repro task']").first();
|
||||
const to = await page.getByText("Drop files here to open them").boundingBox();
|
||||
const from = await title.boundingBox();
|
||||
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.getByRole("tab", { name: "Repro task" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("docks a session tab dropped on the tool window", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.getByRole("treeitem", { name: "README.md", exact: true }).dblclick();
|
||||
await page.getByRole("tab", { name: "README.md" }).waitFor();
|
||||
await page.evaluate(async (task) => {
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const modelPath = "/src/features/agents/model.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const store = await import(storePath);
|
||||
const { TOOL_WINDOW } = await import(modelPath);
|
||||
const { toolWindowService } = await import(contributionsPath);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
toolWindowService().show(TOOL_WINDOW, true);
|
||||
store.openTask(task.id);
|
||||
}, TASK);
|
||||
await page.getByRole("tab", { name: "Repro task" }).waitFor();
|
||||
|
||||
const tab = page.getByRole("tab", { name: "Repro task" });
|
||||
const to = await page.getByRole("tree", { name: "Agent tasks" }).boundingBox();
|
||||
const from = await tab.boundingBox();
|
||||
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Docked: the tab closes and the tool window gives itself to the conversation.
|
||||
await expect(page.getByRole("tab", { name: "Repro task" })).toHaveCount(0);
|
||||
await expect(page.getByRole("tree", { name: "Agent tasks" })).toHaveCount(0);
|
||||
await expect(page.locator("span[title='Repro task']").first()).toBeVisible();
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"languages": [
|
||||
"xml",
|
||||
"html",
|
||||
"vue",
|
||||
"svelte",
|
||||
"css",
|
||||
"java",
|
||||
"rust",
|
||||
"typescript",
|
||||
"python",
|
||||
"csharp",
|
||||
"sql",
|
||||
"clojure",
|
||||
"erlang",
|
||||
"bat",
|
||||
"ocaml",
|
||||
"fsharp",
|
||||
"haskell",
|
||||
"lua",
|
||||
"cmake",
|
||||
"powershell",
|
||||
"julia",
|
||||
"ruby"
|
||||
],
|
||||
"markers": [
|
||||
{
|
||||
"start": "region Name",
|
||||
"end": "endregion",
|
||||
"name": "Name"
|
||||
},
|
||||
{
|
||||
"start": "region要折叠的内容",
|
||||
"end": "endregion",
|
||||
"name": "要折叠的内容"
|
||||
},
|
||||
{
|
||||
"start": "#region \"Quoted name\"",
|
||||
"end": "#endregion",
|
||||
"name": "Quoted name"
|
||||
},
|
||||
{
|
||||
"start": "REGION Mixed case",
|
||||
"end": "ENDREGION",
|
||||
"name": "Mixed case"
|
||||
},
|
||||
{
|
||||
"start": "region",
|
||||
"end": "endregion",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"start": "<editor-fold desc=\"Head 内容\">",
|
||||
"end": "</editor-fold>",
|
||||
"name": "Head 内容"
|
||||
},
|
||||
{
|
||||
"start": "<editor-fold>",
|
||||
"end": "</editor-fold>",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"invalid": [
|
||||
"regional Name",
|
||||
"regions.push(x)",
|
||||
"endregionFoo",
|
||||
"<editor-folder>",
|
||||
"</editor-folder>",
|
||||
"the region below",
|
||||
"region_name",
|
||||
"<editor-fold",
|
||||
"</editor-fold"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "css"
|
||||
name = "CSS"
|
||||
formatter = { tool = "prettier", parser = "css", range = false }
|
||||
extensions = ["css", "pcss", "postcss"]
|
||||
block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "graphql"
|
||||
name = "GraphQL"
|
||||
formatter = { tool = "prettier", parser = "graphql", range = true }
|
||||
extensions = ["graphql", "graphqls", "gql"]
|
||||
line-comment = "#"
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "html"
|
||||
name = "HTML"
|
||||
formatter = { tool = "prettier", parser = "html", range = false }
|
||||
extensions = ["html", "htm", "xhtml", "shtml", "html.tmpl"]
|
||||
first-line = "^\\s*<!(?i:doctype)\\s+html"
|
||||
block-comment = ["<!--", "-->"]
|
||||
|
||||
+1
-2
@@ -32,13 +32,12 @@ resolveAdditionalTextEditsSupport = true
|
||||
|
||||
[language-servers.initialization-options.settings.java]
|
||||
autobuild = { enabled = true }
|
||||
contentProvider = { preferred = "fernflowerContentProvider" }
|
||||
contentProvider = { preferred = "fernflower" }
|
||||
maven = { downloadSources = true, updateSnapshots = false }
|
||||
eclipse = { downloadSources = true }
|
||||
references = { includeDecompiledSources = true }
|
||||
signatureHelp = { enabled = true }
|
||||
configuration = { updateBuildConfiguration = "automatic" }
|
||||
project = { referencedLibraries = ["lib/**/*.jar"] }
|
||||
# Generated code (implement methods, getters, toString…) indents like the editor (`indent` above).
|
||||
format = { insertSpaces = true, tabSize = 4 }
|
||||
# Calls complete as `name()` with the caret inside and parameter info, not guessed arguments;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "javascript"
|
||||
name = "JavaScript"
|
||||
formatter = { tool = "prettier", parser = "babel", range = true }
|
||||
extensions = ["js", "mjs", "cjs"]
|
||||
first-line = "^#!.*\\b(node|nodejs|deno|bun)\\b"
|
||||
line-comment = "//"
|
||||
|
||||
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user