Compare commits

..
Author SHA1 Message Date
l0ng-ai c60cf55dbc perf: add a scenario for switching between and scrolling long agent conversations
`agentTabs` opens two agent tasks with 150-turn (2,100-entry) conversations,
switches between them with the Switcher (Ctrl+Tab) and scrolls one, with
scroll anchoring off as in WebKit. Budgets cover the conversation's DOM size,
rows that never skip rendering off screen, switch latency and main-thread
time, scroll jumps and scroll frame pacing.

The conversations come from a dev/perf-only `agents.openSampleConversations`
action.
2026-11-09 11:36:14 +08:00
900 changed files with 7138 additions and 42523 deletions

No files matched your search

Generated
+59 -73
View File
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
@@ -4180,7 +4180,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4204,7 +4204,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4218,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4229,7 +4229,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4245,7 +4245,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4259,9 +4259,8 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"gtk",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
@@ -4269,12 +4268,11 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
"libc",
"percent-encoding",
"serde",
"serde_json",
"specta",
@@ -4287,7 +4285,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4301,14 +4299,12 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
"ide-jsonrpc",
"ide-language",
"ide-remote",
"ide-run",
"libc",
"memchr",
"parking_lot",
@@ -4324,7 +4320,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"bb8",
"blake3",
@@ -4354,7 +4350,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4367,7 +4363,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4387,7 +4383,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4399,7 +4395,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4412,7 +4408,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4456,7 +4452,6 @@ dependencies = [
"ignore",
"jsonschema",
"parking_lot",
"percent-encoding",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
@@ -4479,7 +4474,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4497,7 +4492,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4519,7 +4514,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"encoding_rs",
"futures",
@@ -4536,7 +4531,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4592,7 +4587,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"futures",
@@ -4618,7 +4613,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"bytes",
"dunce",
@@ -4637,7 +4632,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4660,7 +4655,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -4683,7 +4678,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4696,7 +4691,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"globset",
@@ -4715,7 +4710,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4739,7 +4734,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"ide-core",
@@ -4747,7 +4742,6 @@ dependencies = [
"ide-run",
"ide-vfs",
"ignore",
"quick-xml 0.42.0",
"serde",
"serde_json",
"specta",
@@ -4759,7 +4753,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"criterion",
"globset",
@@ -4779,7 +4773,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4792,7 +4786,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4813,7 +4807,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-language",
@@ -4828,7 +4822,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"libc",
@@ -4841,7 +4835,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"criterion",
"ide-core",
@@ -4854,7 +4848,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"flate2",
"hex",
@@ -4875,7 +4869,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -4896,7 +4890,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"chardetng",
"dunce",
@@ -4920,7 +4914,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"open",
@@ -4936,7 +4930,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -5583,9 +5577,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.39.1"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
dependencies = [
"cc",
"pkg-config",
@@ -6365,15 +6359,6 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.2.2"
@@ -6523,7 +6508,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -8640,7 +8625,8 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlx"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -8652,7 +8638,8 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -8674,7 +8661,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2 0.11.1",
"sha2 0.10.9",
"smallvec",
"thiserror 2.0.21",
"tokio",
@@ -8687,7 +8674,8 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
dependencies = [
"proc-macro2",
"quote",
@@ -8699,7 +8687,8 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
dependencies = [
"cfg-if",
"dotenvy",
@@ -8710,7 +8699,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2 0.11.1",
"sha2 0.10.9",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@@ -8723,7 +8712,8 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
dependencies = [
"bitflags 2.13.2",
"byteorder",
@@ -8748,7 +8738,8 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -8782,7 +8773,8 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
dependencies = [
"atoi",
"flume",
@@ -9547,7 +9539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -11313,12 +11305,6 @@ name = "whoami"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"web-sys",
]
[[package]]
name = "win_uds"
+3 -6
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.40"
version = "0.1.39"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -103,8 +103,7 @@ portable-pty = "0.9"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
@@ -132,9 +131,7 @@ debug = false
[profile.release]
codegen-units = 1
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
# (Linux arm64 was killed mid-link from v0.1.39 on).
lto = "thin"
lto = true
opt-level = 3
# Unwind, so a panic fails one task or command instead of quitting the IDE.
panic = "unwind"
+1 -3
View File
@@ -69,13 +69,11 @@ the [official plugins](plugins/official). The plugin API is still a preview.
### Contributors
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=500" alt="OpenIDEA contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
</a>
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+1 -10
View File
@@ -169,10 +169,8 @@ impl Shared {
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": options }));
let _ = tokio::task::spawn_blocking(move || {
// Notifies subscribers itself when the discovery
// changed the cached config; unchanged results
// stay silent.
shared.remember_discovered(&spec.id, &options, &commands);
shared.emit(AgentEvent::Providers);
})
.await;
}
@@ -255,13 +253,6 @@ impl Shared {
{
tracing::warn!("saving agent settings: {e}");
}
// The cached provider config actually changed (a session report, discovery,
// or a turn update): tell subscribers to re-list, so a changed model list
// reaches the composer without waiting for the next task (#1490). Unchanged
// reports stay silent, so routine session traffic emits nothing. The cache
// lock is released first; the sink never runs under it.
drop(cache);
self.emit(AgentEvent::Providers);
}
}
-72
View File
@@ -366,35 +366,6 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
)
}
#[tokio::test(flavor = "multi_thread")]
async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Review the saved conversation"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.shutdown();
let restored_agent = Arc::new(FakeAgent::default());
let restored = manager(tmp.path(), restored_agent.clone());
assert!(!restored.entries(&task.id, None).unwrap().is_empty());
assert!(restored_agent.calls.lock().is_empty());
assert!(restored.entries("missing-task", None).is_err());
assert!(restored.entries(&task.id, Some("missing-subagent".into())).is_err());
restored.task(&task.id).unwrap().state.lock().conversation = Conversation::default();
assert!(restored.entries(&task.id, None).unwrap().is_empty());
restored.shutdown();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn claude_commands_before_session_are_fetched_once_and_saved() {
@@ -2175,49 +2146,6 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
assert!(probe.error.is_none());
}
/// A session report that changes the cached provider config notifies subscribers,
/// so the composer re-lists models without waiting for the next task (#1490).
/// Reports that change nothing stay silent.
#[tokio::test(flavor = "multi_thread")]
async fn remembered_config_changes_notify_subscribers_once() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let events = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
events.lock().clear();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a first report changing the cache notifies subscribers"
);
events.lock().clear();
// The same report again: the cache is unchanged, so subscribers hear nothing.
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(events.lock().is_empty(), "an unchanged report must stay silent");
// A changed model list notifies again, and providers() serves the new list.
let updated = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }, { "value": "custom-sonnet" }] },
] }));
mgr.shared.remember("claude", Some(&updated), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a changed model list notifies subscribers"
);
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus", "custom-sonnet"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
let new = || NewAgentTask {
+9 -641
View File
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
/// Quoted string literals in `s`.
fn literals(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = s;
while let Some(start) = rest.find(['"', '\'']) {
let Some((value, used)) = literal(&rest[start..]) else { break };
out.push(value);
rest = &rest[start + used..];
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '"' || c == '\'' {
out.push(chars.by_ref().take_while(|&n| n != c).collect());
}
}
out
}
@@ -565,12 +565,11 @@ fn is_dependency_configuration(name: &str) -> bool {
/// `(group, artifact, version)` of the external dependencies a build script declares:
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
let text = strip_comments(text);
let mut out = Vec::new();
for line in text.lines().flat_map(statements) {
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
let line = line.trim_start();
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if name.is_empty() || !is_dependency_configuration(&name) {
@@ -592,7 +591,6 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
}),
};
if let Some((g, a, v)) = coordinate {
let v = interpolate(&v, properties);
let plain = |s: &str| {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
};
@@ -604,354 +602,11 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
out
}
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
fn statements(line: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut index = 0;
while index < line.len() {
let c = line[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
continue;
}
if matches!(c, '{' | '}' | ';' | '\n') {
out.push(&line[start..index]);
start = index + c.len_utf8();
}
index += c.len_utf8();
}
out.push(&line[start..]);
out
}
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
/// targets, as written in the script.
pub fn applied_scripts(text: &str) -> Vec<String> {
let text = strip_comments(text);
let mut out = Vec::new();
for (index, word) in code_words(&text) {
if word != "apply" || !starts_word(&text, index) {
continue;
}
let mut rest = text[index + "apply".len()..].trim_start();
if let Some(inner) = rest.strip_prefix('(') {
rest = inner.trim_start();
}
let Some(rest) = rest.strip_prefix("from") else { continue };
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
continue;
};
let mut rest = rest.trim_start();
if let Some(inner) = rest.strip_prefix("file") {
let inner = inner.trim_start();
if let Some(inner) = inner.strip_prefix('(') {
rest = inner.trim_start();
}
}
if let Some((path, _)) = literal(rest) {
out.push(path);
}
}
out
}
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
let text = strip_comments(text);
let mut out = BTreeMap::new();
for (index, word) in code_words(&text) {
if !starts_word(&text, index) {
continue;
}
let rest = text[index + word.len()..].trim_start();
if word == "val" || word == "var" {
let (name, rest) = identifier(rest);
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
if let Some(rest) = rest.strip_prefix('(')
&& let Some((value, used)) = literal(rest.trim_start())
&& rest.trim_start()[used..].trim_start().starts_with(')')
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
continue;
}
if word != "ext" && word != "extra" {
continue;
}
if let Some(block) = rest.strip_prefix('{') {
if let Some(end) = matching_brace(block) {
out.extend(assignments(&block[..end]));
}
} else if let Some(rest) = rest.strip_prefix('.') {
let (name, rest) = identifier(rest);
if let Some(value) = assigned_literal(rest)
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
} else if let Some(rest) = rest.strip_prefix('[')
&& let Some((name, used)) = literal(rest.trim_start())
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
&& let Some(value) = assigned_literal(rest)
{
out.insert(name, value);
}
}
out
}
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with(['#', '!']) {
return None;
}
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
let (key, rest) = line.split_at(separator);
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
})
.collect()
}
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
/// read from disk: absolute path and text. Targets that do not exist are skipped.
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
const MAX_SCRIPTS: usize = 256;
const MAX_DEPTH: usize = 32;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let first = normalize(&root.join(build_file));
let Ok(identity) = dunce::canonicalize(&first) else { return out };
seen.insert(identity);
let mut stack = vec![(first, 0)];
while let Some((path, depth)) = stack.pop() {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if depth < MAX_DEPTH {
let mut targets = Vec::new();
for spec in applied_scripts(&text) {
if seen.len() >= MAX_SCRIPTS {
break;
}
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
&& let Ok(identity) = dunce::canonicalize(&target)
&& seen.insert(identity)
{
targets.push((target, depth + 1));
}
}
stack.extend(targets.into_iter().rev());
}
out.push((path, text));
}
out
}
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
let spec = spec.trim();
if spec.contains("://") {
return None;
}
let properties = BTreeMap::from([
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
]);
let spec = interpolate(spec, &properties);
if spec.contains('$') {
return None;
}
let path = Path::new(&spec);
if path.is_absolute() {
return path.is_file().then(|| normalize(path));
}
let project_path = normalize(&project_dir.join(path));
if project_path.is_file() {
return Some(project_path);
}
let path = normalize(&script.parent()?.join(path));
path.is_file().then_some(path)
}
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
root.join(dir.map_or(&model.dir, |p| &p.dir))
}
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
/// expressions are left as written, so ranges and catalogs stay skipped.
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
if !value.contains('$') {
return value.to_owned();
}
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
if braced {
for n in chars.by_ref() {
if n == '}' {
break;
}
name.push(n);
}
} else {
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || n == '_' || n == '.' {
name.push(n);
chars.next();
} else {
break;
}
}
}
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
let (key, used) = literal(rest.trim_start())?;
(rest.trim_start()[used..].trim() == "]").then_some(key)
});
match properties.get(bracket.as_deref().unwrap_or(key)) {
Some(v) if !name.is_empty() => out.push_str(v),
_ => {
out.push('$');
if braced {
out.push('{');
}
out.push_str(&name);
if braced {
out.push('}');
}
}
}
}
out
}
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
fn starts_word(text: &str, index: usize) -> bool {
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
}
/// Identifiers outside quoted strings, with their byte positions.
fn code_words(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
} else if c.is_alphabetic() || c == '_' {
let (word, _) = identifier(&text[index..]);
out.push((index, word));
index += word.len();
} else {
index += c.len_utf8();
}
}
out
}
fn identifier(text: &str) -> (&str, &str) {
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
text.split_at(end)
}
fn assigned_literal(text: &str) -> Option<String> {
let rest = text.trim_start().strip_prefix('=')?.trim_start();
let (value, used) = literal(rest)?;
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
}
/// The quoted string at the start of `s`, and how many bytes it spans.
fn literal(s: &str) -> Option<(String, usize)> {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &s[quote.len_utf8()..];
let mut index = 0;
while index < rest.len() {
let c = rest[index..].chars().next()?;
if c == quote {
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
}
if c == '\\' {
index += c.len_utf8();
index += rest[index..].chars().next()?.len_utf8();
} else if quote == '"' && rest[index..].starts_with("${") {
index += 2;
index += matching_brace(&rest[index..])? + 1;
} else {
index += c.len_utf8();
}
}
None
}
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
fn matching_brace(text: &str) -> Option<usize> {
let mut depth = 1usize;
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next()?;
if c == '"' || c == '\'' {
index += literal(&text[index..])?.1;
continue;
}
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += c.len_utf8();
}
None
}
/// `name = 'value'` pairs in `text`.
fn assignments(text: &str) -> Vec<(String, String)> {
statements(text)
.into_iter()
.filter_map(|statement| {
let (name, rest) = identifier(statement.trim_start());
let value = assigned_literal(rest)?;
(!name.is_empty()).then(|| (name.to_owned(), value))
})
.collect()
}
/// Every settings and build script of the model (for change detection).
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// Scripts a build script applies are build files too: editing one can change the model.
for project in &model.projects {
let Some(file) = project.build_file.as_deref() else { continue };
let build_dir = project_build_dir(root, model, project);
let properties = build_dir.join("gradle.properties");
if properties.is_file() {
out.push(properties);
}
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
}
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
@@ -969,8 +624,6 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
catalogs.sort();
out.extend(catalogs);
}
out.sort();
out.dedup();
out
}
@@ -1155,7 +808,7 @@ dependencies {
classpath 'not:a:library'
}
"#;
let deps = script_dependencies(script, &BTreeMap::new());
let deps = script_dependencies(script);
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
assert_eq!(
names,
@@ -1169,291 +822,6 @@ dependencies {
);
}
#[test]
fn applied_scripts_read_groovy_and_kotlin_forms() {
let script = r#"
apply plugin: 'java'
apply from: 'gradle/dependencies.gradle'
apply from: file('gradle/extra.gradle')
apply(from = "gradle/kotlin.gradle.kts")
apply(from = file("gradle/kotlin-file.gradle.kts"))
// apply from: 'commented.gradle'
reapply from: 'not-a-call.gradle'
"#;
assert_eq!(
applied_scripts(script),
[
"gradle/dependencies.gradle",
"gradle/extra.gradle",
"gradle/kotlin.gradle.kts",
"gradle/kotlin-file.gradle.kts"
]
);
}
#[test]
fn ext_values_fill_in_dependency_versions() {
let script = r#"
ext {
gsonVersion = '2.8.9'
junit = "4.13.2"
}
ext.logback = '1.4.14'
dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "junit:junit:$junit"
implementation "ch.qos.logback:logback-classic:${ext.logback}"
implementation 'org.plain:literal:1.0'
implementation "org.x:unknown:${missing}"
}
"#;
let names: Vec<String> = script_dependencies(script, &script_properties(script))
.into_iter()
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
.collect();
assert_eq!(
names,
[
"com.google.code.gson:gson:2.8.9",
"junit:junit:4.13.2",
"ch.qos.logback:logback-classic:1.4.14",
"org.plain:literal:1.0",
]
);
}
#[test]
fn extra_properties_support_brackets_and_kotlin_delegates() {
for declaration in [
"ext.x = '1.2'",
"ext { x = '1.2' }",
"ext[\"x\"] = '1.2'",
"extra[\"x\"] = \"1.2\"",
"val x by extra(\"1.2\")",
] {
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
assert_eq!(
script_dependencies(&script, &script_properties(&script)),
[("g".into(), "a".into(), "1.2".into())],
"{declaration}"
);
}
}
#[test]
fn parsers_ignore_apply_and_ext_inside_strings() {
let script = r#"
println "apply from: 'fake.gradle'"
println "ext.x = 'bogus'"
ext.real = '1'
"#;
assert!(applied_scripts(script).is_empty());
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
}
#[test]
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
assert_eq!(
script_dependencies(script, &properties),
[
("g".into(), "a".into(), "1.2".into()),
("g".into(), "b".into(), "1.2-RC1".into()),
("g".into(), "c".into(), "2".into())
]
);
assert_eq!(
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
);
}
#[test]
fn dependency_versions_skip_unresolved_names_and_expressions() {
let script = r#"dependencies {
implementation "g:missing:${unknown}"
implementation "g:expression:${1 + 2}"
implementation "g:cyclic:${cycle}"
implementation 'g:range:[1,2)'
implementation 'g:dynamic:1.+'
}"#;
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
assert!(script_dependencies(script, &properties).is_empty());
}
#[test]
fn extra_property_references_support_qualified_and_bracket_access() {
let script = r#"dependencies {
implementation "g:a:${ext.x}"
implementation "g:b:${extra.x}"
implementation "g:c:${ext['x']}"
implementation "g:d:${extra["x"]}"
}"#;
let properties = BTreeMap::from([("x".into(), "1".into())]);
assert_eq!(script_dependencies(script, &properties).len(), 4);
}
#[test]
fn applied_scripts_are_read_recursively_and_survive_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
.into_iter()
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
}
#[test]
fn applied_paths_prefer_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
std::fs::write(root.join("versions.gradle"), "right").unwrap();
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].1, "right");
}
#[test]
fn nested_applied_paths_are_relative_to_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
}
#[test]
fn applied_paths_fall_back_to_the_applying_script_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
}
#[test]
fn applied_paths_resolve_gradle_directory_variables() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let project = root.join("app");
let script = project.join("gradle/deps.gradle");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(root.join("shared.gradle"), "").unwrap();
std::fs::write(project.join("local.gradle"), "").unwrap();
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
Some(root.join("shared.gradle")),
"{prefix}"
);
}
for prefix in ["$projectDir", "${projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
Some(project.join("local.gradle")),
"{prefix}"
);
}
assert_eq!(
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
Some(root.join("shared.gradle"))
);
}
#[test]
fn applied_scripts_skip_urls_and_missing_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Even a local path resembling a URL must not be read.
if !cfg!(windows) {
for scheme in ["http", "https"] {
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
}
}
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(
root.join("build.gradle"),
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
)
.unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
}
#[cfg(unix)]
#[test]
fn applied_scripts_deduplicate_symlink_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_script_traversal_bounds_depth_and_total_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for i in 0..300 {
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
}
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
assert_eq!(chain.len(), 33);
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
std::fs::write(root.join("build.gradle"), fanout).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
}
#[test]
fn applied_scripts_use_their_composite_build_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("included/app")).unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
std::fs::write(root.join("included/build.gradle"), "").unwrap();
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
let mut model = load(root).unwrap();
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
project.build = Some(":included".into());
model.projects.push(project);
}
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
let model = crate::BuildModel { maven: None, gradle: vec![model] };
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
}
use super::*;
#[test]
+6 -57
View File
@@ -147,8 +147,7 @@ pub struct DependencyCoordinate {
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
/// including the scripts they pull in with `apply from`.
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
@@ -178,26 +177,11 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
}
}
for g in &model.gradle {
for p in &g.projects {
let Some(build_file) = p.build_file.as_deref() else { continue };
let build_dir = gradle::project_build_dir(root, g, p);
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
// The script's own `ext` values win over the scripts it applies.
let mut properties = std::collections::BTreeMap::new();
for (_, text) in scripts.iter().rev() {
properties.extend(gradle::script_properties(text));
}
properties.extend(gradle::file_properties(&build_dir));
for (_, text) in &scripts {
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|(group_id, artifact_id, version)| DependencyCoordinate {
gradle: true,
group_id,
artifact_id,
version,
},
));
}
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
}
}
out.sort();
@@ -328,39 +312,4 @@ mod tests {
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
#[test]
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
std::fs::write(
root.join("gradle/dependencies.gradle"),
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
)
.unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
}
#[test]
fn gradle_properties_override_script_extra_values() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
std::fs::write(
root.join("deps.gradle"),
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
)
.unwrap();
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
assert_eq!(versions, ["1.2", "2.3"]);
}
}
-2
View File
@@ -16,8 +16,6 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub mod sources;
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
-124
View File
@@ -1,124 +0,0 @@
//! On-demand sources resolution using the project's Maven launcher and repositories.
use super::MavenProject;
use crate::process;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
pub struct Artifact {
pub group: String,
pub name: String,
pub version: String,
pub sources: PathBuf,
}
/// Resolve only jars inside the selected local repository, including custom repositories.
pub fn artifact(jar: &Path, repository: &Path) -> Option<Artifact> {
let relative = jar.strip_prefix(repository).ok()?;
let parts: Vec<&str> = relative
.components()
.map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
})
.collect::<Option<_>>()?;
if parts.len() < 4 {
return None;
}
let n = parts.len();
let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]);
let valid = |s: &str| {
!s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
};
if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") {
return None;
}
Some(Artifact {
group: parts[..n - 3].join("."),
name: name.into(),
version: version.into(),
sources: jar.with_file_name(format!("{name}-{version}-sources.jar")),
})
}
pub fn download(
root: &Path,
project: &MavenProject,
artifact: &Artifact,
repository: &Path,
cancel: &CancellationToken,
) -> IdeResult<Option<PathBuf>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()];
args.extend(project.options.clone());
args.extend([
format!("-Dmaven.repo.local={}", repository.display()),
"org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(),
format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version),
"-Dtransitive=false".into(),
]);
let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version);
if output.success
|| output.text.lines().any(|line| {
line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found"))
})
{
return Ok(None);
}
Err(IdeError::unavailable(
output.text.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_sources_coordinates_from_maven_layouts() {
for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] {
for (group, name, version) in [
("org/apache/commons", "commons-lang3", "3.17.0"),
("com/example", "example-lib", "1.0-SNAPSHOT"),
("single", "lib", "1.0.0-rc.1"),
] {
let repository = Path::new(repository);
let dir = repository.join(group).join(name).join(version);
let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap();
assert_eq!(found.group, group.replace('/', "."));
assert_eq!(found.name, name);
assert_eq!(found.version, version);
assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar")));
}
}
}
#[test]
fn rejects_non_artifacts_and_paths_outside_the_repository() {
let repository = Path::new("/m2/repository");
for path in [
"/m2/repository-other/g/a/1/a-1.jar",
"/m2/repository/g/a/1/a-1-sources.jar",
"/m2/repository/g/a/1/other.jar",
"/m2/repository/g/a/../a-1.jar",
"/m2/repository/g/a/1/a-1.pom",
"/m2/repository/g/a-1.jar",
"/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar",
] {
assert_eq!(artifact(Path::new(path), repository), None, "{path}");
}
}
}
-34
View File
@@ -818,40 +818,6 @@ mod tests {
assert!(m.stale, "gradle.properties is watched");
}
#[test]
fn applied_script_edits_invalidate_the_cached_gradle_model() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
let applied = root.join("gradle/dependencies.gradle");
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
let model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
projects: vec![ReportedProject {
path: ":".into(),
dir: root.to_string_lossy().into_owned(),
..Default::default()
}],
..Default::default()
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
let stale = || {
let mut model = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
model.stale
};
assert!(!stale());
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
assert!(!stale());
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
assert!(stale());
std::fs::remove_file(&applied).unwrap();
assert!(stale());
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
-3
View File
@@ -11,6 +11,3 @@ license.workspace = true
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
+1 -60
View File
@@ -21,12 +21,6 @@
use std::path::PathBuf;
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -171,12 +165,8 @@ mod imp {
/// Opens the clipboard as its owner: the list is written in full, so no
/// owning window is needed.
fn open() -> bool {
open_for(ptr::null_mut())
}
fn open_for(owner: Handle) -> bool {
for _ in 0..OPEN_ATTEMPTS {
if unsafe { OpenClipboard(owner) } != 0 {
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
return true;
}
std::thread::sleep(OPEN_RETRY);
@@ -240,29 +230,6 @@ mod imp {
}
}
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
let (memory, _) = global_text(text);
if memory.is_null() {
return Err(std::io::Error::last_os_error());
}
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
unsafe {
if !open_for(owner as Handle) {
GlobalFree(memory);
return Err(std::io::Error::last_os_error());
}
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
let error = std::io::Error::last_os_error();
CloseClipboard();
if taken {
Ok(())
} else {
GlobalFree(memory);
Err(error)
}
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
@@ -373,19 +340,6 @@ mod imp {
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
board.clearContents();
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
Ok(())
} else {
Err(std::io::Error::other("Cannot write clipboard text"))
}
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -416,19 +370,6 @@ mod imp {
mod imp {
use std::path::PathBuf;
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).set_text(text);
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
let _ = text;
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
}
}
pub fn has_files() -> bool {
false
}
+1 -2
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
dunce.workspace = true
percent-encoding = "2"
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -23,4 +22,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
-10
View File
@@ -42,10 +42,6 @@ pub enum IdeEvent {
created: Vec<crate::IpcPath>,
modified: Vec<crate::IpcPath>,
removed: Vec<crate::IpcPath>,
/// Subtrees whose ignore rules changed, after the matchers were rebuilt.
#[serde(default)]
#[specta(optional)]
ignore_rules: Vec<crate::IpcPath>,
},
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
ExcludedDirsChanged {
@@ -152,12 +148,6 @@ pub enum IdeEvent {
SettingsChanged {
key: String,
},
UpdateStatusChanged {
status: crate::updates::UpdateStatus,
},
UpdateAvailable {
update: crate::updates::UpdateInfo,
},
/// The project's source / test / resource / excluded roots changed (build files
/// changed or folders were marked); the UI re-reads them with `project_roots`.
ProjectRootsChanged {
-2
View File
@@ -16,7 +16,6 @@ pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod path_mapping;
pub mod platform;
pub mod process;
pub mod project_dir;
@@ -27,7 +26,6 @@ pub mod settings;
pub mod task;
pub mod text;
pub mod tool_path;
pub mod updates;
pub mod version_managers;
pub mod workflow_settings;
pub mod wsl;
-193
View File
@@ -1,193 +0,0 @@
//! Lexical path translation at protocol boundaries, independent of LSP or DAP.
use percent_encoding::{CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
const ESCAPES: &percent_encoding::AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b']')
.add(b'\\')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathMapping {
host: String,
target: String,
windows: bool,
}
#[derive(Clone, Copy)]
pub enum Direction {
ToTarget,
ToHost,
}
fn normalized(path: &str) -> String {
let path = path
.strip_prefix(r"\\?\UNC\")
.map(|p| format!("//{p}"))
.unwrap_or_else(|| path.strip_prefix(r"\\?\").unwrap_or(path).to_owned());
let path = path.replace('\\', "/");
if path == "/" { path } else { path.trim_end_matches('/').to_owned() }
}
pub fn file_path(uri: &str) -> Option<String> {
if !uri.get(..7)?.eq_ignore_ascii_case("file://") {
return None;
}
let rest = &uri[7..];
let path = if rest.starts_with('/') {
rest.to_owned()
} else {
let (authority, path) = rest.split_once('/')?;
if authority.eq_ignore_ascii_case("localhost") { format!("/{path}") } else { format!("//{authority}/{path}") }
};
let decoded = percent_decode_str(path.split(['?', '#']).next()?).decode_utf8().ok()?;
let path = decoded.strip_prefix('/').filter(|p| p.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
Some(path.to_owned())
}
pub fn file_uri(path: &str) -> String {
let path = normalized(path);
format!("file://{}{}", if path.starts_with('/') { "" } else { "/" }, utf8_percent_encode(&path, ESCAPES))
}
impl PathMapping {
pub fn new(host: &str, target: &str) -> Self {
let host = normalized(host);
let windows = host.as_bytes().get(1) == Some(&b':') || host.starts_with("//");
Self { host, target: normalized(target), windows }
}
pub fn map(&self, value: &str, direction: Direction) -> Option<String> {
let uri_path = file_path(value);
let is_uri = uri_path.is_some();
let path = normalized(uri_path.as_deref().unwrap_or(value));
let (from, to) = match direction {
Direction::ToTarget => (&self.host, &self.target),
Direction::ToHost => (&self.target, &self.host),
};
let prefix = path.get(..from.len())?;
let same = if self.windows && matches!(direction, Direction::ToTarget) {
prefix.eq_ignore_ascii_case(from)
} else {
prefix == from
};
if !same {
return None;
}
let suffix = &path[from.len()..];
if !suffix.is_empty() && !suffix.starts_with('/') && from != "/" {
return None;
}
// Never let lexical parent segments escape either mapped root.
if suffix.split('/').any(|p| p == ".." || p == ".") {
return None;
}
let mapped = format!("{}/{}", to.trim_end_matches('/'), suffix.trim_start_matches('/'));
let mapped = if suffix.is_empty() { to.clone() } else { mapped };
Some(if is_uri {
let suffix = value.find(['?', '#']).map(|i| &value[i..]).unwrap_or("");
format!("{}{suffix}", file_uri(&mapped))
} else if self.windows && matches!(direction, Direction::ToHost) {
mapped.replace('/', "\\")
} else {
mapped
})
}
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
rewrite_strings(value, &|s| self.map(s, direction));
}
}
/// Rewrites strings and object keys (WorkspaceEdit.changes uses URIs as keys).
/// Resolve tokens and command arguments remain opaque; source text is never a path.
pub fn rewrite_strings(value: &mut Value, map: &dyn Fn(&str) -> Option<String>) {
match value {
Value::String(s) => {
if let Some(mapped) = map(s) {
*s = mapped;
}
}
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_strings(v, map)),
Value::Object(fields) => {
let old = std::mem::take(fields);
for (key, mut value) in old {
if !matches!(key.as_str(), "data" | "arguments" | "text" | "newText" | "insertText") {
rewrite_strings(&mut value, map);
}
fields.insert(map(&key).unwrap_or(key), value);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn roots_encoding_boundaries_and_windows() {
for host in ["C:/Users/me/项目", r"C:\Users\me\项目", r"\\?\C:\Users\me\项目"] {
let m = PathMapping::new(host, "/app");
assert_eq!(m.map(r"\\?\c:\Users\me\项目\a b#%.php", Direction::ToTarget).unwrap(), "/app/a b#%.php");
assert_eq!(
m.map("file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php", Direction::ToTarget).unwrap(),
"file:///app/a%20b%23%25.php"
);
assert_eq!(
m.map("file:///app/a%20b%23%25.php", Direction::ToHost).unwrap(),
"file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php"
);
assert_eq!(m.map("/app/a.php", Direction::ToHost).unwrap(), r"C:\Users\me\项目\a.php");
for outside in ["/app2/a", "/vendor/a", "/app/../outside", "jdt://app/a"] {
assert!(m.map(outside, Direction::ToHost).is_none());
}
}
let unc = PathMapping::new(r"\\?\UNC\server\share\project", "/app");
assert_eq!(unc.map("file://server/share/project/a.php", Direction::ToTarget).unwrap(), "file:///app/a.php");
assert_eq!(unc.map("/app/a.php", Direction::ToHost).unwrap(), r"\\server\share\project\a.php");
let m = PathMapping::new("/home/me/project", "/app");
assert_eq!(m.map("/home/me/project", Direction::ToTarget).unwrap(), "/app");
assert_eq!(m.map("file://localhost/app/a", Direction::ToHost).unwrap(), "file:///home/me/project/a");
assert!(m.map("/home/me/project2/a", Direction::ToTarget).is_none());
assert_eq!(
m.map("file:///app/a%23b?query#fragment", Direction::ToHost).unwrap(),
"file:///home/me/project/a%23b?query#fragment"
);
}
#[test]
fn nested_messages_resource_operations_and_opaque_payloads() {
let m = PathMapping::new("/host/project", "/app");
let mut v = json!({"rootUri":"file:///host/project", "rootPath":"/host/project", "workspaceFolders":[{"uri":"file:///host/project"}],
"edit":{"changes":{"file:///host/project/a":[]}, "documentChanges":[{"oldUri":"file:///host/project/a", "newUri":"file:///host/project/b"}]},
"relatedInformation":[{"location":{"uri":"file:///host/project/a"}}], "target":"file:///host/project/b",
"configuration":[{"includePath":"/host/project/vendor"}], "text":"/host/project/a", "newText":"/host/project/b", "data":{"uri":"file:///host/project/a"}, "command":{"arguments":["/host/project/a"]}});
let original = v.clone();
m.rewrite(&mut v, Direction::ToTarget);
assert_eq!(v["rootUri"], "file:///app");
assert_eq!(v["rootPath"], "/app");
assert!(v["edit"]["changes"].get("file:///app/a").is_some());
assert_eq!(v["edit"]["documentChanges"][0]["newUri"], "file:///app/b");
assert_eq!(v["relatedInformation"][0]["location"]["uri"], "file:///app/a");
assert_eq!(v["configuration"][0]["includePath"], "/app/vendor");
assert_eq!(v["data"], original["data"]);
assert_eq!(v["command"], original["command"]);
m.rewrite(&mut v, Direction::ToHost);
assert_eq!(v, original);
}
}
+3 -271
View File
@@ -104,11 +104,6 @@ fn signal_group(child: &Child, signal: libc::c_int) {
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
pub use windows_job::Job;
#[cfg(windows)]
pub use windows_tree::ProcessTree;
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
/// started is gone: termination is asynchronous, and until a process is gone the files it
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
@@ -141,48 +136,13 @@ mod windows_tree {
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
TerminateProcess, WaitForSingleObject,
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
pub struct ProcessTree {
processes: Vec<OwnedHandle>,
}
impl ProcessTree {
pub fn capture(pid: u32) -> Self {
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
let children = descendants_with(pid, PROCESS_TERMINATE);
Self { processes: std::iter::once(root).chain(children).collect() }
}
pub fn is_running(&self) -> bool {
self.processes.iter().any(|process| {
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
unsafe {
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
}
})
}
/// Requests termination children first; poll `is_running` until termination completes.
pub fn terminate(&self) {
for process in self.processes.iter().rev() {
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
}
}
}
/// Every process `root` started, directly or not, opened so it can be waited for. A
/// parent's id is reused once it is gone, so a process counts only if it started after
/// its parent.
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
descendants_with(root, 0)
}
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
let processes = processes();
let mut seen = HashSet::from([root]);
@@ -193,7 +153,7 @@ mod windows_tree {
if ppid != parent || !seen.insert(pid) {
continue;
}
let Some(handle) = open_with(pid, access) else { continue };
let Some(handle) = open(pid) else { continue };
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
parents.push((pid, at));
found.push(handle);
@@ -233,12 +193,8 @@ mod windows_tree {
}
fn open(pid: u32) -> Option<OwnedHandle> {
open_with(pid, 0)
}
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
// SAFETY: as above.
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
}
@@ -253,167 +209,6 @@ mod windows_tree {
}
}
/// A kernel job object over one launched process tree. Everything a launch starts joins
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
/// started after the launch's snapshot was taken, orphans of a launcher that already
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
/// processes born in the window a process-enumeration kill would miss. Spawns assign
/// their root right after it starts; when the assignment fails (an enclosing job that
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
#[cfg(windows)]
mod windows_job {
use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
SetInformationJobObject, TerminateJobObject,
};
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A job object holding one launch's processes. The handle is kept open for the
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
/// launch record (a run entry, a debug session) is finally dropped.
pub struct Job(OwnedHandle);
impl Job {
/// Takes the just-started `child` into a new job, so everything it starts is in
/// there too. `None` when the job could not be made or the process rejected the
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
pub fn for_child(child: &std::process::Child) -> Option<Job> {
// SAFETY: the child's own handle is a live process handle.
Self::from_handle(child.as_raw_handle())
}
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
/// a `tokio` child). `None` when the id no longer names an openable process — a
/// caller that holds the child cannot have had it reaped, so this is a dead or
/// already-gone launch.
pub fn for_pid(pid: u32) -> Option<Job> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return None;
}
// SAFETY: the handle was just opened and is owned here.
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
Self::from_handle(handle.as_raw_handle())
}
fn from_handle(handle: RawHandle) -> Option<Job> {
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() || job == INVALID_HANDLE_VALUE {
return None;
}
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let job = unsafe { OwnedHandle::from_raw_handle(job) };
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
PerProcessUserTimeLimit: 0,
PerJobUserTimeLimit: 0,
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
MinimumWorkingSetSize: 0,
MaximumWorkingSetSize: 0,
ActiveProcessLimit: 0,
Affinity: 0,
PriorityClass: 0,
SchedulingClass: 0,
},
IoInfo: IO_COUNTERS {
ReadOperationCount: 0,
WriteOperationCount: 0,
OtherOperationCount: 0,
ReadTransferCount: 0,
WriteTransferCount: 0,
OtherTransferCount: 0,
},
ProcessMemoryLimit: 0,
JobMemoryLimit: 0,
PeakProcessMemoryUsed: 0,
PeakJobMemoryUsed: 0,
};
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let ok = unsafe {
SetInformationJobObject(
job.as_raw_handle(),
JobObjectExtendedLimitInformation,
&mut limits as *mut _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if ok == 0 {
return None;
}
// SAFETY: the process handle belongs to the just-spawned child and outlives
// this call; the job handle is owned here.
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
if ok == 0 {
return None;
}
Some(Job(job))
}
/// Adds another just-started root of the same launch to the job (a Go build's
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
/// take; the process then relies on its own kill.
pub fn assign_pid(&self, pid: u32) -> bool {
// SAFETY: plain call; the handle is opened only to assign it, and the job
// handle is owned and valid.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return false;
}
// SAFETY: both handles are valid for the duration of the call.
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
// SAFETY: the process handle was opened above and is owned here.
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
ok != 0
}
/// Terminates every process in the job. `false` when the call failed (the job
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
/// the tree is gone.
pub fn terminate(&self) -> bool {
// SAFETY: a job handle owned here.
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
}
/// Whether any process of the launch is still in the job. When the job's state
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
/// [`Job::terminate`] does not depend on the answer.
pub fn is_running(&self) -> bool {
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
// longer, which means there are processes.
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
NumberOfAssignedProcesses: 0,
NumberOfProcessIdsInList: 0,
ProcessIdList: [0],
};
let mut returned = 0u32;
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
let ok = unsafe {
QueryInformationJobObject(
self.0.as_raw_handle(),
JobObjectBasicProcessIdList,
&mut list as *mut _ as *mut _,
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
&mut returned,
)
};
if ok != 0 {
list.NumberOfProcessIdsInList > 0
} else {
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
}
}
}
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
@@ -1119,66 +914,3 @@ mod kill_tests {
assert!(started.elapsed() < Duration::from_secs(5));
}
}
#[cfg(all(test, windows))]
mod job_tests {
use super::*;
use std::os::windows::process::CommandExt;
use std::process::Stdio;
use std::time::Instant;
/// `cmd` that keeps a `ping` grandchild for a minute, like a launcher that started an
/// application: the job must reach both.
fn launch_tree() -> (std::process::Child, Job, ProcessTree) {
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let child = cmd.spawn().unwrap();
let job = Job::for_child(&child).expect("the job takes the launch");
let tree = ProcessTree::capture(child.id());
assert!(tree.is_running(), "cmd started a grandchild the job inherits");
(child, job, tree)
}
/// Termination is asynchronous: the job reports its processes gone while their
/// handles still wait, so wait on the handles (the reused-pid-proof oracle).
fn assert_gone(tree: &ProcessTree) {
let until = Instant::now() + Duration::from_secs(10);
while tree.is_running() {
assert!(Instant::now() < until, "the tree survived the job");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn job_terminates_the_tree_the_root_started() {
let (mut child, job, tree) = launch_tree();
assert!(job.is_running());
assert!(job.terminate(), "TerminateJobObject succeeds");
// The captured tree's handles survive the root's death, so the wait below is the
// grandchild's, not a reused pid's.
assert_gone(&tree);
assert!(!job.is_running());
let _ = child.wait();
}
#[test]
fn a_later_spawned_root_joins_the_job() {
let (mut first, job, _) = launch_tree();
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let mut second = cmd.spawn().unwrap();
let second_tree = ProcessTree::capture(second.id());
assert!(job.assign_pid(second.id()), "the follow-up run joins the job");
assert!(job.terminate());
assert_gone(&second_tree);
let _ = first.wait();
let _ = second.wait();
}
#[test]
fn a_dead_pid_makes_no_job() {
// An id of a process this test cannot open (or that does not exist) gives no job,
// which is the callers' cue for the `taskkill /T /F` fallback.
assert!(Job::for_pid(u32::MAX).is_none());
}
}
+1 -12
View File
@@ -201,18 +201,7 @@ pub struct PluginRunTarget {
pub args: Vec<String>,
pub workdir_args: Vec<String>,
pub container_root: Option<String>,
pub commands: Vec<PluginCommandOverride>,
}
/// Per-command argv prefix and optional replacement working-directory arguments.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommandOverride {
pub name: String,
pub args: Vec<String>,
/// None inherits the target's list; Some([]) suppresses it.
#[serde(default)]
pub workdir_args: Option<Vec<String>>,
pub commands: Vec<(String, Vec<String>)>,
}
#[cfg(test)]
-122
View File
@@ -48,8 +48,6 @@ pub struct Settings {
/// Per language server, by name (`rust-analyzer`): launch and protocol
/// options on top of the language definition, or a server defined here.
pub language_servers: BTreeMap<String, LanguageServerSettings>,
/// Project language id → server and plugin target. Empty target uses the host.
pub language_server_targets: BTreeMap<String, LanguageServerTarget>,
/// Editor › Inspections profiles.
pub inspections: InspectionSettings,
/// Editor › Natural Languages › Spelling.
@@ -141,14 +139,6 @@ pub struct ToolSettings {
pub path: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerTarget {
pub target: String,
/// Empty selects the first configured server for the language.
pub server: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerSettings {
@@ -243,7 +233,6 @@ impl Default for Settings {
external_tools: Vec::new(),
tools: BTreeMap::new(),
language_servers: BTreeMap::new(),
language_server_targets: BTreeMap::new(),
inspections: InspectionSettings::default(),
spelling: SpellingSettings::default(),
ssh: SshSettings::default(),
@@ -277,10 +266,6 @@ pub struct CustomKeymap {
pub struct GeneralSettings {
pub reopen_last_project: bool,
pub confirm_exit: bool,
/// When on, the last window closes instead of showing the Welcome screen.
pub close_without_welcome: bool,
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
pub close_tabs_with_window: bool,
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
pub open_project_in: String,
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
@@ -296,7 +281,6 @@ pub struct GeneralSettings {
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
pub auto_check_updates: bool,
}
impl Default for GeneralSettings {
@@ -304,8 +288,6 @@ impl Default for GeneralSettings {
Self {
reopen_last_project: true,
confirm_exit: true,
close_without_welcome: false,
close_tabs_with_window: false,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -314,7 +296,6 @@ impl Default for GeneralSettings {
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
auto_check_updates: true,
}
}
}
@@ -369,21 +350,6 @@ impl Default for NotificationGroupSettings {
}
}
/// Where the Navigation Bar (the file path breadcrumbs) is shown. Mirrors
/// IDEA's `View | Appearance | Navigation Bar` (Top / Status Bar / Don't show).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum NavigationBarPosition {
/// Above the editor, below the main menu / toolbar.
Top,
/// In the footer, next to the status bar widgets — the default, matching
/// where the bar lived before the three-state choice existed.
#[default]
StatusBar,
/// Not shown at all.
Hidden,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AppearanceSettings {
@@ -432,9 +398,6 @@ pub struct AppearanceSettings {
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
pub show_main_toolbar: bool,
pub show_status_bar: bool,
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
/// the status bar by default, above the editor, or not at all.
pub navigation_bar: NavigationBarPosition,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
pub status_bar_widgets: BTreeMap<String, bool>,
@@ -507,7 +470,6 @@ impl Default for AppearanceSettings {
widescreen_tool_windows: false,
show_main_toolbar: true,
show_status_bar: true,
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
@@ -545,9 +507,6 @@ pub struct EditorSettings {
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
pub semantic_highlighting: bool,
/// Docstring markup format whose tags and keywords the Python editor highlights
/// (`plain`, `rest`, `epytext`, `google`, `numpy`).
pub docstring_format: String,
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
pub rainbow_brackets: bool,
pub ligatures: bool,
@@ -592,7 +551,6 @@ impl Default for EditorSettings {
color_overrides: BTreeMap::new(),
custom_schemes: BTreeMap::new(),
semantic_highlighting: false,
docstring_format: "rest".into(),
rainbow_brackets: false,
ligatures: false,
fallback_font: String::new(),
@@ -637,15 +595,6 @@ pub struct FileSettings {
pub file_types: BTreeMap<String, FileTypeAssociation>,
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
pub file_type_overrides: BTreeMap<String, String>,
/// Compose detection choices, keyed by project-relative file path.
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ComposeFileOverride {
Kept,
Excluded,
}
/// One language's file name and hashbang patterns on top of its definition.
@@ -673,7 +622,6 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
compose_file_overrides: BTreeMap::new(),
}
}
}
@@ -695,7 +643,6 @@ pub struct LanguagesSettings {
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
}
impl Default for LanguagesSettings {
@@ -717,22 +664,10 @@ impl Default for LanguagesSettings {
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
arduino: ArduinoSettings::default(),
}
}
}
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArduinoSettings {
pub cli: String,
pub clangd: String,
pub language_server: String,
pub cli_config: String,
pub fqbn: String,
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -950,8 +885,6 @@ pub struct JavaSettings {
/// Extra JVM options for the Java language server (jdtls), space-separated
/// (VS Code's `java.jdt.ls.vmargs`).
pub jdtls_vm_args: String,
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
pub jdtls_shared: bool,
/// Build Tools › Maven.
pub maven: MavenSettings,
/// Build Tools › Gradle.
@@ -1235,26 +1168,6 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn window_close_settings_default_off_and_round_trip() {
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
let settings: Settings = serde_json::from_str(old).unwrap();
assert!(!settings.general.close_without_welcome);
assert!(!settings.general.close_tabs_with_window);
}
for close_window in [false, true] {
for close_tabs in [false, true] {
let mut settings = Settings::default();
settings.general.close_without_welcome = close_window;
settings.general.close_tabs_with_window = close_tabs;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["general"]["closeWithoutWelcome"], close_window);
assert_eq!(json["general"]["closeTabsWithWindow"], close_tabs);
assert_eq!(serde_json::from_value::<Settings>(json).unwrap().general, settings.general);
}
}
}
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
@@ -1317,21 +1230,6 @@ mod tests {
}
}
#[test]
fn docstring_format_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"editor":{"semanticHighlighting":true}}"#).unwrap();
assert_eq!(old.editor.docstring_format, "rest");
assert_eq!(Settings::default().editor.docstring_format, "rest");
for format in ["plain", "rest", "epytext", "google", "numpy"] {
let mut settings = Settings::default();
settings.editor.docstring_format = format.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["editor"]["docstringFormat"], format);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.editor.docstring_format, format);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
@@ -1568,26 +1466,6 @@ mod maven_linkage_tests {
}
}
#[cfg(test)]
mod java_settings_tests {
use super::*;
#[test]
fn shared_jdtls_defaults_off_and_round_trips() {
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
let java: JavaSettings = serde_json::from_str(old).unwrap();
assert!(!java.jdtls_shared);
}
for shared in [false, true] {
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
let json = serde_json::to_value(&java).unwrap();
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
}
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
-151
View File
@@ -1,151 +0,0 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashSet;
use std::time::{Duration, Instant};
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum UpdateSource {
#[default]
Github,
Cnb,
}
impl UpdateSource {
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
match preference {
"github" => return Self::Github,
"cnb" => return Self::Cnb,
_ => {}
}
let locale = locale.trim();
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
Self::Cnb
} else {
Self::Github
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub version: String,
pub current_version: String,
pub notes: Option<String>,
pub date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
pub enabled: bool,
pub update: Option<UpdateInfo>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatus {
/// Unix milliseconds of the last successful check in this process.
pub last_checked: Option<f64>,
pub checking: bool,
}
/// One schedule and announcement history for the desktop process, independent of its windows.
#[derive(Default)]
pub struct UpdatePolicy {
attempted: Option<Instant>,
pub status: UpdateStatus,
announced: HashSet<String>,
}
impl UpdatePolicy {
pub fn due_in(&self, now: Instant) -> Duration {
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
}
pub fn begin(&mut self, now: Instant) {
self.attempted = Some(now);
self.status.checking = true;
}
pub fn finish(&mut self, checked_at: Option<f64>) {
self.status.checking = false;
if let Some(at) = checked_at {
self.status.last_checked = Some(at);
}
}
pub fn announced(&self, version: &str) -> bool {
self.announced.contains(version)
}
/// Manual checks always show their result, but also count as an announcement.
pub fn announce(&mut self, version: &str) -> bool {
self.announced.insert(version.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
for (preference, locale, system, expected) in [
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
("auto", "system", "zh-CN", UpdateSource::Cnb),
("auto", "auto", "zh-TW", UpdateSource::Cnb),
("auto", "", "zh", UpdateSource::Cnb),
("auto", "en", "zh-CN", UpdateSource::Github),
("github", "zh", "zh-CN", UpdateSource::Github),
("cnb", "en", "en-US", UpdateSource::Cnb),
] {
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
}
}
#[test]
fn checks_at_startup_and_every_four_hours_even_after_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
assert_eq!(policy.due_in(now), Duration::ZERO);
policy.begin(now);
assert!(policy.status.checking);
policy.finish(None);
assert_eq!(policy.status.last_checked, None);
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
policy.begin(now + CHECK_INTERVAL);
policy.finish(Some(1234.0));
assert_eq!(policy.status.last_checked, Some(1234.0));
assert!(!policy.status.checking);
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
}
#[test]
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
policy.begin(now);
policy.finish(Some(1000.0));
policy.begin(now + Duration::from_secs(60));
policy.finish(None);
assert_eq!(policy.status.last_checked, Some(1000.0));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
}
#[test]
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
let mut policy = UpdatePolicy::default();
assert!(policy.announce("0.1.40"));
assert!(!policy.announce("0.1.40"));
assert!(policy.announce("0.1.41"));
assert!(!policy.announce("0.1.40"));
assert!(UpdatePolicy::default().announce("0.1.40"));
}
}
-2
View File
@@ -24,7 +24,5 @@ tracing.workspace = true
libc = "0.2"
[dev-dependencies]
ide-remote.workspace = true
ide-run.workspace = true
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
+83 -152
View File
@@ -31,7 +31,6 @@ pub enum Transport {
/// A fully resolved adapter command line.
#[derive(Clone, Debug)]
pub struct AdapterSpec {
pub target: Option<crate::target::TargetMapping>,
pub adapter: String,
pub command: String,
pub args: Vec<String>,
@@ -52,7 +51,6 @@ pub struct AdapterSpec {
/// Built-in adapters used when no language definition names `adapter`.
fn builtin(adapter: &str) -> Option<DebuggerConfig> {
let cfg = |command: &str, args: &[&str], transport: &str, hint: &str| DebuggerConfig {
target_placement: None,
adapter: adapter.to_owned(),
command: command.to_owned(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -136,7 +134,6 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
AdapterSpec {
target: None,
adapter: config.adapter.clone(),
command: config.command.clone(),
args: config.args.clone(),
@@ -169,11 +166,6 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
}
/// A port that was free a moment ago on the loopback interface.
///
/// Inherently racy: another process can bind the port between the listener being
/// dropped here and the adapter binding it. Callers that hand the port to a child
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
pub fn free_port() -> std::io::Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
@@ -402,9 +394,6 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
/// Probe of a `connect` address before starting the command.
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
/// claimed by another process before the adapter binds it, so a new one usually wins.
const PORT_ATTEMPTS: u32 = 3;
impl AdapterLauncher for ProcessLauncher {
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
@@ -450,7 +439,7 @@ impl AdapterLauncher for ProcessLauncher {
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
if spec.adapter == DEBUGPY_TOOL {
prepare_debugpy(&mut spec).await?;
}
if is_plugin_bundle(&spec.command) {
@@ -470,141 +459,95 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
// retried if another process takes it before the adapter binds.
let fixed = match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => None,
};
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
for attempt in 1..=PORT_ATTEMPTS {
let port = match fixed {
Some(port) => port,
None => free_port()?,
};
let args = substitute_port(&base_args, port);
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
Ok(io) => return Ok(io),
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
target: "ide_dap::adapter",
"debug adapter failed to start on port {port}: {}; retrying",
e.message
);
continue;
}
return Err(e);
}
}
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => Some(free_port()?),
},
Transport::Stdio => None,
};
let args = substitute_root(&spec.args, &spec.cwd);
let args = match port {
Some(port) => substitute_port(&args, port),
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
if spec.adapter == "rdbg" {
ide_core::process::own_group(cmd.as_std_mut());
}
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
unreachable!("the retry loop returns on its last attempt")
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
Ok(s) => s,
Err(e) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
})
}
}
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
async fn spawn_adapter(
spec: &AdapterSpec,
binary: &Path,
args: &[String],
port: Option<u16>,
start_timeout: Duration,
) -> IdeResult<AdapterIo> {
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
let mut cmd = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
// Every adapter leads its own process group: the program an adapter launches itself
// (java-debug's JVM) joins the group, so a forced stop reaches the whole tree with
// one `kill(-pgid)`. On Windows the same coverage comes from the session's job object.
ide_core::process::own_group(cmd.as_std_mut());
cmd.args(args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
Ok(s) => s,
Err(e) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
}
/// A failed adapter start that another process grabbing the free port can explain
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
/// it could not bind). Anything else is a real start failure and is not retried.
fn is_port_race(err: &IdeError) -> bool {
let message = err.message.to_ascii_lowercase();
message.contains("address already in use")
|| message.contains("addrinuse")
|| message.contains("only one usage of each socket address")
|| message.contains("exited before accepting connections")
}
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
@@ -832,16 +775,4 @@ mod tests {
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
);
assert!(is_port_race(&in_use));
assert!(is_port_race(&IdeError::unavailable(
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
)));
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
}
}
+2 -24
View File
@@ -71,7 +71,6 @@ pub enum Incoming {
type Pending = HashMap<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
struct Inner {
mapping: Option<crate::target::TargetMapping>,
out: mpsc::UnboundedSender<Vec<u8>>,
pending: Mutex<Pending>,
seq: AtomicI64,
@@ -107,22 +106,9 @@ impl Connection {
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
/// own output on stdout, decoded) to `on_output`.
pub fn start_with_output<R, W>(
reader: R,
writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start_with_target(reader, writer, on_output, None)
}
pub fn start_with_target<R, W>(
reader: R,
mut writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
target: Option<crate::target::TargetMapping>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
@@ -131,7 +117,6 @@ impl Connection {
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (in_tx, in_rx) = mpsc::unbounded_channel();
let inner = Arc::new(Inner {
mapping: target,
out: out_tx,
pending: Mutex::new(HashMap::new()),
seq: AtomicI64::new(1),
@@ -202,11 +187,7 @@ impl Connection {
if self.is_closed() {
return Err(DapError::Closed);
}
let mut value = value.clone();
if let Some(mapping) = self.inner.mapping.as_ref() {
mapping.rewrite(&mut value, ide_core::path_mapping::Direction::ToTarget);
}
let body = serde_json::to_vec(&value).map_err(|e| DapError::Protocol(e.to_string()))?;
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
self.inner.out.send(body).map_err(|_| DapError::Closed)
}
@@ -265,16 +246,13 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: unparsable message: {e}");
return;
}
};
if let Some(mapping) = inner.mapping.as_ref() {
mapping.rewrite(&mut msg, ide_core::path_mapping::Direction::ToHost);
}
match msg.get("type").and_then(Value::as_str) {
Some("response") => {
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
+10 -77
View File
@@ -16,7 +16,6 @@ pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
pub mod target;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
@@ -608,22 +607,11 @@ fn remove_delve_binaries(cwd: &Path) {
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
// Windows: a just-killed dlv / debuggee can still hold this session's
// binary while the previous one's lock is already gone, so one try
// leaves exactly the current leftover behind. Retry on a short back-off
// to outlast the handle release; a truly busy file is retried into the
// next run's sweep anyway.
for attempt in 0..5 {
match std::fs::remove_file(entry.path()) {
Ok(()) => break,
Err(e) if attempt == 4 => {
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
}
if leftover
&& entry.file_type().is_ok_and(|t| t.is_file())
&& let Err(e) = std::fs::remove_file(entry.path())
{
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
}
}
@@ -697,17 +685,6 @@ impl DebugManager {
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
self.start_prepared(config, owner, custom_command, |_, _| Ok(())).await
}
/// Configure a target adapter before any host tool resolution or launch.
pub async fn start_prepared(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
prepare: impl FnOnce(&mut AdapterSpec, &mut Value) -> IdeResult<()>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
@@ -722,22 +699,6 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
prepare(&mut spec, &mut arguments)?;
if spec.target.is_some() {
return self
.start_session(
DebugSessionId::next(),
owner,
config.name,
config.adapter,
None,
config.request,
arguments,
spec,
None,
)
.await;
}
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
@@ -886,22 +847,11 @@ impl DebugManager {
if request != "launch" && request != "attach" {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await.map_err(|e| {
if spec.target.is_some() {
IdeError::new(e.kind, format!("{}\nargv: {:?} {:?}", e.message, spec.command, spec.args))
} else {
e
}
})?;
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_target(
io.reader,
io.writer,
Box::new(move |line| adapter::push_line(&stderr, line)),
spec.target.clone(),
)
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
@@ -942,9 +892,6 @@ impl DebugManager {
Err(e) => {
let e = if session.conn.is_closed() {
let mut msg = format!("debug adapter `{}` exited during startup", session.adapter);
if session.spec.target.is_some() {
msg.push_str(&format!("\nargv: {:?} {:?}", session.spec.command, session.spec.args));
}
let tail = stderr.lock().join("\n");
if !tail.is_empty() {
msg.push_str(":\n");
@@ -955,17 +902,6 @@ impl DebugManager {
msg.push_str(hint);
}
IdeError::unavailable(msg)
} else if session.spec.target.is_some() {
IdeError::new(
e.kind,
format!(
"{}\nargv: {:?} {:?}\n{}",
e.message,
session.spec.command,
session.spec.args,
stderr.lock().join("\n")
),
)
} else {
e
};
@@ -1003,8 +939,8 @@ impl DebugManager {
"pathFormat": "path",
"supportsVariableType": true,
"supportsVariablePaging": false,
"supportsRunInTerminalRequest": s.spec.target.is_none(),
"supportsStartDebuggingRequest": s.spec.target.is_none(),
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsProgressReporting": true,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
@@ -1130,9 +1066,6 @@ impl DebugManager {
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
if terminate {
crate::session::remember_debuggee(s).await;
}
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
// ends that DAP session, so every line the program writes while shutting down is lost.
// Ask the program to exit ourselves and keep the adapter attached until it has drained
@@ -1160,7 +1093,7 @@ impl DebugManager {
if own_terminate && !graceful {
// Signal before anything else: waiting on a request the adapter may not answer
// would delay the shutdown the console is supposed to show.
crate::session::signal_debuggee(s, false).await;
crate::session::signal_debuggee(s, false);
}
if graceful {
// java-debug sends `terminated` only after it has drained the program's console
+21 -187
View File
@@ -45,13 +45,6 @@ pub(crate) struct Session {
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// The job object over the adapter and everything it started: the program an adapter
/// like java-debug launches is its child, so a forced stop (and the session's
/// cleanup) ends the whole tree even when the adapter exited first and the reported
/// pid no longer names it. `None` when the assignment failed; the kill then falls
/// back to the captured process tree.
#[cfg(windows)]
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
@@ -66,8 +59,6 @@ pub(crate) struct Session {
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
#[cfg(windows)]
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -98,10 +89,6 @@ impl Session {
port: Option<u16>,
child: Option<Child>,
) -> Self {
// Before `child` moves into the session: the adapter is the root of the job, so
// everything it starts (java-debug's JVM) is in there by inheritance.
#[cfg(windows)]
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
Self {
id,
owner,
@@ -116,8 +103,6 @@ impl Session {
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
@@ -126,8 +111,6 @@ impl Session {
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
#[cfg(windows)]
stop_tree: Mutex::new(None),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -295,24 +278,6 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
#[cfg(unix)]
if self.request == "launch" {
for group in launch_groups(self) {
ide_core::process::kill_tree_of(group as u32);
}
}
// The job first: it ends the adapter and everything it started (the program
// java-debug launched is its child), members included that the stop's captured
// tree never saw. An empty job is unaffected, and whatever survives this session
// dies when the handle closes (`KILL_ON_JOB_CLOSE`).
#[cfg(windows)]
if let Some(job) = self.job.lock().as_ref() {
job.terminate();
}
#[cfg(windows)]
if let Some(tree) = self.stop_tree.lock().as_ref() {
tree.terminate();
}
if let Some(mut c) = self.adapter_process.lock().take() {
if self.adapter == "rdbg"
&& let Some(pid) = c.id()
@@ -326,9 +291,6 @@ impl Session {
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = program.child.wait();
});
}
}
}
@@ -336,21 +298,6 @@ impl Session {
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
#[cfg(windows)]
{
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
return;
}
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
s.stop_tree.lock().get_or_insert(tree);
}
}
#[cfg(not(windows))]
let _ = s;
}
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
@@ -380,36 +327,18 @@ fn process_tree(pid: i32) -> Vec<i32> {
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
if s.request != "launch" {
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
#[cfg(not(windows))]
let pid = s.debuggee_pid.load(Ordering::Acquire);
#[cfg(unix)]
{
let needs_tree = pid > 0 && s.stop_pids.lock().is_empty();
if needs_tree {
let tree = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
*s.stop_pids.lock() = tree;
let mut known = s.stop_pids.lock();
if known.is_empty() {
*known = process_tree(pid as i32);
}
let known = s.stop_pids.lock();
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
if force {
// A forced stop kills the launch: every root the session spawned leads its own
// process group (`own_group` at spawn), so one signal reaches the program and
// everything it started, even processes born after the snapshot below was
// taken — the adapter's program is in the adapter's group. The session's own
// JVM roots (Kotlin) and `runInTerminal` programs lead groups of their own.
// The roots are unreaped until the session drops them, so their ids (and the
// groups named by them) cannot have been reused yet.
for group in launch_groups(s) {
// SAFETY: plain syscalls on the processes this session started.
if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 {
unsafe { libc::kill(group, libc::SIGKILL) };
}
}
}
// Children first: the shell that started the program would otherwise reparent it.
for p in known.iter().rev() {
// SAFETY: plain syscall on the processes this session started.
@@ -420,53 +349,17 @@ pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
}
#[cfg(windows)]
{
// Console programs have no external graceful signal; the adapter gets its chance
// first. The job ends the program and everything it started, members included
// that the captured tree never saw.
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
if force {
if let Some(job) = s.job.lock().as_ref() {
job.terminate();
}
// A separately spawned program may have rejected assignment to the adapter's job.
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
}
cmd.arg("/F");
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// The process groups the session spawned roots for: the adapter's (the program it
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
#[cfg(unix)]
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
let mut groups = Vec::new();
let mut push = |id: Option<u32>| {
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
groups.push(pid);
}
};
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
for child in s.debuggees.lock().iter() {
push(child.id());
}
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
groups.retain(|g| *g > 0);
groups
}
/// Whether the launch is still running: the stop's captured tree when there is one,
/// otherwise the session's job (the adapter and everything it started).
#[cfg(windows)]
fn launch_running(s: &Arc<Session>) -> bool {
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.is_running()
} else {
s.job.lock().as_ref().is_some_and(|job| job.is_running())
}
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
@@ -480,39 +373,25 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
#[cfg(unix)]
{
use std::time::Instant;
let mut targets = s.stop_pids.lock().clone();
if targets.is_empty() {
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
}
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true).await;
signal_debuggee(s, true);
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true).await;
signal_debuggee(s, true);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(windows)]
{
remember_debuggee(s).await;
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
// The captured tree is the oracle when it was captured; otherwise the job (every
// process the launch started) is, so a failed capture can no longer end the wait
// while the program still holds its port.
while launch_running(s) {
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
signal_debuggee(s, true).await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(any(unix, windows)))]
#[cfg(not(unix))]
{
let _ = pid;
}
@@ -700,10 +579,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, command: &str, args: Value) {
if s.spec.target.is_some() {
let _ = s.conn.respond(seq, command, Err("This debugger does not support plugin run targets".into()));
return;
}
match command {
"runInTerminal" => {
let result = run_in_terminal(s, &args);
@@ -790,14 +665,6 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
}
}
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
// The program joins the session's job, so Stop reaches what it starts (its children),
// though only the program itself is the session's child.
#[cfg(windows)]
if let Some(pid) = child.process_id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
@@ -924,6 +791,7 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
@@ -936,9 +804,7 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
// port here races with other processes that may take it before the JVM binds.
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
@@ -965,33 +831,20 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
}
}
}
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
// forced stop), the session's job on Windows, so a stop reaches what it forks.
ide_core::process::own_group(cmd.as_std_mut());
let mut child = cmd.spawn()?;
#[cfg(windows)]
if let Some(pid) = child.id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
// the free-port race window entirely.
let port = tokio::time::timeout(Duration::from_secs(15), async {
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return parse_jdwp_port(&line).ok_or_else(|| {
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
});
return Ok(());
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
@@ -1005,22 +858,3 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
args["timeout"] = json!(10000);
Ok(())
}
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
fn parse_jdwp_port(line: &str) -> Option<u16> {
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
}
-72
View File
@@ -1,72 +0,0 @@
//! DAP path fields only: output, expressions, environment and program arguments are opaque.
use ide_core::path_mapping::{Direction, PathMapping, file_path, file_uri};
use serde_json::Value;
#[derive(Clone, Debug)]
pub struct TargetMapping {
pub paths: PathMapping,
pub external_prefix: String,
}
impl TargetMapping {
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
match value {
Value::Array(items) => items.iter_mut().for_each(|v| self.rewrite(v, direction)),
Value::Object(fields) => {
for (key, value) in fields {
if key == "pathMappings" {
continue;
}
if matches!(key.as_str(), "path" | "program" | "cwd" | "localRoot" | "remoteRoot") {
if let Some(path) = value.as_str() {
if matches!(direction, Direction::ToTarget) && path.starts_with(&self.external_prefix) {
if let Some(decoded) =
file_path(&format!("file:///{}", path.trim_start_matches(&self.external_prefix)))
{
*value = decoded.into();
}
} else if let Some(mapped) = self.paths.map(path, direction) {
*value = mapped.into();
} else if matches!(direction, Direction::ToHost) && key == "path" && path.starts_with('/') {
*value = format!(
"{}{}",
self.external_prefix,
file_uri(path).trim_start_matches("file:///")
)
.into();
}
}
} else if !matches!(key.as_str(), "args" | "env" | "output" | "expression" | "value") {
self.rewrite(value, direction);
}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_protocol_paths_without_changing_user_data_or_adapter_mappings() {
let m = TargetMapping {
paths: PathMapping::new("C:/project", "/app"),
external_prefix: "openidea-target://test/0/".into(),
};
let mut request = json!({"arguments":{"source":{"path":"C:/project/a.py"},"program":"C:/project/a.py","cwd":"C:/project", "args":["C:/project/text"],"env":{"path":"C:/project/text"},"pathMappings":[{"localRoot":"/app","remoteRoot":"/app"}]}});
m.rewrite(&mut request, Direction::ToTarget);
assert_eq!(request["arguments"]["source"]["path"], "/app/a.py");
assert_eq!(request["arguments"]["cwd"], "/app");
assert_eq!(request["arguments"]["args"][0], "C:/project/text");
assert_eq!(request["arguments"]["env"]["path"], "C:/project/text");
let mut response = json!({"body":{"stackFrames":[{"source":{"path":"/app/a.py"}},{"source":{"path":"/usr/lib/a.py"}}],"sources":[{"path":"/app/b.py"}],"output":"/app/a.py"}});
m.rewrite(&mut response, Direction::ToHost);
assert_eq!(response["body"]["stackFrames"][0]["source"]["path"], "C:\\project\\a.py");
assert_eq!(response["body"]["sources"][0]["path"], "C:\\project\\b.py");
assert_eq!(response["body"]["stackFrames"][1]["source"]["path"], "openidea-target://test/0/usr/lib/a.py");
assert_eq!(response["body"]["output"], "/app/a.py");
}
}
-140
View File
@@ -14,93 +14,6 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[cfg(windows)]
#[test]
#[ignore = "child process for Windows stop tests"]
fn windows_debuggee_process() {
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
return;
}
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
let mut child = ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
.spawn()
.unwrap();
let _ = child.wait();
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
println!("LISTENING {}", listener.local_addr().unwrap().port());
std::thread::sleep(Duration::from_secs(60));
}
#[cfg(windows)]
#[tokio::test]
async fn windows_stop_releases_the_debuggees_port_before_returning() {
use std::io::BufRead;
use std::sync::atomic::Ordering;
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
impl Drop for Child {
fn drop(&mut self) {
if let Some(tree) = &self.1 {
tree.terminate();
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
for (second_stop, wrapper, supports_terminate) in
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
{
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
.stdout(std::process::Stdio::piped());
let mut child = Child(command.spawn().unwrap(), None);
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
.lines()
.map(Result::unwrap)
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
.unwrap();
let pid = child.0.id();
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while session.stop_tree.lock().is_none() && !stop.is_finished() {
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!stop.is_finished(), "Stop must wait for the live program");
if wrapper {
assert!(session.stop_tree.lock().is_some());
child.0.kill().unwrap();
child.0.wait().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
}
if second_stop {
h.manager.stop(id).await.unwrap();
}
let limit = if second_stop { 10 } else { 25 };
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
}
}
#[derive(Clone)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
@@ -1253,7 +1166,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
target_placement: None,
adapter: "godot".into(),
command: command.into(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -1963,55 +1875,3 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
)));
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_ends_the_adapter_tree_without_a_process_event() {
use tokio::io::AsyncBufReadExt;
let h = harness(Script::default());
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
let mut command = tokio::process::Command::new("python3");
ide_core::process::own_group(command.as_std_mut());
command
.args([
"-u",
"-c",
"import subprocess,sys,time; subprocess.Popen([sys.executable, '-u', '-c', \
\"import socket,time; s=socket.socket(); s.bind(('127.0.0.1',0)); s.listen(); \
print(s.getsockname()[1],flush=True); time.sleep(60)\"]); time.sleep(60)",
])
.stdout(std::process::Stdio::piped());
let mut child = command.spawn().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let port: u16 = lines.next_line().await.unwrap().unwrap().parse().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the descendant holds the port");
let pid = child.id().unwrap();
*session.adapter_process.lock() = Some(child);
session.kill_processes();
let deadline = Instant::now() + Duration::from_secs(3);
while std::net::TcpListener::bind(("127.0.0.1", port)).is_err() {
if Instant::now() >= deadline {
ide_core::process::kill_tree_of(pid);
panic!("adapter cleanup left its descendant holding the port");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
h.manager.stop(id).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn stop_detaches_from_an_attached_program() {
let mut child = std::process::Command::new("sleep").arg("60").spawn().unwrap();
let h = harness(Script { debuggee_pid: Some(child.id()), ..Default::default() });
let id = h.start(LaunchConfig { request: "attach".into(), ..config() }).await.unwrap();
h.manager.stop(id).await.unwrap();
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(alive, "Stop killed the attached program");
assert!(!h.commands().contains(&"terminate".to_owned()));
assert_eq!(h.request("disconnect")["arguments"]["terminateDebuggee"], false);
}
-30
View File
@@ -1,30 +0,0 @@
import sys,json,os,socket
root=os.environ.get("FAKE_ROOT","/app")
r=sys.stdin.buffer; w=sys.stdout.buffer
seq=0
def send(v):
global seq
seq+=1; v['seq']=seq
b=json.dumps(v).encode(); w.write(('Content-Length: %d\r\n\r\n'%len(b)).encode()+b); w.flush()
while True:
line=r.readline()
if not line: break
n=int(line.split(b':')[1]); assert r.readline()==b'\r\n'
q=json.loads(r.read(n)); c=q['command']; a=q.get('arguments',{}); body={}
if c=='initialize': body={'supportsConfigurationDoneRequest':True}
if c=='launch':
if 'program' in a: assert a['program']==root+'/main.py',a
if os.environ.get('FAKE_CONNECT_BACK'):
listener=socket.socket(); listener.bind(('127.0.0.1',a['port'])); listener.listen()
assert a['cwd']==root,a
send({'type':'event','event':'initialized'})
if c=='setBreakpoints':
assert a['source']['path']==root+'/main.py',a
body={'breakpoints':[{'verified':True,'line':3}]}
if c=='threads': body={'threads':[{'id':1,'name':'target main'}]}
if c=='configurationDone': send({'type':'event','event':'stopped','body':{'reason':'breakpoint','threadId':1,'allThreadsStopped':True}})
if c=='stackTrace' and os.environ.get('FAKE_CONNECT_BACK'):
client,_=listener.accept(); assert client.recv(4096).decode()==root+'/main.py'; client.close()
if c=='stackTrace': body={'stackFrames':[{'id':1,'name':'main','line':3,'column':1,'source':{'path':root+'/main.py'}}]}
send({'type':'response','request_seq':q['seq'],'command':c,'success':True,'body':body})
if c=='disconnect': break
+3 -17
View File
@@ -93,23 +93,9 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
.await
.unwrap();
let id = manager
.start_prepared(
listen_config(port, &served, &served),
SessionOwner::new("main", sink.clone()),
|_| None,
|spec, _| {
spec.command = adapter.clone();
spec.cwd = local.clone();
spec.target = Some(ide_dap::target::TargetMapping {
paths: ide_core::path_mapping::PathMapping::new(
&local.to_string_lossy(),
&served.to_string_lossy(),
),
external_prefix: "openidea-target://php/0/".into(),
});
Ok(())
},
)
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
-130
View File
@@ -1,130 +0,0 @@
//! Real stdio transport through a target wrapper, without Docker or installed adapters.
#![cfg(unix)]
use ide_core::{SharedSink, path_mapping::PathMapping};
use ide_dap::{target::TargetMapping, *};
use ide_language::LanguageRegistry;
use std::{collections::BTreeMap, sync::Arc};
const ADAPTER: &str = include_str!("fixtures/target_adapter.py");
#[tokio::test]
async fn stdio_adapter_through_target_maps_launch_breakpoints_and_frames() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let adapter = root.join("adapter.py");
std::fs::write(&adapter, ADAPTER).unwrap();
let wrapper = root.join("wrapper");
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' \"$@\" > wrapper-argv\nexec \"$@\"\n").unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = ide_core::remote_settings::PluginRunTarget {
id: "fake".into(),
name: "Fake".into(),
program: "wrapper".into(),
args: vec![],
workdir_args: vec![],
container_root: Some("/app".into()),
commands: vec![],
};
// Keep the fixture adapter outside mapped argv; this is the container's executable.
let run: ide_run::RunConfig = serde_json::from_value(serde_json::json!({"id":"adapter","name":"Adapter","kind":"shell","command":"/usr/bin/python3","args":["-u","-c",ADAPTER],"cwd":".","env":{},"detected":false,"isTest":false})).unwrap();
let run = ide_remote::plugin_target::prepare(&root, &target, &wrapper, &run).unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Target".into(),
adapter: "debugpy".into(),
request: "launch".into(),
program: Some(root.join("main.py").to_string_lossy().into_owned()),
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: None,
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = run.command;
spec.args = run.args;
spec.cwd = root;
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(
frames[0].path.as_ref().unwrap().to_string_lossy(),
tmp.path().canonicalize().unwrap().join("main.py").to_string_lossy()
);
assert_eq!(frames[0].line, 2);
assert!(std::fs::read_to_string(tmp.path().join("wrapper-argv")).unwrap().starts_with("python3\n-u\n-c\n"));
manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn host_adapter_accepts_connect_back_from_target_with_mapped_sources() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let port = ide_dap::adapter::free_port().unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Connect back".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({"port":port,"pathMappings":{"/app":"/app"}}).to_string()),
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = "/usr/bin/python3".into();
spec.args = vec!["-u".into(), "-c".into(), ADAPTER.into()];
spec.env.push(("FAKE_CONNECT_BACK".into(), "1".into()));
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let code =
format!("import socket;s=socket.create_connection(('127.0.0.1', {port}));s.sendall(b'/app/main.py');s.close()");
let mut child = tokio::process::Command::new("/usr/bin/python3").args(["-c", &code]).spawn().unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
assert!(child.wait().await.unwrap().success());
manager.stop(id).await.unwrap();
}
+1 -1
View File
@@ -92,7 +92,7 @@ pub struct SequenceInfo {
pub data_type: String,
}
/// A schema's routines (procedures and functions) and sequences.
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSchemaObjects")]
-26
View File
@@ -253,32 +253,6 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
}
}
fn check_id(id: &str) -> IdeResult<&str> {
let id = id.trim();
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
}
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
let id = check_id(id)?;
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
Ok(parse_image_history(&out))
}
/// `docker top <id>` of a running container.
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
let id = check_id(id)?;
let out = run(ep, &["top", id], QUICK)?;
Ok(parse_container_top(&out))
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
let id = check_id(id)?;
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
}
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
+19 -181
View File
@@ -41,15 +41,6 @@ pub struct DockerContainer {
pub compose_working_dir: Option<String>,
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
pub compose_files: Vec<String>,
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
#[serde(default)]
pub mounts: Vec<String>,
/// Connected network names reported by `docker ps` (`Networks`).
#[serde(default)]
pub networks: Vec<String>,
/// User and tool labels assigned to the container.
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl DockerContainer {
@@ -89,10 +80,6 @@ pub struct DockerVolume {
pub driver: String,
pub mountpoint: String,
pub compose_project: Option<String>,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -103,46 +90,6 @@ pub struct DockerNetwork {
pub driver: String,
pub scope: String,
pub compose_project: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub ipv6: bool,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerImageLayer {
pub id: String,
pub created_by: String,
pub size: String,
pub created_since: String,
pub created_at: String,
pub comment: String,
}
/// Process table returned by `docker top <container>`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerProcessList {
pub titles: Vec<String>,
pub processes: Vec<Vec<String>>,
}
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainerStats {
pub cpu_perc: String,
pub mem_usage: String,
pub mem_perc: String,
pub net_io: String,
pub block_io: String,
pub pids: String,
}
/// Everything the Services tree shows, fetched in one go.
@@ -239,20 +186,17 @@ struct RawContainer {
ports: String,
created_at: String,
labels: String,
mounts: String,
networks: String,
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
}
/// `docker ps -a --no-trunc --format '{{json .}}'`.
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
.map(|r| {
let all_labels = parse_labels(&r.labels);
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
let mut labels = parse_labels(&r.labels);
let files = labels
.remove(COMPOSE_FILES)
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default();
// Older docker versions derive `State` from nothing: fall back on the status text.
let state = if r.state.is_empty() {
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
@@ -268,13 +212,10 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
status: r.status,
ports: r.ports,
created_at: r.created_at,
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
compose_files: files,
mounts: split_csv(&r.mounts),
networks: split_csv(&r.networks),
labels: all_labels,
}
})
.collect();
@@ -317,23 +258,17 @@ struct RawVolume {
name: String,
driver: String,
mountpoint: String,
scope: String,
labels: String,
}
/// `docker volume ls --format '{{json .}}'`.
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerVolume {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
scope: r.scope,
labels,
}
.map(|r| DockerVolume {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -348,121 +283,24 @@ struct RawNetwork {
name: String,
driver: String,
scope: String,
created_at: String,
#[serde(rename = "IPv6")]
ipv6: String,
internal: String,
labels: String,
}
/// `docker network ls --no-trunc --format '{{json .}}'`.
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerNetwork {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
created_at: r.created_at,
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
internal: r.internal.eq_ignore_ascii_case("true"),
labels,
}
.map(|r| DockerNetwork {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawImageLayer {
#[serde(rename = "ID")]
id: String,
created_by: String,
size: String,
created_since: String,
created_at: String,
comment: String,
}
/// `docker image history --no-trunc --format '{{json .}}'`.
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
lines::<RawImageLayer>(text)
.map(|r| DockerImageLayer {
id: r.id,
created_by: r.created_by,
size: r.size,
created_since: r.created_since,
created_at: r.created_at,
comment: r.comment,
})
.collect()
}
/// `docker top <container>` tabular output.
pub fn parse_container_top(text: &str) -> DockerProcessList {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return DockerProcessList::default();
};
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
if titles.is_empty() {
return DockerProcessList::default();
}
let cols = titles.len();
let processes = lines
.map(|line| {
let mut row = Vec::with_capacity(cols);
let mut rest = line.trim_start();
for _ in 0..cols.saturating_sub(1) {
match rest.split_once(char::is_whitespace) {
Some((field, tail)) => {
row.push(field.to_owned());
rest = tail.trim_start();
}
None => break,
}
}
if !rest.is_empty() || row.len() < cols {
row.push(rest.to_owned());
}
row
})
.collect();
DockerProcessList { titles, processes }
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawStats {
#[serde(rename = "CPUPerc")]
cpu_perc: String,
mem_usage: String,
mem_perc: String,
#[serde(rename = "NetIO")]
net_io: String,
#[serde(rename = "BlockIO")]
block_io: String,
#[serde(rename = "PIDs")]
pids: String,
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
cpu_perc: r.cpu_perc,
mem_usage: r.mem_usage,
mem_perc: r.mem_perc,
net_io: r.net_io,
block_io: r.block_io,
pids: r.pids,
})
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawVersion {
+1 -44
View File
@@ -1,9 +1,6 @@
//! Parsing of recorded `docker … --format '{{json .}}'` output.
use ide_docker::{
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
parse_volumes,
};
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
const PS: &str = include_str!("fixtures/ps.jsonl");
const IMAGES: &str = include_str!("fixtures/images.jsonl");
@@ -25,16 +22,11 @@ fn containers_with_compose_labels() {
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
assert_eq!(db.mounts, ["shop_pgdata"]);
assert_eq!(db.networks, ["shop_default"]);
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
let web = &c[1];
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
assert!(!web.running());
assert_eq!(web.compose_project, None);
assert!(web.mounts.is_empty());
assert_eq!(web.networks, ["bridge"]);
}
#[test]
@@ -52,44 +44,9 @@ fn volumes_and_networks() {
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
assert_eq!(v[1].scope, "local");
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
let n = parse_networks(NETWORKS);
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
assert_eq!(n[1].driver, "host");
assert!(!n[2].ipv6);
assert!(!n[2].internal);
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
}
#[test]
fn image_history_top_and_stats() {
let history = [
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
]
.join("\n");
let layers = parse_image_history(&history);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
assert_eq!(layers[1].size, "42.5MB");
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
let parsed_top = parse_container_top(top);
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
assert_eq!(parsed_top.processes.len(), 1);
assert_eq!(parsed_top.processes[0][0], "root");
assert_eq!(parsed_top.processes[0][1], "1234");
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
let stats = parse_container_stats(stats_line).expect("stats parsed");
assert_eq!(stats.cpu_perc, "1.25%");
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
assert_eq!(stats.mem_perc, "2.50%");
assert_eq!(stats.net_io, "12kB / 8kB");
assert_eq!(stats.block_io, "1.2MB / 0B");
assert_eq!(stats.pids, "14");
}
+1 -1
View File
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.5.0";
pub const ENGINE_VERSION: &str = "1.4.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
+2 -2
View File
@@ -627,8 +627,8 @@ mod tests {
#[test]
fn finds_remote_by_repo() {
let remotes = vec![
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into(), resolved_url: None },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into(), resolved_url: None },
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
];
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
-1
View File
@@ -10,7 +10,6 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
percent-encoding = "2"
encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
-124
View File
@@ -1,37 +1,9 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
/// silent fallback to the OS location.
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
let dir = root.join(identifier);
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
@@ -304,102 +276,6 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
for var in [None, Some(OsString::new())] {
assert_eq!(
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
Path::new("/os-local/ai.l0ng.openidea")
);
}
assert!(resolve_local(None, None, "id").is_err());
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("missing/nested");
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
assert_eq!(dir, root.join("ai.l0ng.openidea"));
assert!(dir.is_dir());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
// Debug worktrees have their own identifiers and stay apart under one root.
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
}
#[test]
fn local_dir_preserves_an_existing_probe_file() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let probe = dir.join(".openidea-write-check");
fs::write(&probe, "keep").unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(probe).unwrap(), "keep");
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn local_dir_preserves_an_existing_probe_symlink_and_its_target() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let target = tmp.path().join("target");
fs::write(&target, "keep").unwrap();
let probe = dir.join(".openidea-write-check");
std::os::unix::fs::symlink(&target, &probe).unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(target).unwrap(), "keep");
assert!(fs::symlink_metadata(probe).unwrap().file_type().is_symlink());
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[test]
fn local_dir_allows_concurrent_probes_for_the_same_identifier() {
let tmp = tempfile::tempdir().unwrap();
let barrier = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
for _ in 0..8 {
let barrier = &barrier;
let root = tmp.path();
scope.spawn(move || {
barrier.wait();
for _ in 0..16 {
assert_eq!(resolve_local(Some(root.into()), None, "id").unwrap(), root.join("id"));
}
});
}
});
assert_eq!(fs::read_dir(tmp.path().join("id")).unwrap().count(), 0);
}
#[test]
fn local_dir_rejects_relative_and_unwritable_paths() {
for relative in ["local", "./local", "../local"] {
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
}
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("file");
fs::write(&blocker, "").unwrap();
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains("cannot write"), "{error}");
}
#[cfg(unix)]
#[test]
fn local_dir_rejects_an_existing_read_only_directory() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
// Root ignores permission bits.
if fs::write(dir.join("probe"), "").is_err() {
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
}
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
+2 -2
View File
@@ -56,7 +56,7 @@ pub struct HostConfig {
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: Arc<RwLock<std::sync::Weak<plugins::PluginHost>>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
@@ -125,7 +125,7 @@ impl Host {
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: Arc::default(),
plugin_host: RwLock::default(),
mcp_executable: RwLock::default(),
mcp_reports: Default::default(),
mcp_requests: Default::default(),
-32
View File
@@ -28,8 +28,6 @@ pub struct Reports(RwLock<State>);
struct State {
windows: HashMap<String, Window>,
focused: Option<String>,
/// Windows in the order they were last focused, the most recent last.
focus_order: Vec<String>,
remote: HashMap<String, PathBuf>,
libraries: HashMap<String, Vec<PathBuf>>,
}
@@ -48,16 +46,8 @@ impl Reports {
state.windows.entry(window.into()).or_default().focused = editor;
if focused {
state.focused = Some(window.into());
state.focus_order.retain(|w| w != window);
state.focus_order.push(window.into());
}
}
/// Sorts `windows` by when they were last focused, the most recent first; windows
/// never focused keep their order after them.
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
let order = &self.0.read().focus_order;
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
}
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
let mut state = self.0.write();
let problems = &mut state.windows.entry(window.into()).or_default().problems;
@@ -78,7 +68,6 @@ impl Reports {
state.windows.remove(window);
state.remote.remove(window);
state.libraries.remove(window);
state.focus_order.retain(|w| w != window);
if state.focused.as_deref() == Some(window) {
state.focused = None;
}
@@ -133,24 +122,3 @@ impl Reports {
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_sort_by_their_last_focus() {
let reports = Reports::default();
for window in ["a", "b", "a", "c"] {
reports.set_focused_editor(window, None, true);
}
reports.set_focused_editor("b", None, false);
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["c", "a", "b", "never"]);
reports.forget_window("c");
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["a", "b", "never", "c"]);
}
}
+1 -2
View File
@@ -552,8 +552,7 @@ pub async fn build_run(
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache =
if member == &root { cache.clone() } else { cache.join(crate::project::project_key(member)) };
let member_cache = cache.join(crate::project::project_key(member));
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = prepare_plan(
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
+4 -35
View File
@@ -12,18 +12,13 @@ pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>>
}
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
let window_root = root;
Ok(match ide_workspace::workspace::Workspace::load(root)? {
Some(workspace) => workspace
.projects
.into_iter()
.filter(|p| p.is_dir())
.map(|root| Scope {
cache: if root == window_root {
cache.to_owned()
} else {
cache.join(crate::project::project_key(&root))
},
cache: cache.join(crate::project::project_key(&root)),
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
root,
})
@@ -41,12 +36,10 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
let mut combined = BuildModel::default();
for scope in scopes {
let mut model = load(&scope.root, &scope.cache, &scope.settings);
if scopes.len() == 1 && scope.root == root {
if scope.root == root {
return model;
}
if scope.root != root
&& let Some(maven) = &mut model.maven
{
if let Some(maven) = &mut model.maven {
let owner = ide_core::ipc_path::to_ipc(&scope.root);
for p in &mut maven.projects {
p.project_root = Some(owner.clone());
@@ -80,7 +73,7 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
}
}
}
for g in model.gradle.iter_mut().filter(|_| scope.root != root) {
for g in &mut model.gradle {
g.dir = absolute(&scope.root, &g.dir);
for p in &mut g.projects {
p.dir = absolute(&scope.root, &p.dir);
@@ -152,30 +145,6 @@ pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, to
mod tests {
use super::*;
#[test]
fn attached_primary_keeps_its_cache_and_combines_build_models() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("primary");
let other = base.join("other");
for path in [&root, &other] {
std::fs::create_dir(path).unwrap();
std::fs::write(path.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
}
let cache = base.join("cache");
ide_build::save_maven_profiles(&cache, &["kept".into()]).unwrap();
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
let scopes = scopes_at(&root, &cache, &base.join("config"), Settings::default()).unwrap();
assert_eq!(scopes[0].cache, cache);
assert_eq!(ide_build::maven_profiles(&scopes[0].cache), ["kept"]);
assert_eq!(scopes[1].cache, cache.join(crate::project::project_key(&other)));
let model = load_scopes(&root, &scopes);
let projects = model.maven.unwrap().projects;
assert_eq!(projects.len(), 2);
assert_eq!(projects[0].pom, "pom.xml");
assert_eq!(projects[1].pom, absolute(&other, "pom.xml"));
}
#[test]
fn workspace_models_use_member_settings_paths_and_caches() {
let temp = tempfile::tempdir().unwrap();
+1 -48
View File
@@ -1,5 +1,4 @@
mod output;
mod target;
use crate::Host;
use crate::rpc::Dispatcher;
@@ -9,42 +8,7 @@ use std::path::PathBuf;
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
debug_start_request(host, window, config, None).await
}
pub async fn debug_start_request(
host: &Host,
window: &str,
config: LaunchConfig,
request: Option<ide_core::RequestId>,
) -> IdeResult<DebugSessionId> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let result = debug_start_inner(host, window, config, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
result
}
async fn debug_start_inner(
host: &Host,
window: &str,
config: LaunchConfig,
cancel: &ide_core::CancellationToken,
) -> IdeResult<DebugSessionId> {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
host.require_trust(window)?;
let target_id = config
.extra_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("__runTarget").and_then(|v| v.as_str()).map(str::to_owned));
if let Some(id) = target_id {
let owner = SessionOwner::new(host.owner_window(window), host.window_sink(window));
return target::start(host, window, config, owner, id, cancel).await;
}
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
@@ -387,14 +351,6 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
}
/// A loopback port that is free right now, for a debug agent the launch line starts
/// (`address=127.0.0.1:<port>`) — used when its "Listening for transport" line never reaches
/// the console (a Surefire fork holds it until the tests run, which `suspend=y` prevents).
pub async fn debug_free_port(host: &Host, window: &str) -> IdeResult<u16> {
host.require_trust(window)?;
ide_dap::adapter::free_port().map_err(ide_core::IdeError::from)
}
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
pub async fn debug_listen_for_jvm(
@@ -418,9 +374,7 @@ pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSe
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("debug_start", |c| async move {
debug_start_request(&c.host, &c.window, c.arg("config")?, c.arg("request")?).await
});
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
d.add("debug_set_breakpoints", |c| async move {
debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await
});
@@ -538,7 +492,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
d.add("debug_free_port", |c| async move { debug_free_port(&c.host, &c.window).await });
d.add("debug_listen_for_jvm", |c| async move {
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
});
-121
View File
@@ -1,121 +0,0 @@
use crate::Host;
use ide_core::path_mapping::PathMapping;
use ide_core::{IdeError, IdeResult};
use ide_dap::target::TargetMapping;
use ide_dap::{LaunchConfig, SessionOwner};
use ide_language::ProgramPin;
use serde_json::json;
pub(super) async fn start(
host: &Host,
window: &str,
mut config: LaunchConfig,
owner: SessionOwner,
id: String,
cancel: &ide_core::CancellationToken,
) -> IdeResult<ide_core::DebugSessionId> {
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let registration = plugins
.run_targets(&project.root)
.into_iter()
.find(|t| t.id == id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let container = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("Debugging a plugin target requires a container root"))?;
let dbg = ide_dap::adapter::find_config(&host.languages, &config.adapter)
.ok_or_else(|| IdeError::invalid("unknown debug adapter"))?;
let placement = dbg.target_placement.as_deref().unwrap_or("");
if !matches!(placement, "connect-back" | "adapter-in-target")
|| (placement == "adapter-in-target" && dbg.transport != "stdio")
{
return Err(IdeError::invalid("This debugger does not support plugin run targets"));
}
let program = config
.extra_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("__runTargetProgram").and_then(|p| p.as_str()).map(str::to_owned))
.or_else(|| config.program.clone())
.unwrap_or_else(|| dbg.command.clone());
let metadata = serde_json::from_value(json!({"id":"debug", "name":config.name, "kind":"shell", "command":program, "args":[], "cwd":config.cwd, "env":{}, "detected":false, "isTest":false}))
.map_err(|e| IdeError::invalid(e.to_string()))?;
let (validator, root, target, token) = (plugins.clone(), project.root.clone(), id.clone(), cancel.clone());
host.blocking(move || {
validator.prepare_run_target_launch(&root, &target, &metadata, ide_plugin::RunTargetOperation::Debug, &token)
})
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mapping = TargetMapping {
paths: PathMapping::new(&project.root.to_string_lossy(), &container),
external_prefix: format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC)
),
};
let mut extra: serde_json::Value = config
.extra_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| IdeError::invalid(e.to_string()))?
.unwrap_or(json!({}));
extra.as_object_mut().ok_or_else(|| IdeError::invalid("invalid debug launch arguments"))?.remove("__runTarget");
extra.as_object_mut().unwrap().remove("__runTargetProgram");
// DAP paths already use target coordinates; adapter-native mappings stay identity.
if placement == "connect-back" {
config.program = None;
config.args.clear();
for key in ["program", "runtimeExecutable", "runtimeArgs"] {
extra.as_object_mut().unwrap().remove(key);
}
if extra.get("port").and_then(|v| v.as_u64()).unwrap_or(0) == 0 {
extra["port"] = json!(9003);
}
extra["hostname"] = json!("0.0.0.0");
extra["pathMappings"] = json!({container.clone(): container.clone()});
} else {
for key in ["debugLauncherPython", "pythonPath"] {
extra.as_object_mut().unwrap().remove(key);
}
extra["console"] = json!("internalConsole");
extra["python"] = json!(dbg.command);
extra["pathMappings"] = json!([{"localRoot":container,"remoteRoot":container}]);
}
config.extra_json = Some(extra.to_string());
let wrapped = if placement == "adapter-in-target" {
let run = serde_json::from_value(json!({"id":"debug-adapter","name":"Debug adapter","kind":"shell","command":dbg.command,"args":dbg.args,"cwd":config.cwd,"env":config.env,"detected":false,"isTest":false})).map_err(|e| IdeError::invalid(e.to_string()))?;
let root = project.root.clone();
Some(host.blocking(move || plugins.prepare_run_target(&root, &id, &run)).await?)
} else {
None
};
host.require_trust(window)?;
let root = project.root.clone();
let settings = project.settings.effective();
host.debugger
.start_prepared(
config,
owner,
|_| None,
move |spec, _| {
if let Some(run) = wrapped {
spec.command = run.command;
spec.args = run.args;
spec.cwd = run.cwd.map(Into::into).unwrap_or(root);
spec.env = run.env.into_iter().collect();
spec.pinned = Some(ProgramPin::approve(&spec.command)?);
} else if let Some(command) = ide_sdk::apply::tool_command(&root, &settings, &spec.command) {
spec.command = command.to_string_lossy().into_owned();
}
spec.target = Some(mapping);
Ok(())
},
)
.await
}
+1 -1
View File
@@ -82,7 +82,7 @@ fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeRes
workspace
.projects
.into_iter()
.filter(|root| **root != project.root && path.starts_with(root))
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
});
let settings = match &member {
+23 -233
View File
@@ -4,39 +4,11 @@ use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::error::ErrorKind;
use ide_core::{DocId, IdeError, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
use std::path::Path;
/// Select an embedded server only for point requests; formatting and rename keep the host.
async fn position_document(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<DocId> {
let lsp = host.project(window)?.lsp.clone();
lsp.refresh_injections(doc).await?;
Ok(lsp.injected_document(doc, pos))
}
async fn begin_position_request(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
) -> IdeResult<(DocId, ide_core::CancellationToken)> {
let cancel = host.begin_request(request);
let target = tokio::select! {
result = position_document(host, window, doc, pos) => result,
_ = cancel.cancelled() => Err(ide_core::IdeError::cancelled()),
};
match target {
Ok(target) => Ok((target, cancel)),
Err(e) => {
host.end_request(request);
Err(e)
}
}
}
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
doc: DocId,
@@ -69,8 +41,8 @@ pub async fn lsp_completion(
trigger: Option<String>,
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = lsp.completion(target, pos, trigger.clone(), cancel).await;
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
@@ -197,7 +169,6 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
CompletionItem {
label: item.label,
label_detail: None,
label_description: None,
detail: item.detail,
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
@@ -267,14 +238,14 @@ pub async fn lsp_hover(
pos: Pos,
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let cancel = host.begin_request(request);
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(target, pos, cancel).await;
let result = lsp.hover(doc, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(target, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
host.end_request(request);
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
@@ -289,8 +260,7 @@ pub async fn lsp_hover(
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.signature_help(target, pos).await;
let server = host.project(window)?.lsp.signature_help(doc, pos).await;
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
return server;
}
@@ -308,9 +278,7 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
if target == doc
&& kind == GotoKind::Definition
if kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
@@ -321,7 +289,7 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
return Ok(found.targets);
}
}
let result = host.project(window)?.lsp.goto(target, pos, kind).await;
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
let k =
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
@@ -456,46 +424,8 @@ pub async fn lsp_implementations(
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
let lsp = host.project(window)?.lsp.clone();
let (_, cancel) =
begin_position_request(host, window, request, doc, positions.first().copied().unwrap_or_default()).await?;
let mut groups = std::collections::BTreeMap::<DocId, Vec<(usize, Pos)>>::new();
for (index, pos) in positions.iter().copied().enumerate() {
groups.entry(lsp.injected_document(doc, pos)).or_default().push((index, pos));
}
if groups.is_empty() {
groups.insert(doc, Vec::new());
}
let results = futures::future::join_all(groups.into_iter().map(|(target, entries)| {
let (lsp, cancel) = (lsp.clone(), cancel.clone());
async move {
let positions: Vec<_> = entries.iter().map(|(_, p)| *p).collect();
(entries, lsp.implementations(target, &positions, cancel).await)
}
}))
.await;
let mut out = vec![Vec::new(); positions.len()];
let mut error = None;
let mut succeeded = false;
for (entries, result) in results {
match result {
Ok(found) => {
succeeded = true;
for ((index, _), locations) in entries.into_iter().zip(found) {
out[index] = locations;
}
}
Err(e) => {
error.get_or_insert(e);
}
}
}
let result = if cancel.is_cancelled() {
Err(ide_core::IdeError::cancelled())
} else if succeeded {
Ok(out)
} else {
Err(error.expect("at least one request"))
};
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let count = positions.len();
@@ -526,9 +456,9 @@ pub async fn lsp_goto_implementation(
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let cancel = host.begin_request(request);
let result = lsp
.goto_implementation(target, pos, cancel, &|locs| {
.goto_implementation(doc, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
@@ -546,121 +476,9 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
if uri.starts_with("openidea-target:") && uri.contains("/0/") {
host.require_trust(window)?;
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
for registration in plugins.run_targets(&project.root) {
let prefix = format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&registration.id, percent_encoding::NON_ALPHANUMERIC)
);
if let Some(suffix) = uri.strip_prefix(&prefix) {
let path = ide_core::path_mapping::file_path(&format!("file:///{suffix}"))
.filter(|p| p.starts_with('/') && !p.split('/').any(|s| s == ".." || s == "."))
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let container_root = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let target = ide_language::ServerTarget {
id: registration.id,
origin: None,
project_root: project.root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&project.root.to_string_lossy(), &container_root),
container_root,
};
let title = std::path::Path::new(&path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let language_id = host.languages.detect(std::path::Path::new(&path), None);
let text = host.blocking(move || super::lsp_target::read(&plugins, &target, &path)).await?;
return Ok(VirtualDocument { uri, text, title, language_id, description: None, decompiled: false });
}
}
return Err(IdeError::unavailable("plugin run target is no longer available"));
}
host.project(window)?.lsp.virtual_document(&uri).await
}
/// Whether this class belongs to the project's Maven local repository.
pub async fn lsp_can_download_sources(host: &Host, window: &str, uri: String) -> IdeResult<bool> {
let project = host.project(window)?;
if !project.is_trusted() {
return Ok(false);
}
let Some(jar) = ide_lsp::LspManager::library_archive(&uri) else { return Ok(false) };
host.blocking(move || {
let settings = project.settings.effective();
let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
Ok(repository.is_some_and(|repo| ide_build::maven::sources::artifact(&jar, &repo).is_some())
&& ide_build::maven::has_projects(&project.root, &settings.languages.java.maven))
})
.await
}
/// Download with the project's Maven configuration, or attach an archive selected by the user.
pub async fn lsp_library_sources(
host: &Host,
window: &str,
request: RequestId,
uri: String,
pos: Pos,
sources: Option<std::path::PathBuf>,
) -> IdeResult<Option<Location>> {
host.require_trust(window)?;
let project = host.project(window)?;
let cancel = host.begin_project_request(window, request);
let result = async {
let sources = match sources {
Some(sources) => Some(sources),
None => {
let jar = ide_lsp::LspManager::library_archive(&uri)
.ok_or_else(|| IdeError::invalid("not a library class"))?;
let project = project.clone();
let cancel = cancel.clone();
host.blocking(move || {
let settings = project.settings.effective();
let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo)
.ok_or_else(|| IdeError::unavailable("Maven local repository is unavailable"))?;
let artifact = ide_build::maven::sources::artifact(&jar, &repository)
.ok_or_else(|| IdeError::invalid("not an artifact in the Maven local repository"))?;
let model = super::build::load(&project.root, &project.build_cache_dir(), &settings);
let maven = model.maven.ok_or_else(|| IdeError::unavailable("no Maven project"))?;
let owns = |p: &&ide_build::maven::MavenProject| {
p.dependencies.iter().any(|d| d.group_id == artifact.group && d.artifact_id == artifact.name)
};
let mut owner = maven
.projects
.iter()
.find(owns)
.or_else(|| maven.projects.first())
.cloned()
.ok_or_else(|| IdeError::unavailable("no Maven project"))?;
let profiles =
settings.languages.java.maven.profiles(&ide_build::maven_profiles(&project.build_cache_dir()));
if !profiles.is_empty() {
owner.options.push(format!("-P{}", profiles.join(",")));
}
ide_build::maven::sources::download(&project.root, &owner, &artifact, &repository, &cancel)
})
.await?
}
};
match sources {
Some(sources) => project.lsp.library_source_location(&uri, sources, pos, &cancel).await,
None => Ok(None),
}
}
.await;
host.end_request(request);
result
}
/// Opens compiled classes through JDT, with project source taking priority on disk.
pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult<String> {
let archive = ide_vfs::split_archive_path(&path);
@@ -669,8 +487,8 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Java: without `libraries`, the dependencies and the JDK are not
/// searched; with it and `project_wide`, those found in the sources arrive on `on_partial` before
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
/// are not searched; with it, those found in the sources arrive on `on_partial` before
/// the slower search for the rest, and the result has them all. Cancel with
/// `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
@@ -687,9 +505,9 @@ pub async fn lsp_references(
) -> IdeResult<References> {
let project = host.project(window)?;
let lsp = project.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let cancel = host.begin_request(request);
let result = async {
let symbol = if target == doc { indexed_symbol(host, window, doc, pos).await? } else { None };
let symbol = indexed_symbol(host, window, doc, pos).await?;
if let Some(symbol) = &symbol {
let project = project.clone();
let snapshot = symbol.clone();
@@ -699,7 +517,7 @@ pub async fn lsp_references(
}
let result = lsp
.references_streaming(
target,
doc,
pos,
include_declaration,
project_wide,
@@ -825,8 +643,7 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
}
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.document_highlights(target, pos).await;
let server = host.project(window)?.lsp.document_highlights(doc, pos).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
return server;
};
@@ -857,9 +674,7 @@ pub async fn lsp_code_actions(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let target = position_document(host, window, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_actions(target, range, only.clone()).await;
let result = project.lsp.code_actions(doc, range, only.clone()).await;
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
None => Vec::new(),
@@ -899,8 +714,6 @@ fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> C
.and_then(|r| serde_json::to_value(r).ok());
CodeAction {
title: a.title,
diagnostics: Vec::new(),
original_diagnostics: None,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason,
@@ -998,9 +811,8 @@ pub async fn lsp_code_action_query(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let (target, cancel) = begin_position_request(host, window, request, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_action_query(target, range, only, cancel).await;
let cancel = host.begin_request(request);
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
host.end_request(request);
let mut result = result?;
result.actions = prepare_actions(project, doc, version, result.actions).await?;
@@ -1621,9 +1433,7 @@ pub struct PluginLanguageVisuals {
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
}
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let generation = project.lsp.injection_generation();
let snap = host.project(window)?.documents.snapshot(doc)?;
blocking(move || {
let d = ProviderDocument {
path: snap.path.0,
@@ -1640,18 +1450,6 @@ pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> I
out.colors = plugins.language_colors(&d);
out.gutter_actions = plugins.language_gutter_actions(&d);
}
project.lsp.set_injections_at(
doc,
d.version as u32,
out.injections
.iter()
.map(|i| ide_lsp::InjectedRange {
range: i.range,
language: ide_core::LanguageId::new(i.language.clone()),
})
.collect(),
generation,
);
Ok(out)
})
.await
@@ -1725,14 +1523,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
);
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add(
"lsp_can_download_sources",
|c| async move { lsp_can_download_sources(&c.host, &c.window, c.arg("uri")?).await },
);
d.add("lsp_library_sources", |c| async move {
lsp_library_sources(&c.host, &c.window, c.arg("request")?, c.arg("uri")?, c.arg("pos")?, c.arg("sources")?)
.await
});
d.add("lsp_references", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_references(
-120
View File
@@ -1,120 +0,0 @@
//! Project language-server targets, shared by desktop and openidea-server.
use crate::plugins::PluginHost;
use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageServerConfig, ServerTarget};
use std::path::Path;
pub(crate) fn configure(
plugins: &PluginHost,
root: &Path,
id: &str,
cfg: &mut LanguageServerConfig,
) -> IdeResult<bool> {
let Some(registration) = plugins.run_targets(root).into_iter().find(|t| t.id == id) else { return Ok(false) };
let container_root = registration.target.container_root.clone().unwrap_or_default();
cfg.target = Some(ServerTarget {
id: id.into(),
origin: None,
project_root: root.into(),
container_root: container_root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), &container_root),
});
if container_root.is_empty() {
return Err(IdeError::invalid("language server targets require a container root"));
}
let run = command(&cfg.command, cfg.args.clone());
let wrapped = plugins.prepare_run_target(root, id, &run)?;
cfg.command = wrapped.command;
cfg.args = wrapped.args;
cfg.connect = None;
cfg.pinned = Some(ide_language::ProgramPin::approve(&cfg.command)?);
Ok(true)
}
fn command(command: &str, args: Vec<String>) -> ide_run::RunConfig {
serde_json::from_value(serde_json::json!({
"id":"language-server", "name":"Language server", "kind":"shell",
"command":command, "args":args, "cwd":".", "env":{}, "detected":false, "isTest":false
}))
.expect("valid internal run configuration")
}
pub(crate) fn read(plugins: &PluginHost, target: &ServerTarget, path: &str) -> IdeResult<String> {
let registration = plugins
.run_targets(&target.project_root)
.into_iter()
.find(|t| t.id == target.id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
if registration.target.container_root.as_deref() != Some(&target.container_root) {
return Err(IdeError::unavailable("plugin run target changed; navigate again"));
}
let mut wrapped = plugins.prepare_run_target(&target.project_root, &target.id, &command("cat", Vec::new()))?;
wrapped.args.extend(["--".into(), path.into()]);
capture(&wrapped, &target.project_root)
}
/// Bounds both output and time, and always kills/reaps the wrapper on failure.
fn capture(run: &ide_run::RunConfig, root: &Path) -> IdeResult<String> {
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
const LIMIT: u64 = 4 * 1024 * 1024;
let mut cmd = Command::new(&run.command);
ide_core::process::prepare(&mut cmd);
ide_core::process::own_group(&mut cmd);
let mut child = cmd
.args(&run.args)
.current_dir(root)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let overflow = Arc::new(AtomicBool::new(false));
let large = overflow.clone();
let output = std::thread::spawn(move || {
let mut bytes = Vec::new();
let result = stdout.take(LIMIT + 1).read_to_end(&mut bytes);
large.store(bytes.len() as u64 > LIMIT, Ordering::Release);
result.map(|_| bytes)
});
let errors = std::thread::spawn(move || {
let mut stderr = stderr;
let mut bytes = Vec::new();
let _ = (&mut stderr).take(65536).read_to_end(&mut bytes);
let _ = std::io::copy(&mut stderr, &mut std::io::sink());
bytes
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let status = loop {
if output.is_finished()
&& errors.is_finished()
&& let Ok(Some(status)) = child.try_wait()
{
break Ok(status);
}
match (std::time::Instant::now() < deadline, overflow.load(Ordering::Acquire)) {
(true, false) => std::thread::sleep(std::time::Duration::from_millis(10)),
_ => {
ide_core::process::kill_tree_of(child.id());
let _ = child.kill();
let _ = child.wait();
break Err(IdeError::unavailable("container document exceeds the size or time limit"));
}
}
};
let bytes = output.join().map_err(|_| IdeError::unavailable("container document reader failed"))??;
let stderr = errors.join().unwrap_or_default();
if !status?.success() {
return Err(IdeError::unavailable(format!("container document: {}", String::from_utf8_lossy(&stderr))));
}
if bytes.len() as u64 > LIMIT {
return Err(IdeError::invalid("container document exceeds 4 MiB"));
}
String::from_utf8(bytes).map_err(|_| IdeError::invalid("container document is not UTF-8 text"))
}
-2
View File
@@ -98,5 +98,3 @@ impl Host {
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
}
pub(crate) mod lsp_target;
-6
View File
@@ -41,11 +41,6 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub async fn find_files_named(host: &Host, window: &str, name: String) -> IdeResult<Vec<IpcPath>> {
let project = host.project(window)?;
blocking(move || Ok(project.index.files_named(&name).into_iter().map(Into::into).collect())).await
}
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
}
@@ -172,7 +167,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_files_named", |c| async move { find_files_named(&c.host, &c.window, c.arg("name")?).await });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
+21 -62
View File
@@ -13,8 +13,6 @@ pub struct ProjectInfo {
pub trusted: bool,
pub root: ide_core::IpcPath,
pub name: String,
#[serde(default)]
pub display_name: String,
pub is_git: bool,
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -26,23 +24,17 @@ pub struct ProjectInfo {
pub fn info(project: &Project) -> ProjectInfo {
let settings = project.settings.effective();
let custom = settings.general.project_name.trim();
let primary = if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
};
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
ProjectInfo {
trusted: project.is_trusted(),
display_name: workspace
.as_ref()
.map(|w| w.display_name(&project.root, &primary))
.unwrap_or_else(|| primary.clone()),
name: workspace.as_ref().filter(|w| w.is_container(&project.root)).map(|w| w.name.clone()).unwrap_or(primary),
name: if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
},
is_git: !project.repos.read().is_empty(),
settings,
root: project.root.clone().into(),
workspace,
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
unity: ide_workspace::unity::detect(&project.root),
}
}
@@ -71,71 +63,38 @@ pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
let project = host.project(window)?;
let previous = ide_workspace::workspace::Workspace::load(&project.root)?
.ok_or_else(|| ide_core::IdeError::invalid("This folder is not a workspace"))?;
for path in paths.iter().filter(|p| !previous.projects.contains(p)) {
require_attachment_trust(host, path)?;
}
if !previous.is_container(&project.root) && paths.first() != Some(&project.root) {
return Err(ide_core::IdeError::invalid("The primary project cannot be detached"));
}
if !previous.is_container(&project.root) && paths == [project.root.clone()] {
std::fs::remove_file(project.root.join(ide_workspace::workspace::FILE))?;
} else {
let workspace = ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
keep_attachment_private(&project.root, &workspace)?;
}
refresh_workspace(&project);
Ok(info(&project))
}
fn require_attachment_trust(host: &Host, path: &Path) -> IdeResult<()> {
if !host.trust.trusted(path)? {
return Err(ide_core::IdeError::invalid("Trust the project before attaching it"));
}
Ok(())
}
fn keep_attachment_private(root: &Path, workspace: &ide_workspace::workspace::Workspace) -> IdeResult<()> {
if !workspace.is_container(root) {
ide_vcs::changelists::ensure_ignored(&root.join(".openidea"), &["/workspace.json"])?;
}
Ok(())
}
fn refresh_workspace(project: &Project) {
ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
project.settings.reload();
project.refresh_index_scope();
project.refresh_repositories();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
Ok(info(&project))
}
/// Attaches a trusted folder without replacing the current project.
/// Links a folder, or prepares an automatic workspace for the caller to open in place.
pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult<ide_core::IpcPath> {
let project = host.project(window)?;
let path = dunce::canonicalize(path)?;
if project.light {
return Err(ide_core::IdeError::invalid("Open a project before attaching a folder"));
}
if path == project.root {
return Ok(project.root.clone().into());
}
require_attachment_trust(host, &path)?;
if let Some(mut workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
if !workspace.projects.contains(&path) {
workspace.projects.push(path);
save_workspace(host, window, &workspace.name, workspace.projects)?;
}
} else {
let workspace = ide_workspace::workspace::Workspace::attach(
&project.root,
&info(&project).name,
vec![project.root.clone(), path],
)?;
keep_attachment_private(&project.root, &workspace)?;
refresh_workspace(&project);
return Ok(project.root.clone().into());
}
Ok(project.root.clone().into())
if path == project.root {
return Ok(project.root.clone().into());
}
Ok(ide_workspace::workspace::Workspace::create_for_project(
&host.data_dir,
&project.root,
&info(&project).name,
&path,
)?
.into())
}
/// What [`set_settings`] changed; the caller tells the windows.
@@ -222,7 +181,7 @@ pub fn set_settings(
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let toolchain = ["languages", "tools", "languageServers", "languageServerTargets", "projectStructure"]
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.
+12 -47
View File
@@ -24,7 +24,7 @@ pub(super) fn project_configs(
let root_suppressed = ide_run::load_suppressed_config_ids(root)?;
let mut all = detected_and_saved(root, settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? {
for member in workspace.projects.into_iter().filter(|p| p != root && p.is_dir()) {
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective();
let saved = ide_run::load_user_configs(&member)?;
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
@@ -145,21 +145,20 @@ fn settings_for(
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
let owner = ide_run::scope::owner(&project.root, config);
let member = workspace.as_ref().and_then(|workspace| {
workspace
.projects
.iter()
.filter(|root| **root != project.root && owner.starts_with(root))
.max_by_key(|root| root.components().count())
.cloned()
});
if workspace.as_ref().is_some_and(|w| w.is_container(&project.root)) || member.is_some() {
if workspace.is_some() {
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
}
ide_run::scope::rebase(config, &project.root, None);
}
let member = workspace.and_then(|workspace| {
let owner = ide_run::scope::owner(&project.root, config);
workspace
.projects
.into_iter()
.filter(|root| owner.starts_with(root))
.max_by_key(|root| root.components().count())
});
Ok(match member {
Some(root) => {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
@@ -203,22 +202,8 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
start_request(host, window, config, on_output, None).await
}
pub async fn start_request(
host: &Host,
window: &str,
config: RunConfig,
on_output: Emit<OutputChunk>,
request: Option<ide_core::RequestId>,
) -> IdeResult<RunInfo> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let operation = config.extras.params.get("plugin.operation").cloned();
let result = start_inner(host, window, config, on_output, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
let result = start_inner(host, window, config, on_output).await;
if let (Ok(info), Some(operation), Some(plugins)) = (&result, operation, host.plugin_host()) {
plugins.run_operation_started(window, &operation, info.id);
}
@@ -230,11 +215,7 @@ async fn start_inner(
window: &str,
mut config: RunConfig,
on_output: Emit<OutputChunk>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<RunInfo> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
host.require_trust(window)?;
let project = host.project(window)?;
if config.extras.target.is_none() {
@@ -277,23 +258,7 @@ async fn start_inner(
host.plugin_host().ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let root = project.root.clone();
let id = id.to_owned();
let token = cancel.clone();
let config = host
.blocking(move || {
let operation =
if config.extras.params.get("plugin.targetOperation").is_some_and(|op| op == "debug") {
ide_plugin::RunTargetOperation::Debug
} else if config.is_test {
ide_plugin::RunTargetOperation::Test
} else {
ide_plugin::RunTargetOperation::Run
};
plugins.prepare_run_target_launch(&root, &id, &config, operation, &token)
})
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let config = host.blocking(move || plugins.prepare_run_target(&root, &id, &config)).await?;
project.require_trust()?;
return host.processes.start(
&project.root,
+6 -7
View File
@@ -218,13 +218,12 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
.filter(|s| !s.is_empty())
.or_else(|| artifacts.first().map(|a| &a.id))
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
// "Build before launch" is skipped when the built WAR is already newer than the
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
return Ok(None);
}
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
let mut plan = ide_build::web::build(
&project.root,
&model,
id,
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
id: format!("tomcat-build:{}", config.id),
+1 -1
View File
@@ -61,7 +61,7 @@ pub(crate) fn search_project(
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.is_container(root));
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some();
let include_root = !workspace
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
let content_roots = match query.scope {
-2
View File
@@ -24,10 +24,8 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
.collect();
// Godot is usually an app bundle / Flatpak outside the search path.
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
let runtime = ide_sdk::apply::tool_install_environment(&root, &settings);
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
Ok(Tools::new(&data)
.with_runtime(runtime.commands, runtime.env)
.with_custom_paths(custom)
.with_found_paths(found.into_iter().collect())
.with_work_dir(work_dir))
+3 -15
View File
@@ -673,11 +673,10 @@ pub async fn vcs_rename_branch(
repo: Option<PathBuf>,
from: String,
to: String,
unset_upstream: bool,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to, unset_upstream))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
}
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
@@ -996,17 +995,7 @@ pub async fn vcs_rebase_with(
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || {
let mut remotes = project.with_log_repo_at(repo.as_deref(), |r| r.remotes())?;
for r in &mut remotes {
let resolved = ide_ssh::resolve_ssh_remote_url(&r.url);
if resolved != r.url {
r.resolved_url = Some(resolved);
}
}
Ok(remotes)
})
.await
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -1390,8 +1379,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
d.add("vcs_rebase", |c| async move { vcs_rebase(&c.host, &c.window, c.arg("repo")?, c.arg("onto")?).await });
+6 -40
View File
@@ -110,10 +110,6 @@ pub trait PluginWorkspace: Send + Sync {
fn trusted(&self, root: &Path) -> bool;
/// Root of the project open in `window`.
fn project_of(&self, window: &str) -> Option<PathBuf>;
/// Windows showing the open project at `root`, the most recently focused first.
fn project_windows(&self, _root: &Path) -> Vec<String> {
Vec::new()
}
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
@@ -1762,31 +1758,17 @@ impl HostApi for PluginApi {
body: String,
actions: Vec<ide_plugin::NotificationAction>,
) -> Result<(), HostError> {
check_actions(self, &actions)?;
if !actions.is_empty() {
let commands = self.shared.ui.items("commands", Some(self.id()));
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
}
}
let plugin_id = self.id().to_owned();
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn notify_project(
&self,
root: &Path,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<ide_plugin::NotificationAction>,
) -> Result<(), HostError> {
check_actions(self, &actions)?;
let windows = self.shared.workspace().map(|w| w.project_windows(root)).unwrap_or_default();
let window = windows
.into_iter()
.find(|w| !self.shared.client_closed(w))
.ok_or_else(|| HostError::NotFound(format!("the project {} is not open", root.display())))?;
let plugin_id = self.id().to_owned();
self.shared.sink.emit(Some(&window), PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let client = from_window(client, "copy to the clipboard")?;
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
@@ -2736,15 +2718,6 @@ fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError {
}
}
/// Notification buttons must name commands the plugin declares.
fn check_actions(api: &PluginApi, actions: &[ide_plugin::NotificationAction]) -> Result<(), HostError> {
let commands = if actions.is_empty() { Vec::new() } else { api.shared.ui.items("commands", Some(api.id())) };
match actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
Some(a) => Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command))),
None => Ok(()),
}
}
/// [`PluginWorkspace`] over the desktop host's open projects.
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
@@ -3043,13 +3016,6 @@ impl PluginWorkspace for HostWorkspace {
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
}
fn project_windows(&self, root: &Path) -> Vec<String> {
let Some(host) = self.0.upgrade() else { return Vec::new() };
let mut windows = host.windows_for_root(root);
host.mcp_reports.most_recently_focused_first(&mut windows);
windows
}
fn publish_diagnostics(
&self,
source: &str,
+3 -4
View File
@@ -301,7 +301,7 @@ impl PluginHost {
Ok(Some((root, owner.to_owned(), id.to_owned())))
}
/// Derives replacement argv for a user action, under the command budget.
/// Derives replacement argv under the parser's soft/hard call budgets.
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
if tests.is_empty()
|| tests.len() > 10000
@@ -317,10 +317,9 @@ impl PluginHost {
self.changed();
}
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
// A user action, not the edit path: allow the command budget (a cold instance may be compiling).
let timeout = self.limits.timeout;
let hard = self.limits.edit_hard;
let started = Instant::now();
let result = actor.call(Some(timeout), move |rt| rt.select_tests(id, tests, invocation, timeout));
let result = actor.call(Some(self.limits.edit_soft), move |rt| rt.select_tests(id, tests, invocation, hard));
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
}
+1 -33
View File
@@ -1,4 +1,4 @@
//! Embedded documents preserve the host's UTF-16 coordinates.
//! Embedded plugin-provider documents preserve the host's UTF-16 coordinates.
use super::{PluginHost, ProviderKind};
use ide_core::{Pos, Range};
use ide_plugin::{ProviderDocument, language_features::Injection};
@@ -104,38 +104,6 @@ pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], languag
text
}
/// Shared by local and remote projects; all plugin work runs on the host.
static LSP_PROJECTS: parking_lot::Mutex<Vec<std::sync::Weak<ide_lsp::LspManager>>> =
parking_lot::Mutex::new(Vec::new());
pub(super) fn providers_changed() {
let projects = {
let mut projects = LSP_PROJECTS.lock();
projects.retain(|p| p.strong_count() > 0);
projects.iter().filter_map(std::sync::Weak::upgrade).collect::<Vec<_>>()
};
for project in projects {
project.injection_providers_changed();
}
}
pub(crate) fn configure_lsp(lsp: &Arc<ide_lsp::LspManager>) {
LSP_PROJECTS.lock().push(Arc::downgrade(lsp));
lsp.set_injection_provider(Arc::new(|path, language, version, text| {
let Some(plugins) = super::editor_plugin_host() else { return Vec::new() };
plugins
.provide_injections(&ProviderDocument {
path: path.to_path_buf(),
language: language.0.clone(),
version: version.into(),
text: text.to_owned(),
})
.into_iter()
.map(|i| ide_lsp::InjectedRange { range: i.range, language: ide_core::LanguageId::new(i.language) })
.collect()
}));
}
#[cfg(test)]
mod tests {
use super::*;
+8 -193
View File
@@ -78,25 +78,6 @@ pub fn read_source(path: &Path) -> IdeResult<IntellijThemeSource> {
/// The theme plugins of the JetBrains IDEs (and Android Studio) of this user,
/// the themes their IDEs are set to first.
pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
find_installed(&latest_product_versions(local_ide_dirs()))
}
/// The settings folder of a JetBrains IDE (or Android Studio) of this user.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InstalledIdeSettings {
/// The IDE and its version: `IntelliJ IDEA 2025.2`.
pub ide: String,
/// What Import Settings reads.
pub path: IpcPath,
}
/// The settings folders of the JetBrains IDEs of this user, most recently used first.
pub fn installed_ide_settings() -> Vec<InstalledIdeSettings> {
settings_folders(&local_ide_dirs())
}
fn local_ide_dirs() -> Vec<IdeDir> {
let Some(home) = dirs::home_dir() else { return Vec::new() };
let layout = if cfg!(target_os = "macos") {
Layout::Mac
@@ -105,65 +86,7 @@ fn local_ide_dirs() -> Vec<IdeDir> {
} else {
Layout::Xdg
};
ide_dirs(layout, &home, |key| std::env::var_os(key))
}
fn settings_folders(ides: &[IdeDir]) -> Vec<InstalledIdeSettings> {
let mut used: Vec<_> =
ides.iter().filter_map(|ide| settings_modified(&ide.config).map(|time| (ide, time))).collect();
used.sort_by(|(a, at), (b, bt)| {
bt.cmp(at).then_with(|| a.label.cmp(&b.label)).then_with(|| a.config.cmp(&b.config))
});
used.into_iter()
.map(|(ide, _)| InstalledIdeSettings { ide: ide.label.clone(), path: ide.config.clone().into() })
.collect()
}
fn settings_modified(config: &Path) -> Option<std::time::SystemTime> {
let options = config.join("options");
// Match the folder reader: never count links or walk below the settings directories.
if !config.symlink_metadata().ok()?.is_dir() || !options.symlink_metadata().ok()?.is_dir() {
return None;
}
let mut modified = None;
let mut customised = false;
for entry in std::fs::read_dir(&options).ok()?.flatten() {
let path = entry.path();
if !entry.file_type().is_ok_and(|t| t.is_file())
|| !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml"))
{
continue;
}
let Ok(meta) = entry.metadata() else { continue };
modified = modified.max(meta.modified().ok());
// Startup bookkeeping can exist before the user has changed any settings.
let name = entry.file_name();
customised |= meta.len() > 0
&& !matches!(
name.to_str(),
Some(
"other.xml"
| "nonRoamable.xml"
| "early-access-registry.xml"
| "applicationInfo.xml"
| "updates.xml"
| "usage.statistics.xml"
)
);
}
customised |= ["keymaps", "codestyles", "colors", "templates", "inspection"].iter().any(|sub| {
let dir = config.join(sub);
dir.symlink_metadata().is_ok_and(|m| m.is_dir())
&& std::fs::read_dir(dir).into_iter().flatten().flatten().any(|entry| {
entry.file_type().is_ok_and(|t| t.is_file())
&& entry.metadata().is_ok_and(|m| m.len() > 0)
&& entry
.path()
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("xml") || e.eq_ignore_ascii_case("icls"))
})
});
customised.then(|| modified.or_else(|| options.metadata().ok()?.modified().ok()).unwrap_or(std::time::UNIX_EPOCH))
find_installed(&ide_dirs(layout, &home, |key| std::env::var_os(key)))
}
fn read(path: &Path) -> Result<IntellijThemeSource, String> {
@@ -499,7 +422,8 @@ struct IdeDir {
plugins: PathBuf,
}
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest version first.
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
/// per product: its newest version's (an upgrade leaves the older ones behind).
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
@@ -539,14 +463,9 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
out
}
/// Theme discovery only uses the newest version of each product.
fn latest_product_versions(mut ides: Vec<IdeDir>) -> Vec<IdeDir> {
let mut products = std::collections::HashSet::new();
ides.retain(|ide| products.insert(ide.product.clone()));
ides
out.retain(|ide| products.insert(ide.product.clone()));
out
}
/// `IntelliJIdea2025.2` → (`IntelliJIdea`, (2025, 2), `2025.2`).
@@ -868,16 +787,15 @@ mod tests {
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
// Settings import keeps every version; theme discovery uses only the newest.
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 3);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
assert_eq!(latest_product_versions(dirs).len(), 1);
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 2);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
assert_eq!((dirs[0].config.clone(), dirs[0].plugins.clone()), (mac.clone(), mac.join("plugins")));
@@ -963,107 +881,4 @@ mod tests {
assert_eq!(found, [theme("shallow-id", "Shallow", false, true), theme("deep-sea-id", "Deep Sea", true, false)]);
assert!(find_installed(&[]).is_empty());
}
#[test]
fn unused_newer_versions_do_not_hide_settings() {
let tmp = tempfile::tempdir().unwrap();
for (layout, root) in [
(Layout::Mac, "Library/Application Support"),
(Layout::Windows, "AppData/Roaming"),
(Layout::Xdg, ".config"),
] {
let root = tmp.path().join(root).join("JetBrains");
let used = root.join("IntelliJIdea2024.3");
let fresh = root.join("IntelliJIdea2025.2");
write(&used.join("options/editor.xml"), b"<application><component name=\"EditorSettings\"/></application>");
std::fs::create_dir_all(&fresh).unwrap();
let expected = vec![InstalledIdeSettings { ide: "IntelliJ IDEA 2024.3".into(), path: used.into() }];
assert_eq!(settings_folders(&ide_dirs(layout, tmp.path(), |_| None)), expected);
write(&fresh.join("options/other.xml"), b"<application/>");
write(&fresh.join("options/early-access-registry.xml"), b"<application/>");
assert_eq!(settings_folders(&ide_dirs(layout, tmp.path(), |_| None)), expected);
}
}
#[test]
fn settings_candidates_keep_used_versions_and_sort_by_options_modification() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("Library/Application Support/JetBrains");
let put = |product: &str, file: &str, seconds| {
let path = root.join(product).join(file);
write(&path, b"<application/>");
std::fs::File::open(path)
.unwrap()
.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds))
.unwrap();
};
put("IntelliJIdea2025.2", "options/editor.xml", 100);
put("IntelliJIdea2024.3", "options/editor.xml", 200);
put("IntelliJIdea2024.3", "options/editor-font.xml", 400);
put("PyCharm2025.1", "options/editor.xml", 300);
// Directory times and unrelated files do not change options-file ordering.
write(&root.join("IntelliJIdea2025.2/options/README.txt"), b"recent");
let dirs = ide_dirs(Layout::Mac, tmp.path(), |_| None);
let found = settings_folders(&dirs);
assert_eq!(
found.iter().map(|s| s.ide.as_str()).collect::<Vec<_>>(),
["IntelliJ IDEA 2024.3", "PyCharm 2025.1", "IntelliJ IDEA 2025.2"]
);
assert_eq!(found[0].path, root.join("IntelliJIdea2024.3"));
assert_eq!(latest_product_versions(dirs).len(), 2);
}
#[test]
fn skips_empty_and_startup_only_settings_but_keeps_custom_schemes() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path();
assert!(settings_modified(config).is_none());
std::fs::create_dir(config.join("options")).unwrap();
assert!(settings_modified(config).is_none());
for file in ["other", "nonRoamable", "early-access-registry", "applicationInfo", "updates", "usage.statistics"]
{
write(
&config.join(format!("options/{file}.xml")),
b"<application><component name=\"Startup\"/></application>",
);
}
write(&config.join("options/editor.xml"), b"");
write(&config.join("options/nested/editor.xml"), b"<application/>");
for sub in ["keymaps", "codestyles", "colors", "templates", "inspection"] {
std::fs::create_dir(config.join(sub)).unwrap();
write(&config.join(sub).join("README.txt"), b"not settings");
}
assert!(settings_modified(config).is_none());
for (sub, file) in [
("keymaps", "custom.xml"),
("codestyles", "custom.xml"),
("colors", "custom.icls"),
("templates", "custom.xml"),
("inspection", "custom.xml"),
] {
let path = config.join(sub).join(file);
write(&path, b"<custom/>");
assert!(settings_modified(config).is_some(), "{sub} is a user setting");
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn lists_the_settings_folders_of_ides_that_have_been_run() {
let tmp = tempfile::tempdir().unwrap();
let ide = |label: &str, product: &str, version: (u32, u32)| {
let config = tmp.path().join(format!("{product}{}.{}", version.0, version.1));
IdeDir { label: label.into(), product: product.into(), version, config: config.clone(), plugins: config }
};
let used = ide("IntelliJ IDEA 2025.2", "IntelliJIdea", (2025, 2));
let fresh = ide("PyCharm 2025.1", "PyCharm", (2025, 1));
let used_config = used.config.clone();
write(&used_config.join("options/editor.xml"), b"<application/>");
std::fs::create_dir_all(&fresh.config).unwrap();
assert_eq!(
settings_folders(&[used, fresh]),
[InstalledIdeSettings { ide: "IntelliJ IDEA 2025.2".into(), path: used_config.into() }]
);
assert!(settings_folders(&[]).is_empty());
}
}
+3 -36
View File
@@ -143,8 +143,6 @@ struct Meta {
retry_at: Option<Instant>,
/// The component exports `syntax-provider` (known once activated).
has_syntax: bool,
/// Known before activation can publish target registrations.
has_run_target_validation: bool,
/// Next `on-schedule` activation.
next_schedule: Option<Instant>,
/// The trusted publisher of a signed installed package.
@@ -166,7 +164,6 @@ impl Meta {
failures: 0,
retry_at: None,
has_syntax: false,
has_run_target_validation: false,
publisher: None,
next_schedule: None,
}
@@ -1435,20 +1432,6 @@ impl PluginHost {
cancel: Option<&ide_core::CancellationToken>,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
self.code_call_with_budget(id, invocation, what, cancel, None, f)
}
fn code_call_with_budget<R: Send + 'static>(
&self,
id: &str,
invocation: Invocation,
what: &str,
cancel: Option<&ide_core::CancellationToken>,
budget: Option<Duration>,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
let deadline = budget.map(|budget| Instant::now() + budget);
let fallback = ide_core::CancellationToken::new();
let plugin = self.get(id)?;
let client = invocation.client.clone();
{
@@ -1489,25 +1472,10 @@ impl PluginHost {
}
let started = Instant::now();
let (call, inv) = (f.clone(), invocation.clone());
let result = match (deadline, cancel) {
(Some(deadline), cancel) => actor.call_bounded(
cancel.unwrap_or(&fallback),
Duration::from_millis(50),
deadline.saturating_duration_since(Instant::now()),
move |rt| call(rt, inv),
),
(None, Some(cancel)) => {
actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv))
}
(None, None) => actor.call(None, move |rt| call(rt, inv)),
let result = match cancel {
Some(cancel) => actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv)),
None => actor.call(None, move |rt| call(rt, inv)),
};
if deadline.is_some() && matches!(result, Err(ActorError::SoftTimeout)) {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::new(
ide_core::error::ErrorKind::TimedOut,
format!("plugin {id:?} timed out while {what}"),
));
}
if matches!(result, Err(ActorError::Cancelled)) {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::cancelled());
@@ -2555,7 +2523,6 @@ impl PluginHost {
return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id())));
}
};
plugin.meta.lock().has_run_target_validation = rt.has_run_target_validation();
let crash = match rt.activate() {
Ok(_) => None,
Err(CallError::Trap(e)) => Some(format!("activation {e}")),
+1 -1
View File
@@ -11,7 +11,7 @@ use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
pub const MAX_DOCUMENT_BYTES: usize = ide_lsp::MAX_INJECTED_DOCUMENT_BYTES;
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
const MAX_FILES: usize = 20_000;
const MAX_EDGES: usize = 4096;
-14
View File
@@ -76,29 +76,16 @@ pub struct Providers {
impl Providers {
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
let injections = decl.kinds.contains(&ProviderKind::Injections);
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
if injections {
self.injections_changed();
}
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, ..)| *s != seq);
if injections {
this.injections_changed();
}
}
})
}
fn injections_changed(self: &Arc<Self>) {
if editor_plugin_host().is_some_and(|host| Arc::ptr_eq(&host.providers, self)) {
super::injections::providers_changed();
}
}
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
@@ -273,7 +260,6 @@ static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
/// definition and inline completion (`None` removes them).
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
super::injections::providers_changed();
}
/// The plugin host whose providers take part, when any declares `kind`.
+4 -81
View File
@@ -21,15 +21,6 @@ fn selector(plugin: &str, id: &str) -> String {
format!("plugin-target:{}:{plugin}{id}", plugin.len())
}
impl Shared {
fn targets_changed(&self) {
if let Some(host) = self.workspace().and_then(|w| w.database_host()) {
for project in host.projects() {
project.lsp.reconfigure_soon();
}
}
self.sink.emit(None, PluginEvent::RunTargetsChanged);
}
fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf {
let key = format!("{}\0{id}", root.display());
self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str())
@@ -40,7 +31,7 @@ impl Shared {
targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r)));
if before != targets.len() {
drop(targets);
self.targets_changed();
self.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
@@ -79,12 +70,12 @@ impl PluginApi {
);
drop(targets);
drop(blocked);
self.shared.targets_changed();
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
Ok(())
}
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
self.shared.targets_changed();
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
@@ -102,31 +93,6 @@ impl PluginHost {
id: &str,
config: &ide_run::RunConfig,
) -> IdeResult<ide_run::RunConfig> {
self.prepare_target(root, id, config, None)
}
/// Prepares a user launch, including the optional plugin validation gate.
pub fn prepare_run_target_launch(
&self,
root: &Path,
id: &str,
config: &ide_run::RunConfig,
operation: ide_plugin::RunTargetOperation,
cancel: &ide_core::CancellationToken,
) -> IdeResult<ide_run::RunConfig> {
self.prepare_target(root, id, config, Some((operation, cancel)))
}
fn prepare_target(
&self,
root: &Path,
id: &str,
config: &ide_run::RunConfig,
launch: Option<(ide_plugin::RunTargetOperation, &ide_core::CancellationToken)>,
) -> IdeResult<ide_run::RunConfig> {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
}
let target = self
.shared
.run_targets
@@ -135,50 +101,7 @@ impl PluginHost {
.cloned()
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
let wrapped = ide_remote::plugin_target::prepare(root, &target.target, &program, config)?;
let validates = self.get(&target.plugin_id)?.meta.lock().has_run_target_validation;
if let Some((operation, cancel)) = launch
&& validates
{
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let info = ide_plugin::RunTargetLaunch {
operation,
program: ide_run::wrap::program_name(&config.command),
cwd: wrapped.cwd.as_ref().unwrap().to_string_lossy().into_owned(),
};
let project_root = root.to_path_buf();
let invocation =
ide_plugin::Invocation { workspace_root: Some(project_root.clone()), ..Default::default() };
let (target_id, timeout) = (target.target.id.clone(), self.limits.timeout);
let result = self.code_call_with_budget(
&target.plugin_id,
invocation,
"validating the run target",
Some(cancel),
Some(timeout),
move |rt, _| {
rt.validate_run_target(
project_root.to_string_lossy().into_owned(),
target_id.clone(),
info.clone(),
timeout,
)
},
);
match result {
Ok(Ok(())) => {}
Ok(Err(message)) => return Err(IdeError::invalid(format!("{}: {message}", target.plugin_name))),
Err(error) if error.kind == ide_core::error::ErrorKind::Cancelled => return Err(error),
Err(error) => return Err(IdeError::unavailable(format!("{}: {error}", target.plugin_name))),
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
}
Ok(wrapped)
ide_remote::plugin_target::prepare(root, &target.target, &program, config)
}
}
+15 -96
View File
@@ -83,7 +83,7 @@ impl Host {
let opening = self.opening(window);
let _opening = opening.lock();
// Reject a damaged manifest before replacing the currently open project.
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
ide_workspace::workspace::Workspace::load(&root)?;
// The window reloaded (or asked again) for the project it already has: keep
// it, with its warm index, language servers and unsaved documents.
if let Ok(current) = self.project(window)
@@ -96,7 +96,7 @@ impl Host {
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
// Before anything the project starts: its children get the project's environment.
let trusted = Arc::new(AtomicBool::new(self.trust.window_trusted(&root)?));
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
let post_start =
trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten();
let settings = SettingsStore::load(&self.config_dir, Some(&root));
@@ -133,55 +133,9 @@ impl Host {
)?;
mark("index");
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
crate::plugins::injections::configure_lsp(&lsp);
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
lsp.set_content_roots(extra_roots.clone());
let settings = Arc::new(settings);
{
let settings = settings.clone();
// Workspace members have independent settings; target servers have their own path mappings.
lsp.set_jdtls_shared_hook(Box::new(move || {
let effective = settings.effective();
!is_workspace
&& effective.languages.java.jdtls_shared
&& effective.language_server_targets.get("java").is_none_or(|target| target.target.is_empty())
}));
}
{
let settings = settings.clone();
let root = root.clone();
let plugins = self.plugin_host.clone();
let reader_plugins = plugins.clone();
lsp.set_target_hooks(
Box::new(move |language, cfg| {
let effective = settings.effective();
let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
else {
return false;
};
let Some(plugins) = plugins.read().upgrade() else { return false };
// A withdrawn target returns the server to the host. An invalid active
// target stays visibly failed; it must never silently fall back.
if !plugins.run_targets(&root).iter().any(|t| t.id == selection.target) {
return false;
}
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
if let Err(e) = crate::ops::lsp_target::configure(&plugins, &root, &selection.target, cfg) {
cfg.launch_error = Some(e.to_string());
}
true
}),
Box::new(move |target, path| {
let plugins = reader_plugins
.read()
.upgrade()
.ok_or_else(|| IdeError::unavailable("plugin host is unavailable"))?;
crate::ops::lsp_target::read(&plugins, target, path)
}),
);
}
{
let settings = settings.clone();
let root = root.clone();
@@ -198,15 +152,14 @@ impl Host {
.as_ref()
.map(|p| SettingsStore::load(&config_dir, Some(p)).effective())
.unwrap_or_else(|| settings.effective());
let window_root = &root;
let root = member.as_ref().unwrap_or(window_root);
let root = member.as_ref().unwrap_or(&root);
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
if cfg.name == "jdtls" {
// A workspace member keeps its build model under its own key (Build Project).
let cache = match &member {
Some(member) if member != window_root => build_cache.join(crate::project::project_key(member)),
_ => build_cache.clone(),
Some(member) => build_cache.join(crate::project::project_key(member)),
None => build_cache.clone(),
};
let profiles = ide_build::maven_profiles(&cache);
ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache);
@@ -319,23 +272,7 @@ impl Host {
{
let settings = settings.clone();
lsp.set_servers_hook(Box::new(move |language, defined| {
let effective = settings.effective();
let mut servers = ide_sdk::apply::language_servers(language.as_str(), defined, &effective);
if let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
{
let chosen = if selection.server.is_empty() {
servers.first().map(|s| s.name.clone())
} else {
Some(selection.server.clone())
};
servers.retain(|s| Some(&s.name) == chosen.as_ref());
for server in &mut servers {
server.fallback = false;
server.target_key = Some(selection.target.clone());
}
}
servers
ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective())
}));
}
{
@@ -412,7 +349,6 @@ impl Host {
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
let (ignored, ignored_gone) = ignored_changes(batch);
index.ignore_rules_changed(&batch.ignore_rules);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
phpstan.files_removed(batch.removed.iter());
@@ -1081,13 +1017,12 @@ impl Project {
let Some(root) = root else {
return ide_lsp::CandidateFiles::default();
};
let mut exclude = self.search_excludes();
// Directories the server analyses fully: their files are never read.
exclude.extend(q.skip.iter().filter_map(|dir| {
let rel = dir.strip_prefix(&root).ok()?.to_str()?.replace('\\', "/");
(!rel.is_empty() && !rel.contains(['*', '?', '[', ']', '{', '}', '!', ','])).then(|| format!("{rel}/**"))
}));
let query = ide_search::WordQuery { word: q.word.clone(), dir: q.root.clone(), exclude, limit: q.limit };
let query = ide_search::WordQuery {
word: q.word.clone(),
dir: q.root.clone(),
exclude: self.search_excludes(),
limit: q.limit,
};
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None));
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_text_of(p), cancel) {
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
@@ -1226,32 +1161,17 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa
}
}
/// Only explicit workspace containers exclude their root from indexed content.
/// A workspace uses its root only for state, never as indexed content.
fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
let container =
ide_workspace::workspace::Workspace::load(root).ok().flatten().is_some_and(|w| w.is_container(root));
ide_index::IndexRoots { include_root: !container, folders: folders.into_iter().filter(|p| p != root).collect() }
ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders }
}
/// Workspace members in manifest order, or extra Project Structure directories and the
/// Gradle project folders outside the project as of the last sync (`build_cache`).
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
if workspace.is_container(root) {
return workspace.projects;
}
let mut roots = workspace.projects.into_iter().filter(|p| !p.starts_with(root)).collect::<Vec<_>>();
for path in extra_content_roots(settings, root, build_cache) {
if !roots.contains(&path) {
roots.push(path);
}
}
return roots;
return workspace.projects;
}
extra_content_roots(settings, root, build_cache)
}
fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
@@ -1310,7 +1230,6 @@ fn content_root_watcher(
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
let (ignored, ignored_gone) = ignored_changes(batch);
index.ignore_rules_changed(&batch.ignore_rules);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
// A workspace member's PHP language level follows its own composer.json.
if composer_changed(&root, changed.iter().chain(&batch.removed)) {
+4 -4
View File
@@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const EOF_GRACE: Duration = Duration::from_secs(10);
const EOF_GRACE: Duration = Duration::from_secs(5);
/// `<host> <port>` from the arguments after `--connect`.
pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
@@ -25,7 +25,7 @@ pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
/// Connects to `host:port`, copies `input` to it and its replies to `output`, and returns when
/// the target is done (the copy of `input` may still be blocked: the caller exits). Fails only
/// when the connection can't be opened. An input EOF allows ten idle seconds for a final reply.
/// when the connection can't be opened. An input EOF allows five idle seconds for a final reply.
pub fn relay(host: &str, port: u16, mut input: impl Read + Send + 'static, mut output: impl Write) -> io::Result<()> {
let mut from_target = connect(host, port)?;
from_target.set_read_timeout(Some(Duration::from_millis(100)))?;
@@ -140,11 +140,11 @@ mod tests {
let mut request = Vec::new();
socket.read_to_end(&mut request).unwrap();
// Remain open after seeing EOF, like an idle keep-alive server.
wait.recv_timeout(Duration::from_secs(15)).unwrap();
wait.recv_timeout(Duration::from_secs(10)).unwrap();
});
let start = Instant::now();
relay("127.0.0.1", port, io::empty(), io::sink()).unwrap();
assert!(start.elapsed() < Duration::from_secs(13));
assert!(start.elapsed() < Duration::from_secs(8));
done.send(()).unwrap();
server.join().unwrap();
}
+1 -1
View File
@@ -262,7 +262,7 @@ impl Dispatcher {
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
.add("run_start", |c| async move {
let on_output = c.channel("onOutput")?;
ops::run::start_request(&c.host, &c.window, c.arg("config")?, on_output, c.arg("request")?).await
ops::run::start(&c.host, &c.window, c.arg("config")?, on_output).await
})
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
+2 -31
View File
@@ -56,22 +56,6 @@ impl TrustStore {
|| self.inherited.lock().contains(&root)))
}
pub fn window_trusted(&self, root: &Path) -> IdeResult<bool> {
if !self.trusted(root)? {
return Ok(false);
}
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)?
&& !workspace.is_container(root)
{
for member in workspace.projects.iter().filter(|p| *p != root && p.exists()) {
if !self.trusted(member)? {
return Ok(false);
}
}
}
Ok(true)
}
/// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]).
pub fn inherit(&self, path: &Path) -> IdeResult<()> {
let root = dunce::canonicalize(path)?;
@@ -150,7 +134,7 @@ impl crate::Host {
pub async fn refresh_project_trust(&self) -> IdeResult<()> {
for project in self.projects().into_iter().filter(|p| !p.light) {
let trusted = self.trust.window_trusted(&project.root).unwrap_or(false);
let trusted = self.trust.trusted(&project.root).unwrap_or(false);
let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel);
if previous == trusted {
continue;
@@ -237,17 +221,13 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "app_cli_uninstall"
| "app_log_location"
| "app_check_update"
| "app_update_window_ready"
| "app_update_status"
| "app_update_announcement"
| "app_startup_update_check"
| "app_install_update"
| "caches_schedule_invalidation"
| "clear_caches"
| "clipboard_file_list"
| "clipboard_has_files"
| "clipboard_image"
| "clipboard_set_file_list"
| "clipboard_write_text"
| "jdk_download_vendors"
| "jdk_download_releases"
| "jdk_download_location"
@@ -291,7 +271,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_files_named"
| "find_dirs"
| "find_symbols"
| "mark_recent"
@@ -455,11 +434,3 @@ impl TrustStore {
Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted })
}
}
#[cfg(test)]
mod tests {
#[test]
fn restricted_mode_allows_clipboard_write_text() {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
}
@@ -93,9 +93,6 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let p = plugins(tmp.path());
p.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
set_editor_providers(Some(&p));
let generation = host.project("main").unwrap().lsp.injection_generation();
drop(plugins(&tmp.path().join("unrelated")));
assert_eq!(host.project("main").unwrap().lsp.injection_generation(), generation);
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
@@ -204,7 +201,6 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let visual = invoke(&peer, "plugin_language_visuals", serde_json::json!({"doc":doc.id})).await;
assert_eq!(visual["gutterActions"].as_array().unwrap().len(), 1);
set_editor_providers(Some(&p));
injected_lsp(&host, &p, &root, &peer).await;
p.set_enabled(ID, false).unwrap();
assert!(ops::lsp::plugin_formatters().is_empty());
set_editor_providers(None);
@@ -236,131 +232,3 @@ fn formatting_uses_the_command_budget_while_editor_calls_can_expire() {
assert!(p.language_signature_help(&doc, Pos::default()).is_none());
assert!(p.info_of(ID).unwrap().stats.slow_calls > 0);
}
// Same TCP fake-server pattern as plugin_refactoring; the real Wasm export supplies the range.
async fn injected_lsp(host: &Host, plugins: &PluginHost, root: &Path, remote: &ide_jsonrpc::Peer) {
use ide_jsonrpc::{Handler, Peer};
use parking_lot::Mutex;
use serde_json::{Value, json};
#[derive(Default)]
struct Fake {
docs: Mutex<std::collections::HashMap<String, String>>,
requests: Mutex<Vec<(String, Value)>>,
}
impl Handler for Fake {
fn on_notification(&self, method: &str, p: Value) {
let uri = p["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
match method {
"textDocument/didOpen" => {
self.docs.lock().insert(uri, p["textDocument"]["text"].as_str().unwrap().into());
}
"textDocument/didChange" => {
self.docs.lock().insert(uri, p["contentChanges"][0]["text"].as_str().unwrap().into());
}
"textDocument/didClose" => {
self.docs.lock().remove(&uri);
}
_ => {}
}
}
fn on_request(&self, method: &str, p: Value) -> Result<Value, (i64, String)> {
self.requests.lock().push((method.to_owned(), p.clone()));
Ok(match method {
"initialize" => {
json!({"capabilities": {"textDocumentSync": {"openClose":true,"change":1}, "completionProvider":{"resolveProvider":true}, "hoverProvider":true, "definitionProvider":true}})
}
"textDocument/completion" => json!([{"label":"embedded-server", "data":{"identity":7}}]),
"completionItem/resolve" => {
assert_eq!(p["data"], json!({"identity":7}));
json!({"label":"embedded-server", "detail":"resolved"})
}
"textDocument/hover" => {
json!({"contents":"embedded hover", "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
}
"textDocument/definition" => {
json!({"uri":p["textDocument"]["uri"], "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
}
_ => Value::Null,
})
}
}
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let fake = Arc::new(Fake::default());
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
let (accepted, state) = (peers.clone(), fake.clone());
let task = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (r, w) = stream.into_split();
accepted.lock().push(Peer::connect(r, w, state.clone()));
}
});
let cfg: ide_language::LanguageServerConfig =
toml::from_str(&format!("name='injected-sql'\ncommand='no-process-required'\nconnect='127.0.0.1:{port}'"))
.unwrap();
let project = host.project("main").unwrap();
project.lsp.set_servers_hook(Box::new(
move |language, _| if language.as_str() == "sql" { vec![cfg.clone()] } else { Vec::new() },
));
let path = root.join("injection.txt");
std::fs::write(&path, "😀[[sql]] tail").unwrap();
let doc = ops::doc::open(host, "main", path.clone()).await.unwrap();
let items = ops::lsp::lsp_completion(host, "main", RequestId(91), doc.id, Pos::new(0, 5), None).await.unwrap();
let item = items.items.into_iter().find(|i| i.label == "embedded-server").unwrap();
assert_eq!(
ops::lsp::lsp_resolve_completion(host, "main", doc.id, item).await.unwrap().detail.as_deref(),
Some("resolved")
);
let opened = fake.docs.lock().clone();
assert_eq!(opened.len(), 1);
assert_eq!(opened.values().next().unwrap(), " sql ");
let virtual_uri = opened.keys().next().unwrap().clone();
assert!(!ide_lsp::uri_to_path(&virtual_uri).unwrap().exists());
let before = fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count();
ops::lsp::lsp_completion(host, "main", RequestId(92), doc.id, Pos::new(0, 2), None).await.unwrap();
assert_eq!(fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count(), before);
let defs = ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Declaration).await;
// Declaration isn't advertised; definition exercises mapping without a plugin definition provider.
assert!(defs.is_err());
let defs =
ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].path.0, path);
assert_eq!(defs[0].range, Range::new(Pos::new(0, 4), Pos::new(0, 7)));
let hover: Value = remote
.request(
"invoke",
json!({"window":"main","cmd":"lsp_hover","args":{"request":93,"doc":doc.id,"pos":{"line":0,"col":5}}}),
None,
)
.await
.unwrap();
assert!(hover.to_string().contains("embedded hover"), "{hover}");
ops::doc::edit(
host,
"main",
doc.id,
doc.version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 7, insert: "SQL".into() }] },
)
.unwrap();
ops::lsp::lsp_completion(host, "main", RequestId(94), doc.id, Pos::new(0, 5), None).await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while fake.docs.lock().get(&virtual_uri).is_none_or(|s| s != " SQL ") {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
plugins.set_enabled(ID, false).unwrap();
while !fake.docs.lock().is_empty() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(project.lsp.injected_document(doc.id, Pos::new(0, 5)), doc.id);
ops::doc::close(host, "main", doc.id).unwrap();
project.lsp.shutdown_all().await;
project.lsp.set_servers_hook(Box::new(|_, _| Vec::new()));
task.abort();
let _ = task.await;
for peer in peers.lock().drain(..) {
peer.kill();
}
}
-200
View File
@@ -352,203 +352,3 @@ async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
assert!(plugins.run_targets(&f.root).is_empty());
plugins.shutdown();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn run_target_api15_overrides_and_frozen_api14_registration() {
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Invocation, Limits, PluginEngine};
use std::os::unix::fs::PermissionsExt;
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(f._tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(),
);
plugins.set_workspace(Some(Arc::new(f.workspace())));
f.host.set_plugin_host(&plugins);
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures");
let engine = PluginEngine::new().unwrap();
let legacy = engine.compile(&fixtures.join("run-target-api14/dist/plugin.wasm")).unwrap();
assert_eq!(engine.check_component(&legacy).unwrap().unwrap().to_string(), "1.4.0");
let tool = f._tmp.path().join("wrapper");
std::fs::write(&tool, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
let config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
"id":"composer", "name":"Composer", "kind":"shell", "command":"composer", "args":["install"],
"env":{}, "detected":false, "isTest":false,
}))
.unwrap();
for (plugin, command) in [("run-target-api14", "target"), ("execution", "target-overrides")] {
plugins.install_from_dir(&fixtures.join(plugin), &Consent::all()).unwrap();
assert!(plugins.run_command(plugin, command, Some("empty"), invocation()).is_err());
plugins.choose_program(plugin, "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
let cases = if plugin == "execution" {
vec![
("empty", vec!["composer", "install"]),
("own", vec!["composer", "--working-dir=/container", "install"]),
("inherit", vec!["composer", "--cwd=/container", "install"]),
]
} else {
vec![("inherit", vec!["composer", "--cwd=/container", "install"])]
};
for (arg, expected) in cases {
plugins.run_command(plugin, command, Some(arg), invocation()).unwrap();
let target = plugins.run_targets(&f.root).into_iter().find(|t| t.plugin_id == plugin).unwrap();
let wrapped = plugins
.prepare_run_target_launch(
&f.root,
&target.id,
&config,
ide_plugin::RunTargetOperation::Run,
&Default::default(),
)
.unwrap();
assert_eq!(wrapped.args, expected);
assert_eq!(PathBuf::from(wrapped.command), tool);
assert_eq!(wrapped.cwd.unwrap(), f.root);
let php = ide_run::RunConfig { command: "php".into(), args: vec!["x.php".into()], ..config.clone() };
assert_eq!(
plugins.prepare_run_target(&f.root, &target.id, &php).unwrap().args,
["wrapped", "--cwd=/container", "php", "x.php"]
);
}
if plugin == "execution" {
assert!(plugins.run_command(plugin, command, Some("invalid"), invocation()).is_err());
// A failed replacement retains the previous valid registration.
assert_eq!(plugins.run_targets(&f.root).iter().filter(|t| t.plugin_id == plugin).count(), 1);
}
plugins.set_capability(plugin, "process:execution-tool", false).unwrap();
assert!(plugins.run_targets(&f.root).iter().all(|t| t.plugin_id != plugin));
assert!(plugins.run_command(plugin, command, None, invocation()).is_err());
plugins.choose_program(plugin, "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
f.host.set_project_trust(&f.root, false, false).await.unwrap();
assert!(plugins.run_command(plugin, command, None, invocation()).unwrap_err().to_string().contains("trusted"));
f.host.set_project_trust(&f.root, true, false).await.unwrap();
}
plugins.shutdown();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn run_target_validation_gates_launches_and_preserves_selection() {
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Invocation, Limits, RunTargetOperation};
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(
f._tmp.path().join("plugins"),
Arc::new(|_: Option<&str>, _| {}),
Limits {
timeout: Duration::from_millis(200),
idle_timeout: Some(Duration::from_millis(20)),
restart_backoff: Duration::ZERO,
..Default::default()
},
)
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(f.workspace())));
f.host.set_plugin_host(&plugins);
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
let tool = f._tmp.path().join("wrapper");
std::fs::write(&tool, "#!/bin/sh\ntouch spawned\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
let config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
"id":"composer", "name":"Composer", "kind":"shell", "command":"/tools/composer", "args":["install"],
"env":{"SECRET":"not sent"}, "detected":false, "isTest":false,
}))
.unwrap();
for id in ["allow", "refuse", "long-refusal", "timeout", "trap"] {
plugins.run_command("execution", "target", Some(id), invocation()).unwrap();
}
let targets = plugins.run_targets(&f.root);
let id = |name: &str| targets.iter().find(|t| t.target.id == name).unwrap().id.clone();
for operation in [RunTargetOperation::Run, RunTargetOperation::Test, RunTargetOperation::Debug] {
plugins.prepare_run_target_launch(&f.root, &id("allow"), &config, operation, &Default::default()).unwrap();
let snapshot = plugins.run_command("execution", "validation", None, invocation()).unwrap().unwrap();
assert_eq!(snapshot, format!("{}|allow|{operation:?}|composer|{}", f.root.display(), f.root.display()));
}
tokio::time::sleep(Duration::from_millis(30)).await;
plugins.tick();
assert_eq!(plugins.info_of("execution").unwrap().status, ide_host::plugins::PluginStatus::Inactive);
plugins
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &Default::default())
.unwrap();
assert!(plugins.info_of("execution").unwrap().stats.reclaims > 0);
// Non-launch consumers (LSP and document reads) do not invoke validation.
assert!(plugins.prepare_run_target(&f.root, &id("refuse"), &config).is_ok());
let selected = id("refuse");
ide_host::plugins::run_targets::answer(&f.host, &plugins, "main", &selected, true).unwrap();
for is_test in [false, true] {
let mut config = config.clone();
config.is_test = is_test;
config.extras.target = Some(selected.clone());
let error = ide_host::ops::run::start(&f.host, "main", config, Arc::new(|_| {})).await.unwrap_err();
assert!(error.message.contains("Plugin execution tests: Project runtime is stopped"), "{error}");
assert!(f.host.processes.list().is_empty());
assert!(!f.root.join("spawned").exists());
assert_eq!(f.host.project("main").unwrap().settings.effective().run.default_target, selected);
assert_eq!(plugins.run_targets(&f.root).len(), targets.len());
}
for adapter in ["php-debug", "debugpy"] {
let launch = ide_dap::LaunchConfig {
name: "Refused debug".into(),
adapter: adapter.into(),
request: "launch".into(),
program: Some("app.php".into()),
args: Vec::new(),
cwd: Some(f.root.clone().into()),
env: Default::default(),
extra_json: Some(serde_json::json!({"__runTarget":selected, "__runTargetProgram":"php"}).to_string()),
};
let error = ide_host::ops::debug::debug_start(&f.host, "main", launch).await.unwrap_err();
assert!(error.message.contains("Project runtime is stopped"), "{adapter}: {error}");
assert!(f.host.debugger.sessions(None).is_empty());
}
let error = plugins
.prepare_run_target_launch(&f.root, &id("long-refusal"), &config, RunTargetOperation::Run, &Default::default())
.unwrap_err();
assert!(error.message.len() < 4200);
let request = ide_core::RequestId(2408);
let mut cfg = config.clone();
cfg.extras.target = Some(id("timeout"));
let worker = f.host.clone();
let pending = tokio::spawn(async move {
ide_host::ops::run::start_request(&worker, "main", cfg, Arc::new(|_| {}), Some(request)).await
});
tokio::time::sleep(Duration::from_millis(30)).await;
f.host.cancel_request(request);
assert_eq!(pending.await.unwrap().unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
for name in ["timeout", "trap"] {
let started = std::time::Instant::now();
let error = plugins
.prepare_run_target_launch(&f.root, &id(name), &config, RunTargetOperation::Run, &Default::default())
.unwrap_err();
assert!(error.message.contains("Plugin execution tests"), "{error}");
if name == "timeout" {
assert!(error.message.contains("timed out"), "{error}");
assert!(started.elapsed() < Duration::from_secs(2), "background tasks must not extend validation");
}
assert!(f.host.processes.list().is_empty());
}
// A cancelled launch never enters the wrapper.
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
assert_eq!(
plugins
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &cancel)
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Cancelled
);
plugins.shutdown();
}
+27 -52
View File
@@ -126,10 +126,6 @@ impl PluginWorkspace for TestWorkspace {
fn trusted(&self, _root: &Path) -> bool {
self.trusted
}
fn project_windows(&self, root: &Path) -> Vec<String> {
// `main` shows the fixture project; other open roots have no window left.
if root == self.root { vec!["closing".into(), "main".into()] } else { Vec::new() }
}
fn project_of(&self, _window: &str) -> Option<PathBuf> {
if let Some(left) = self.project_queries_left.lock().as_mut() {
if *left == 0 {
@@ -3694,6 +3690,31 @@ fn listing_while_projects_change_does_not_deadlock() {
}
}
/// Excalidraw: a web custom editor beside the text editor, with its fonts,
/// file icon, template and language — no code.
#[test]
fn official_excalidraw_installs_and_serves_its_page() {
let tmp = tempfile::tempdir().unwrap();
let pkg = with_stand_in_page(&official("excalidraw"), tmp.path());
let f = fixture(Limits::default());
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let page = f.host.web_page("openidea.excalidraw", "canvas").unwrap();
let (rev, entry) = page.path.strip_prefix("openidea.excalidraw/").unwrap().split_once('/').unwrap();
assert_eq!(entry, "web/index.html");
let html = f.host.web_file("openidea.excalidraw", &format!("{rev}/{entry}")).unwrap();
let html = String::from_utf8(html.bytes).unwrap();
// Relative URLs only: the page is served from the package.
let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap();
assert!(script.starts_with("./assets/"), "{script}");
assert!(f.host.web_file("openidea.excalidraw", &format!("{rev}/web/{}", &script[2..])).is_ok());
let font = f.host.web_file("openidea.excalidraw", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap();
assert_eq!(font.mime, "font/woff2");
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "preview");
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "excalidraw"));
assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2);
}
/// A copy of the official plugin at `src` in `dir`, with a stand-in for its
/// built page (`web/` is built by plugins/build.sh, not checked in; build.sh
/// checks what the build produces): an entry loading a script and a font by
@@ -3735,18 +3756,7 @@ fn official_graphviz_turns_its_pages_reports_into_problems() {
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "split");
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "dot"));
assert_eq!(f.host.assets("openidea.graphviz", "fileIcon/graphviz/").unwrap().len(), 2);
let page = f.host.web_page("openidea.graphviz", "preview").unwrap();
let (rev, entry) = page.path.strip_prefix("openidea.graphviz/").unwrap().split_once('/').unwrap();
assert_eq!(entry, "web/index.html");
let html = f.host.web_file("openidea.graphviz", &format!("{rev}/{entry}")).unwrap();
let html = String::from_utf8(html.bytes).unwrap();
// Relative URLs only: the page is served from the package.
let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap();
assert!(script.starts_with("./assets/"), "{script}");
assert!(f.host.web_file("openidea.graphviz", &format!("{rev}/web/{}", &script[2..])).is_ok());
let font = f.host.web_file("openidea.graphviz", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap();
assert_eq!(font.mime, "font/woff2");
assert!(f.host.web_page("openidea.graphviz", "preview").is_ok());
std::fs::write(f.project.join("flow.dot"), "digraph {\n a -> \n}\n").unwrap();
let editor = f.workspace.open("flow.dot", &[]);
@@ -4791,40 +4801,6 @@ fn notification_actions_keep_commands_and_window_scope() {
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
}
#[test]
fn project_notifications_go_to_a_window_of_that_project_only() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.host.set_capability("jobs", "project.read", false).unwrap();
f.host.forget_client("closing");
let other = f.project.parent().unwrap().join("other");
std::fs::create_dir(&other).unwrap();
f.workspace.other_roots.lock().push(other.clone());
f.take_events();
let notifications = |f: &Fixture| {
f.take_events()
.into_iter()
.filter_map(|(client, event)| match event {
PluginEvent::Notification { title, actions, .. } => Some((client, title, actions)),
_ => None,
})
.collect::<Vec<_>>()
};
// A scheduled call has no invoking window; a closing window is skipped.
let path = f.project.join("notes.txt").to_string_lossy().into_owned();
f.host.run_command("jobs", "notify-project", Some(&path), Invocation::default()).unwrap();
let sent = notifications(&f);
assert_eq!(sent.len(), 1);
assert_eq!((sent[0].0.as_deref(), sent[0].1.as_str()), (Some("main"), "Project update"));
assert_eq!((sent[0].2[0].command.as_str(), sent[0].2[0].argument.as_deref()), ("context", Some(path.as_str())));
// A project without a window fails without falling back to another one, even when invoked from one.
let path = other.join("notes.txt").to_string_lossy().into_owned();
let error = f.host.run_command("jobs", "notify-project", Some(&path), f.inv()).unwrap_err().message;
assert!(error.contains("not open"), "{error}");
assert!(notifications(&f).is_empty());
assert_eq!(f.status("jobs"), PluginStatus::Active);
}
#[test]
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
use ide_plugin::Prompt;
@@ -6082,8 +6058,7 @@ fn api14_test_selection_traps_and_timeouts_are_unsupported() {
f.host.refresh_run_configurations(&f.project);
let started = Instant::now();
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec![failure.into()]]).unwrap(), None);
// Selection is a user action: bounded by the command budget, not the edit path.
assert!(started.elapsed() < Limits::default().timeout + Duration::from_secs(2));
assert!(started.elapsed() < Duration::from_secs(5));
}
}
+1 -35
View File
@@ -548,7 +548,7 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
let bin = tmp.path().join("bin");
std::fs::create_dir(&bin).unwrap();
let executable = bin.join("execution-tool");
std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nif [ \"$1\" = composer ]; then printf '<%s>\\n' \"$@\" > \"$PWD/composer.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap();
std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
let path_var = {
let bin = bin.clone();
@@ -599,17 +599,9 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
)
.await
.unwrap();
invoke(
&peer,
"plugin_run_command",
json!({"pluginId":"execution", "commandId":"target-overrides", "arg":"empty", "editor":null}),
)
.await
.unwrap();
let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap();
assert_eq!(targets.as_array().unwrap().len(), 1);
assert_eq!(targets[0]["pluginId"], "execution");
assert_eq!(targets[0]["target"]["commands"][0]["workdirArgs"], json!([]));
assert!(desktop.run_targets(&project).is_empty());
let id = targets[0]["id"].as_str().unwrap();
let settings = invoke(&peer, "plugin_answer_run_target", json!({"id":id, "useTarget":false})).await.unwrap();
@@ -622,10 +614,6 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
"<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n"
);
assert!(!project.join("bad").exists());
let composer = json!({"id":"composer", "name":"Composer", "kind":"shell", "command":"composer", "args":["install"], "env":{}, "detected":false, "isTest":false, "target":id});
invoke(&peer, "run_start", json!({"config":composer, "onOutput":{"$channel":39}})).await.unwrap();
until("remote composer override", 5, || project.join("composer.txt").exists()).await;
assert_eq!(std::fs::read_to_string(project.join("composer.txt")).unwrap(), "<composer>\n<install>\n");
invoke(
&peer,
"plugin_run_command",
@@ -634,28 +622,6 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
.await
.unwrap();
assert_eq!(invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(), json!([]));
invoke(
&peer,
"plugin_run_command",
json!({"pluginId":"execution", "commandId":"target", "arg":"refuse", "editor":null}),
)
.await
.unwrap();
let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap();
let refused = targets[0]["id"].as_str().unwrap();
std::fs::remove_file(project.join("wrapped.txt")).unwrap();
let config = json!({"id":"refused", "name":"Refused", "kind":"shell", "command":"php", "args":[], "env":{}, "detected":false, "isTest":false, "target":refused});
let error = invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":40}})).await.unwrap_err();
assert!(error.to_string().contains("Project runtime is stopped"), "{error}");
assert!(!project.join("wrapped.txt").exists());
assert!(desktop.run_targets(&project).is_empty());
invoke(
&peer,
"plugin_run_command",
json!({"pluginId":"execution", "commandId":"untarget", "arg":"refuse", "editor":null}),
)
.await
.unwrap();
client.plugin_events.lock().clear();
client.events.lock().clear();
let runner = peer.clone();
-102
View File
@@ -1,102 +0,0 @@
//! Target debugging uses the same trusted plugin wrapper on desktop and the SSH server.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::{
Host, HostConfig,
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
};
use ide_language::LanguageRegistry;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde_json::json;
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
#[tokio::test(flavor = "multi_thread")]
async fn registered_target_debugs_over_stdio_and_rechecks_program_grant() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let adapter = base.join("adapter.py");
std::fs::write(&adapter, include_str!("../../ide-dap/tests/fixtures/target_adapter.py")).unwrap();
let wrapper = base.join("wrapper");
std::fs::write(
&wrapper,
format!(
r#"#!/bin/sh
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
shift 2
if [ "$1" = cat ]; then exec cat "$2" "$3"; fi
export FAKE_ROOT=/container
exec /usr/bin/python3 -u '{}'
"#,
adapter.display()
),
)
.unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let e = sink_events.clone();
Arc::new(move |event: IdeEvent| e.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
plugins.run_command("execution", "target", None, invocation()).unwrap();
let id = plugins.run_targets(&root)[0].id.clone();
let launch: ide_dap::LaunchConfig=serde_json::from_value(json!({"name":"Fake target","adapter":"debugpy","request":"launch","program":root.join("main.py"),"args":[],"cwd":root,"env":{},"extraJson":json!({"__runTarget":id}).to_string()})).unwrap();
host.debugger
.set_breakpoints(
root.join("main.py"),
vec![ide_dap::Breakpoint {
line: 2,
enabled: true,
condition: None,
hit_condition: None,
log_message: None,
}],
)
.await
.unwrap();
let session = ide_host::ops::debug::debug_start(&host, "main", launch.clone()).await.unwrap();
let frames = host.debugger.stack_trace(session, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
host.debugger.stop(session).await.unwrap();
// External debug sources use the container reader even with no LSP running.
let library = base.join("library.py");
std::fs::write(&library, "container library").unwrap();
let uri = format!(
"openidea-target://{}/0/{}",
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC),
library.to_string_lossy().trim_start_matches('/')
);
let doc = ide_host::ops::doc::doc_open_virtual(&host, "main", uri).await.unwrap();
assert!(doc.read_only);
assert_eq!(doc.text, "container library");
std::fs::write(&wrapper, "#!/bin/sh\nexit 99\n").unwrap();
let error = ide_host::ops::debug::debug_start(&host, "main", launch).await.unwrap_err();
assert!(error.message.contains("changed") || error.message.contains("approved"), "{error:?}");
host.close_project("main");
}
-224
View File
@@ -1,224 +0,0 @@
//! Real stdio LSP through a registered plugin wrapper, without a container daemon.
#![cfg(unix)]
use ide_core::{DocId, IdeEvent, LanguageId, Pos};
use ide_host::{
Host, HostConfig,
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
};
use ide_language::{LanguageConfig, LanguageRegistry};
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde_json::json;
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc, time::Duration};
const SERVER: &str = r#"import sys,json,os
root = 'file:///container' if os.environ.get('FAKE_IN_TARGET') else sys.argv[3]
def send(v):
body=json.dumps(v).encode(); sys.stdout.buffer.write(('Content-Length: %d\r\n\r\n'%len(body)).encode()+body); sys.stdout.buffer.flush()
while True:
headers={}
while True:
line=sys.stdin.buffer.readline()
if not line: sys.exit(0)
if line in (b'\r\n',b'\n'): break
k,v=line.decode().split(':',1); headers[k.lower()]=v.strip()
msg=json.loads(sys.stdin.buffer.read(int(headers['content-length'])))
method=msg.get('method'); params=msg.get('params',{})
if method=='initialize':
assert params['rootUri']==root,params
assert params['rootPath']==root.removeprefix('file://'),params
assert params['workspaceFolders'][0]['uri']==root,params
open(sys.argv[1],'a').write(json.dumps(params)+'\n')
result={'capabilities':{'textDocumentSync':1,'definitionProvider':True,'documentLinkProvider':True}}
elif method=='textDocument/didOpen':
if params['textDocument']['uri'] != root+'/main.mock': continue
send({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':root+'/main.mock','diagnostics':[{'range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}},'message':'mapped diagnostic','severity':2}]}})
continue
elif method=='textDocument/documentLink':
result=[{'target':sys.argv[2]+'#L2','range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}}}]
elif method=='textDocument/definition':
assert params['textDocument']['uri']==root+'/main.mock',params
r={'start':{'line':0,'character':0},'end':{'line':0,'character':1}}
result=[{'uri':root+'/definition.mock','range':r},{'uri':sys.argv[2],'range':r}]
elif method=='shutdown': result=None
elif method=='exit': sys.exit(0)
else:
if 'id' not in msg: continue
result=None
send({'jsonrpc':'2.0','id':msg['id'],'result':result})
"#;
#[tokio::test(flavor = "multi_thread")]
async fn initialize_definition_diagnostics_external_read_only_and_withdrawal() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let source = root.join("main.mock");
std::fs::write(&source, "x").unwrap();
std::fs::write(root.join("definition.mock"), "x").unwrap();
let library = base.join("library with space.mock");
std::fs::write(&library, "read only library").unwrap();
let log = base.join("initialize.json");
let reads = base.join("reads");
let server = base.join("server.py");
std::fs::write(&server, SERVER).unwrap();
let wrapper = base.join("wrapper");
std::fs::write(
&wrapper,
format!(
r#"#!/bin/sh
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
shift 2
if [ "$1" = cat ]; then printf '%s' "$3" > '{}'; fi
export FAKE_IN_TARGET=1
exec "$@"
"#,
reads.display()
),
)
.unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut language: LanguageConfig = toml::from_str(
"id='mock'\nname='Mock'\nextensions=['mock']\n[[language-servers]]\nname='mock-ls'\ncommand='python3'",
)
.unwrap();
language.language_servers[0].args = vec![
server.to_string_lossy().into(),
log.to_string_lossy().into(),
ide_lsp::path_to_uri_string(&library),
ide_lsp::path_to_uri_string(&root),
];
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(LanguageRegistry::from_configs(vec![language])),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let e = sink_events.clone();
Arc::new(move |event: IdeEvent| e.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
plugins.run_command("execution", "target", None, invocation()).unwrap();
let id = plugins.run_targets(&root)[0].id.clone();
let project = host.project("main").unwrap();
project
.settings
.set(
ide_workspace::SettingsScope::Project,
"languageServerTargets",
json!({"mock":{"target":id,"server":"mock-ls"}}),
)
.unwrap();
let doc = DocId::next();
project.lsp.did_open(doc, &source, &LanguageId::new("mock"), 1, "x");
// Links have no previously recorded location origin. Their URI must identify
// the right server by itself, including when its fragment is stripped.
let links = project.lsp.document_links(doc).await.unwrap();
let link = &links[0];
assert_eq!(link.fragment.as_deref(), Some("L2"));
assert!(link.url.is_none());
let linked =
ide_host::ops::doc::doc_open_virtual(&host, "main", link.path.as_ref().unwrap().to_string_lossy().into_owned())
.await
.unwrap();
assert!(linked.read_only);
let locations = tokio::time::timeout(
Duration::from_secs(15),
project.lsp.goto(doc, Pos::new(0, 0), ide_lsp::types::GotoKind::Definition),
)
.await
.unwrap()
.unwrap();
assert_eq!(locations[0].path.to_path_buf(), root.join("definition.mock"));
assert!(log.exists(), "initialize reached the fake server with the mapped root");
for _ in 0..200 {
if events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))) { break; }
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))));
let uri = locations[1].uri.clone().expect("external paths are virtual documents");
assert!(uri.starts_with("openidea-target:"));
let opened = ide_host::ops::doc::doc_open_virtual(&host, "main", uri.clone()).await.unwrap();
assert!(opened.read_only);
assert_eq!(project.documents.text_of(std::path::Path::new(&uri)).unwrap(), "read only library");
assert_eq!(std::fs::read_to_string(&reads).unwrap(), library.to_string_lossy());
assert!(
project
.documents
.apply_edits(opened.id, &[ide_core::TextEdit { range: Default::default(), new_text: "bad".into() }])
.is_err()
);
for operation in [
ide_core::FileEdit::Create { path: uri.clone().into(), overwrite: true },
ide_core::FileEdit::Delete { path: uri.clone().into(), recursive: false },
ide_core::FileEdit::Rename { from: uri.clone().into(), to: root.join("copy.mock").into() },
ide_core::FileEdit::Edit { path: uri.clone().into(), version: None, edits: Vec::new() },
] {
let mut edit = ide_core::WorkspaceEdit { files: vec![operation], ..Default::default() };
assert!(project.documents.prepare_workspace_edit(&mut edit).is_err());
}
// A library tab must not prevent ordinary project refactorings.
project
.documents
.apply_workspace_edit(&ide_core::WorkspaceEdit {
files: vec![ide_core::FileEdit::Create { path: root.join("created.mock").into(), overwrite: false }],
..Default::default()
})
.unwrap();
assert!(root.join("created.mock").exists());
// Changing the project setting restarts on the host, then back inside the target.
let selection = json!({"mock":{"target":id,"server":"mock-ls"}});
for value in [json!({}), selection.clone()] {
project.settings.set(ide_workspace::SettingsScope::Project, "languageServerTargets", value.clone()).unwrap();
project.lsp.reconfigure().await;
let expected = if value == json!({}) { ide_lsp::path_to_uri_string(&root) } else { "file:///container".into() };
wait_root(&log, &expected).await;
}
// Withdrawal invalidates reads, even when contents were cached earlier.
plugins.run_command("execution", "untarget", None, invocation()).unwrap();
assert!(project.lsp.virtual_document(&uri).await.is_err());
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
plugins.run_command("execution", "target", None, invocation()).unwrap();
wait_root(&log, "file:///container").await;
plugins.set_enabled("execution", false).unwrap();
assert!(project.lsp.virtual_document(&uri).await.is_err());
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
host.shutdown().await;
plugins.shutdown();
}
async fn wait_root(log: &std::path::Path, expected: &str) {
for _ in 0..500 {
let text = std::fs::read_to_string(log).unwrap_or_default();
if text
.lines()
.last()
.and_then(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.is_some_and(|v| v["rootUri"] == expected)
{
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("server did not initialize with {expected}: {}", std::fs::read_to_string(log).unwrap_or_default());
}
+1 -35
View File
@@ -38,17 +38,13 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"app_cli_uninstall",
"app_log_location",
"app_check_update",
"app_update_window_ready",
"app_update_status",
"app_update_announcement",
"app_startup_update_check",
"app_install_update",
"caches_schedule_invalidation",
"clear_caches",
"clipboard_file_list",
"clipboard_has_files",
"clipboard_image",
"clipboard_set_file_list",
"clipboard_write_text",
"jdk_download_vendors",
"jdk_download_releases",
"jdk_download_location",
@@ -452,33 +448,3 @@ async fn revocation_still_applies_when_a_project_folder_was_removed() {
assert!(!project.is_trusted());
host.shutdown().await;
}
#[test]
fn attached_member_trust_is_required_after_restart_and_revocation() {
let tmp = tempfile::tempdir().unwrap();
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("primary");
let other = base.join("other");
for path in [&root, &other] {
std::fs::create_dir(path).unwrap();
}
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
let trust = TrustStore::load(&config, &data);
trust.decide(&root, true, false).unwrap();
trust.inherit(&other).unwrap();
assert!(trust.window_trusted(&root).unwrap());
let trust = TrustStore::load(&config, &data);
assert!(trust.status(&root).unwrap().trusted);
assert!(!trust.window_trusted(&root).unwrap());
assert!(!trust.status(&other).unwrap().decided);
trust.decide(&root, true, false).unwrap();
assert!(!trust.window_trusted(&root).unwrap(), "trusting only the root does not recover the window");
trust.decide(&other, true, false).unwrap();
assert!(trust.window_trusted(&root).unwrap());
trust.decide(&other, false, false).unwrap();
assert!(trust.status(&other).unwrap().decided);
assert!(!trust.window_trusted(&root).unwrap());
trust.decide(&other, true, false).unwrap();
assert!(trust.window_trusted(&root).unwrap());
}
-2
View File
@@ -197,8 +197,6 @@ async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
let action = |edit: bool| CodeAction {
diagnostics: Vec::new(),
original_diagnostics: None,
title: "Organize Imports".into(),
kind: Some("source.organizeImports".into()),
is_preferred: false,
+14 -134
View File
@@ -32,15 +32,11 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
});
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
host.trust.decide(&root, true, false).unwrap();
for member in &roots {
host.trust.decide(member, true, false).unwrap();
}
let project = host.open_project("main", &root).unwrap();
assert_eq!(ops::project::info(&project).name, "Full stack");
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path());
ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
assert_eq!(project.content_roots(), roots);
assert!(!root.join(".openidea/.gitignore").exists());
assert!(project.index.wait_idle(Duration::from_secs(10)));
assert!(project.index.find_files("internal-needle", 100).is_empty());
assert!(project.index.find_files("workspace.json", 100).is_empty());
@@ -102,6 +98,20 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
std::fs::write(invalid.join(ide_workspace::workspace::FILE), "broken").unwrap();
assert!(host.open_project("main", &invalid).is_err());
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
host.open_project("main", &roots[0]).unwrap();
let automatic = ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
assert_eq!(host.project("main").unwrap().root, roots[0]);
let automatic = host.open_project("main", &automatic).unwrap();
assert_eq!(automatic.content_roots(), roots);
assert_eq!(ops::project::info(&automatic).name, "frontend");
assert!(automatic.root.starts_with(base.join("data/workspaces")));
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
assert!(automatic.index.find_files("workspace.json", 100).is_empty());
let updated = ops::project::save_workspace(&host, "main", "frontend", vec![roots[1].clone()]).unwrap();
assert_eq!(updated.workspace.unwrap().projects, vec![roots[1].clone()]);
assert_eq!(automatic.root, updated.root.as_ref());
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
@@ -196,133 +206,3 @@ async fn build_tool_availability_follows_workspace_members_and_trust() {
assert_eq!(model.gradle.len(), 1);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn attach_and_detach_preserve_the_primary_project_session() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let roots = vec![base.join("primary"), base.join("other")];
for root in &roots {
std::fs::create_dir(root).unwrap();
std::fs::write(root.join("same.txt"), "needle").unwrap();
std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"echo test"}}"#).unwrap();
}
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&roots[0], true, false).unwrap();
let project = host.open_project("main", &roots[0]).unwrap();
assert!(project.index.wait_idle(Duration::from_secs(10)));
let configs = ops::run::configs(&host, "main").unwrap();
assert!(!configs.is_empty());
let cache = project.build_cache_dir();
let doc = ops::doc::open(&host, "main", roots[0].join("same.txt")).await.unwrap();
ops::doc::edit(
&host,
"main",
doc.id,
doc.version,
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "unsaved ".into() }] },
)
.unwrap();
assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err());
host.trust.decide(&roots[1], false, false).unwrap();
assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err());
assert!(!roots[0].join(ide_workspace::workspace::FILE).exists());
let existing_index = &project.index as *const _;
host.trust.decide(&roots[1], true, false).unwrap();
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), roots[0]);
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
assert_eq!(&host.project("main").unwrap().index as *const _, existing_index);
assert_eq!(project.root, roots[0]);
assert_eq!(project.build_cache_dir(), cache);
assert!(!base.join("data/workspaces").exists());
assert_eq!(Workspace::load(&roots[0]).unwrap().unwrap().projects, roots);
assert!(
std::fs::read_to_string(roots[0].join(".openidea/.gitignore")).unwrap().lines().any(|l| l == "/workspace.json")
);
assert_eq!(project.content_roots(), vec![roots[1].clone()]);
let attached = ops::project::info(&project);
assert_eq!(attached.name, "primary");
assert_eq!(attached.display_name, "primary, other");
ops::project::set_settings(
&host,
"main",
ide_workspace::SettingsScope::Project,
"general.projectName",
&serde_json::to_string(&attached.name).unwrap(),
)
.unwrap();
assert_eq!(ops::project::info(&project).display_name, "primary, other");
let restricted = base.join("restricted");
std::fs::create_dir(&restricted).unwrap();
host.trust.decide(&restricted, false, false).unwrap();
assert!(
ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone(), roots[1].clone(), restricted])
.is_err()
);
let all = ops::run::configs(&host, "main").unwrap();
assert_eq!(all.len(), configs.len() * 2);
for config in &configs {
assert_eq!(all.iter().filter(|c| c.id == config.id).count(), 1);
}
assert!(project.index.wait_idle(Duration::from_secs(10)));
assert_eq!(project.index.find_files("same.txt", 100).len(), 2);
let hits = Arc::new(Mutex::new(Vec::new()));
let out = hits.clone();
let query = serde_json::from_value(serde_json::json!({"pattern":"needle", "isRegex":false,"caseSensitive":false,"wholeWord":false,"include":[],"exclude":[],"scope":{"kind":"project"},"maxMatches":100})).unwrap();
ops::search::start(
&host,
"main",
RequestId::next(),
query,
Arc::new(move |event| {
if let ops::search::SearchEvent::File { file } = event {
out.lock().unwrap().push(file);
}
}),
)
.await
.unwrap();
assert_eq!(hits.lock().unwrap().len(), 2);
assert!(ops::project::save_workspace(&host, "main", "Primary", vec![roots[1].clone()]).is_err());
let updated = ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone()]).unwrap();
assert!(updated.workspace.is_none());
assert_eq!(updated.name, "primary");
assert_eq!(updated.root.as_ref(), roots[0]);
assert!(!roots[0].join(ide_workspace::workspace::FILE).exists());
assert!(project.content_roots().is_empty());
assert_eq!(
ops::run::configs(&host, "main").unwrap().iter().map(|c| &c.id).collect::<Vec<_>>(),
configs.iter().map(|c| &c.id).collect::<Vec<_>>()
);
assert!(project.documents.text_of(&roots[0].join("same.txt")).unwrap().starts_with("unsaved"));
assert!(project.index.wait_idle(Duration::from_secs(10)));
assert_eq!(project.index.find_files("same.txt", 100).len(), 1);
let nested = roots[0].join("nested");
std::fs::create_dir(&nested).unwrap();
std::fs::write(nested.join("same.txt"), "needle").unwrap();
host.trust.decide(&nested, true, false).unwrap();
Workspace::attach(&nested, "Nested", vec![nested.clone()]).unwrap();
ops::project::attach_workspace(&host, "main", &nested).unwrap();
assert!(project.content_roots().is_empty());
let container = base.join("container");
Workspace::save(&container, "Container", vec![], true).unwrap();
host.trust.decide(&container, true, false).unwrap();
assert!(ops::project::attach_workspace(&host, "main", &container).is_err());
host.close_project("main");
let reopened = host.open_project("main", &roots[0]).unwrap();
assert_eq!(ops::project::info(&reopened).name, "primary");
assert_eq!(ops::project::info(&reopened).display_name, "primary, nested");
host.set_project_trust(&nested, false, false).await.unwrap();
assert!(!reopened.is_trusted());
host.close_project("main");
}
-4
View File
@@ -138,10 +138,6 @@ impl PathFilter {
&self.config.0 == extra_roots && self.config.1 == excludes
}
pub(crate) fn reload(&self) -> Self {
Self::new(&self.root, &self.config.0, &self.config.1)
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
-31
View File
@@ -234,15 +234,6 @@ impl ProjectIndex {
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// All indexed files with this exact basename, for resolving printed source locations.
/// Unlike interactive fuzzy search, this must not truncate candidates before package matching.
pub fn files_named(&self, name: &str) -> Vec<PathBuf> {
let data = self.shared.data.read();
let mut files: Vec<_> = data.files.iter().flatten().filter(|file| file.name() == name).collect();
files.sort_by(|a, b| a.rel.len().cmp(&b.rel.len()).then_with(|| a.rel.cmp(&b.rel)));
files.into_iter().map(|file| file.path.clone()).collect()
}
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
/// indexed yet (first scan without a cache).
@@ -332,28 +323,6 @@ impl ProjectIndex {
}
}
/// Refreshes ignore matchers and indexed entries in the affected subtrees.
pub fn ignore_rules_changed(&self, dirs: &[PathBuf]) {
let filter = self.shared.filter();
let mut affected = Vec::new();
for dir in dirs {
let path = if dir.is_absolute() { dir.clone() } else { self.root.join(dir) };
let path = dunce::canonicalize(&path).unwrap_or(path);
for root in filter.roots() {
if path.starts_with(root) {
affected.push(path.clone());
break;
}
if root.starts_with(&path) {
affected.push(root.to_path_buf());
}
}
}
if !affected.is_empty() {
self.shared.enqueue(Job::IgnoreRules(affected));
}
}
/// Boost for "recent files" ranking.
/// Records a recently opened file; a virtual document's URI (library
/// class) is kept verbatim, like IDEA lists library classes in Recent Files.
-84
View File
@@ -57,27 +57,6 @@ fn files(idx: &ProjectIndex, q: &str) -> Vec<String> {
idx.find_files(q, 20).into_iter().map(|m| m.rel_path).collect()
}
#[test]
fn exact_file_names_keep_all_source_candidates() {
let f = Fixture::new(&[
("src/OtherOrders.kt", "package other"),
("src/Orders.kt.bak", "backup"),
("target/Orders.kt", "excluded"),
("src/orders.kt", "different case"),
]);
for i in 0..65 {
f.write(&format!("module{i}/Orders.kt"), &format!("package module{i}"));
}
let source = f.write("service/src/Orders.kt", "package com.example.orders");
let idx = f.open();
let candidates = idx.files_named("Orders.kt");
assert_eq!(candidates.len(), 66);
assert!(candidates.contains(&source));
assert!(candidates.iter().all(|path| path.file_name().unwrap() == "Orders.kt"));
assert_eq!(candidates, idx.files_named("Orders.kt"));
assert!(idx.files_named("Missing.kt").is_empty());
}
fn symbols(idx: &ProjectIndex, q: &str, filter: SymbolFilter) -> Vec<String> {
idx.find_symbols(q, filter, 20).into_iter().map(|m| m.name).collect()
}
@@ -750,66 +729,3 @@ fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
assert!(symbols(&idx, "old_symbol", SymbolFilter::All).is_empty());
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
#[test]
fn rule_changes_refresh_only_the_affected_index_subtree() {
for (rule, subtree) in [(".git/info/exclude", ""), ("sub/.gitignore", "sub"), ("sub/.ignore", "sub")] {
let fx = Fixture::new(&[
(".git/HEAD", "ref: refs/heads/main\n"),
("sub/generated/file.ts", "export function generated() {}\n"),
("sub/local.ts", "export function localOnly() {}\n"),
("other/keep.ts", "export function keep() {}\n"),
]);
fx.write(rule, "");
let idx = fx.open();
assert_eq!(files(&idx, "file.ts"), ["sub/generated/file.ts"]);
fx.write(rule, "generated/\nlocal.ts\n");
let dirs = [fx.root.join(subtree)];
idx.ignore_rules_changed(&dirs);
assert!(idx.wait_idle(WAIT));
assert!(files(&idx, "file.ts").is_empty(), "{rule}: ignored directory remained indexed");
assert_eq!(files(&idx, "local.ts"), ["sub/local.ts"]);
assert!(symbols(&idx, "localOnly", SymbolFilter::All).is_empty());
assert_eq!(files(&idx, "keep.ts"), ["other/keep.ts"]);
fx.write(rule, "");
idx.ignore_rules_changed(&dirs);
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "file.ts"), ["sub/generated/file.ts"]);
assert_eq!(symbols(&idx, "localOnly", SymbolFilter::All), ["localOnly"]);
// The cached root matcher must also follow the rules for later watcher updates.
let fresh = fx.write("sub/generated/new.ts", "export function fresh() {}\n");
idx.update(&[fresh], &[]);
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "new.ts"), ["sub/generated/new.ts"]);
}
}
#[test]
fn repository_rule_changes_refresh_members_of_an_unindexed_container() {
let fx = Fixture::new(&[
(".git/HEAD", "ref: refs/heads/main\n"),
(".git/info/exclude", ""),
("member/generated/item.txt", "x"),
]);
let idx = ProjectIndex::open(
&fx.root,
&fx.cache,
IndexRoots { include_root: false, folders: vec![fx.root.join("member")] },
vec![],
Arc::new(LanguageRegistry::load(None).unwrap()),
Arc::new(Syntax::new()),
Arc::new(NullSink),
)
.unwrap();
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
fx.write(".git/info/exclude", "generated/\n");
idx.ignore_rules_changed(std::slice::from_ref(&fx.root));
assert!(idx.wait_idle(WAIT));
assert!(files(&idx, "item.txt").is_empty());
fx.write(".git/info/exclude", "");
idx.ignore_rules_changed(std::slice::from_ref(&fx.root));
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
}
+2 -37
View File
@@ -26,7 +26,6 @@ pub(crate) enum Job {
SaveRecent,
/// The roots or excludes changed: scan again (unchanged files are kept).
Rescan,
IgnoreRules(Vec<PathBuf>),
/// File types changed: re-parse the indexed files whose language under the
/// previous registry differs from the current one.
Reparse(std::sync::Arc<LanguageRegistry>),
@@ -213,10 +212,6 @@ impl Shared {
}
fn full_scan(&self) {
self.scan(None);
}
fn scan(&self, dirs: Option<&[PathBuf]>) {
let started = Instant::now();
self.indexing.store(true, Ordering::SeqCst);
self.emit_state();
@@ -232,11 +227,7 @@ impl Shared {
let filter = self.filter();
let mut found = Vec::new();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
for root in roots {
for root in filter.roots() {
found.extend(filter.walk(root, &self.cancelled));
}
if self.is_cancelled() {
@@ -246,12 +237,7 @@ impl Shared {
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
let d = self.data.read();
let todo = found.iter().filter(|f| d.get(&f.path).is_none_or(|e| e.stale_for(f))).collect();
let stale = d
.paths()
.filter(|p| dirs.is_none_or(|dirs| dirs.iter().any(|dir| p.starts_with(dir))))
.filter(|p| !seen.contains(p.as_path()))
.cloned()
.collect();
let stale = d.paths().filter(|p| !seen.contains(p.as_path())).cloned().collect();
(todo, stale)
};
if !stale.is_empty() {
@@ -395,7 +381,6 @@ impl Shared {
let mut removed: Vec<PathBuf> = Vec::new();
let mut save_recent = false;
let mut rescan = false;
let mut rule_dirs = Vec::new();
let mut reparse: Vec<std::sync::Arc<LanguageRegistry>> = Vec::new();
for job in jobs {
match job {
@@ -403,29 +388,9 @@ impl Shared {
Job::Removed(p) => removed.extend(p),
Job::SaveRecent => save_recent = true,
Job::Rescan => rescan = true,
Job::IgnoreRules(dirs) => rule_dirs.extend(dirs),
Job::Reparse(before) => reparse.push(before),
}
}
if !rule_dirs.is_empty() {
{
let mut filter = self.filter.write();
*filter = std::sync::Arc::new(filter.reload());
}
rule_dirs.sort();
rule_dirs.dedup();
let mut roots: Vec<PathBuf> = Vec::new();
for dir in rule_dirs {
if !roots.iter().any(|root| dir.starts_with(root)) {
roots.push(dir);
}
}
if !rescan {
self.scan(Some(&roots));
changed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
removed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
}
}
if rescan {
// The scan also picks up whatever `changed` / `removed` reported.
self.full_scan();
+6 -52
View File
@@ -248,7 +248,6 @@ struct Inner {
/// Fired by [`Peer::kill`]; stops tasks and kills the child.
kill: CancellationToken,
pid: Option<u32>,
transform: Option<Arc<dyn MessageTransform>>,
/// Fired when the reader task has stopped (EOF, error or kill).
reader_done: CancellationToken,
/// Fired once the child process has exited and been reaped (after [`Peer::kill`],
@@ -256,38 +255,11 @@ struct Inner {
exited: CancellationToken,
}
/// Translates protocol values before serialization and after parsing, including
/// responses to requests initiated by the server.
pub trait MessageTransform: Send + Sync + 'static {
fn outgoing(&self, value: &mut Value);
fn incoming(&self, value: &mut Value);
}
#[derive(Default)]
pub struct TransportOptions {
pub framing: Framing,
pub transform: Option<Arc<dyn MessageTransform>>,
}
impl From<Framing> for TransportOptions {
fn from(framing: Framing) -> Self {
Self { framing, transform: None }
}
}
impl Inner {
fn send_value(&self, value: &Value) -> Result<(), RpcError> {
if self.transport_closed.load(Ordering::Acquire) {
return Err(RpcError::Closed);
}
let mut translated;
let value = if let Some(transform) = &self.transform {
translated = value.clone();
transform.outgoing(&mut translated);
&translated
} else {
value
};
let body = serde_json::to_vec(value).map_err(|e| RpcError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_jsonrpc::wire", "--> {}", String::from_utf8_lossy(&body));
self.out.send(body).map_err(|_| RpcError::Closed)
@@ -415,19 +387,6 @@ impl Peer {
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
framing: Framing,
) -> std::io::Result<Self> {
Self::spawn_transport(command, args, cwd, env, handler, on_stderr, framing.into())
}
/// Starts a stdio peer with message translation installed before any IO.
pub fn spawn_transport(
command: &str,
args: &[String],
cwd: &std::path::Path,
env: &[(String, String)],
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
options: TransportOptions,
) -> std::io::Result<Self> {
use std::process::Stdio;
// Windows resolves a bare name only as `.exe`; find the file a child
@@ -453,7 +412,7 @@ impl Peer {
let pid = child.id();
let on_output: Arc<dyn Fn(String) + Send + Sync> = Arc::from(on_stderr);
let peer = Self::start(stdout, stdin, handler, false, pid, options, Some(on_output.clone()));
let peer = Self::start(stdout, stdin, handler, false, pid, framing, Some(on_output.clone()));
let stderr_task = stderr.map(|stderr| {
let name = command.to_owned();
@@ -507,7 +466,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, Framing::ContentLength.into(), None)
Self::start(reader, writer, handler, true, None, Framing::ContentLength, None)
}
/// [`connect`](Self::connect) with the given wire framing.
@@ -516,7 +475,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, framing.into(), None)
Self::start(reader, writer, handler, true, None, framing, None)
}
/// [`Peer::connect`] over streams served by `child` (a server reached over a socket
@@ -533,7 +492,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength.into(), None);
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength, None);
peer.supervise(child, None);
peer
}
@@ -544,14 +503,13 @@ impl Peer {
handler: Arc<dyn Handler>,
close_on_eof: bool,
pid: Option<u32>,
options: TransportOptions,
framing: Framing,
on_noise: Option<Arc<dyn Fn(String) + Send + Sync>>,
) -> Self
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let TransportOptions { framing, transform } = options;
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (closed, _) = watch::channel(None);
let inner = Arc::new(Inner {
@@ -562,7 +520,6 @@ impl Peer {
closed,
kill: CancellationToken::new(),
pid,
transform,
reader_done: CancellationToken::new(),
exited: CancellationToken::new(),
});
@@ -828,16 +785,13 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("jsonrpc: unparsable message: {e}");
return;
}
};
if let Some(transform) = &inner.transform {
transform.incoming(&mut msg);
}
let Value::Object(mut obj) = msg else {
tracing::warn!("jsonrpc: ignoring non-object message");
return;
+2 -33
View File
@@ -14,14 +14,14 @@ mod markers;
pub mod plugin_point;
mod registry;
mod syntax;
pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root};
pub use markers::marked_root;
pub use plugin_point::PluginLanguages;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
WebReferenceKind, merge_visibility, set_external_syntax, shift_pos, shift_range,
WebReferenceKind, merge_visibility, set_external_syntax,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
@@ -176,30 +176,11 @@ pub struct LanguageServerConfig {
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
/// Host-only transport configuration; never accepted from language definitions.
#[serde(skip)]
pub target: Option<ServerTarget>,
/// Separates instances when languages sharing a server select different targets.
#[serde(skip)]
pub target_key: Option<String>,
}
/// A prepared plugin wrapper. The LSP client never resolves container binaries on the host.
#[derive(Clone, Debug, PartialEq)]
pub struct ServerTarget {
pub id: String,
pub origin: Option<ide_core::ServerId>,
pub project_root: PathBuf,
pub container_root: String,
pub mapping: ide_core::path_mapping::PathMapping,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct DebuggerConfig {
/// Placement in plugin run targets; absent means unsupported.
#[serde(default)]
pub target_placement: Option<String>,
/// DAP adapter name, e.g. `codelldb`, `debugpy`, `js-debug`, `delve`.
pub adapter: String,
pub command: String,
@@ -332,15 +313,3 @@ pub struct SymbolTag {
pub container: Option<String>,
pub range: Range,
}
/// One inline TS/JS `<script>` block of a Vue SFC / HTML document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScriptRegion {
/// Document-absolute UTF-8 byte offset of `text`, for tree-sitter parsing.
/// Convert to UTF-16 positions before exposing ranges through IPC.
pub base: usize,
/// The language the block's `lang` attribute picks.
pub language: LanguageId,
/// The script text.
pub text: String,
}
-21
View File
@@ -19,27 +19,6 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String])
best
}
/// `dir` contains at least one `.ino` regular file.
pub fn is_arduino_sketch_dir(dir: &Path) -> bool {
std::fs::read_dir(dir).is_ok_and(|entries| {
entries.flatten().any(|entry| {
entry.path().extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ino"))
&& entry.file_type().is_ok_and(|kind| kind.is_file())
})
})
}
/// Nearest enclosing directory of `path` (including `path` when it is already a
/// directory) within `project` that contains a `.ino` file.
pub fn arduino_sketch_root(project: &Path, path: &Path) -> Option<PathBuf> {
let start = usize::from(!path.is_dir());
path.ancestors()
.skip(start)
.take_while(|dir| dir.starts_with(project))
.find(|dir| is_arduino_sketch_dir(dir))
.map(Path::to_path_buf)
}
/// `dir` contains `marker`: a relative path, a file name pattern with one
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
/// must contain (`pyproject.toml#tool.ruff`).
+2 -10
View File
@@ -70,7 +70,6 @@ const BUILTIN: &[(&str, &str)] = &[
("proto", include_str!("../../../languages/proto.toml")),
("python", include_str!("../../../languages/python.toml")),
("r", include_str!("../../../languages/r.toml")),
("restructuredtext", include_str!("../../../languages/restructuredtext.toml")),
("ruby", include_str!("../../../languages/ruby.toml")),
("rust", include_str!("../../../languages/rust.toml")),
("sass", include_str!("../../../languages/sass.toml")),
@@ -563,7 +562,6 @@ mod tests {
"R",
"RPM Changes",
"RPM Spec",
"reStructuredText",
"Ruby",
"SAS",
"Scala",
@@ -630,12 +628,6 @@ mod tests {
let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap();
assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc"));
assert_eq!(asciidoc.line_comment.as_deref(), Some("//"));
for path in ["guide.rst", "README.RST"] {
assert_eq!(detect(&reg, path), "restructuredtext");
}
let rst = reg.get(&LanguageId::new("restructuredtext")).unwrap();
assert_eq!(rst.ui_mode.as_deref(), Some("reStructuredText"));
assert_eq!(rst.line_comment.as_deref(), Some(".."));
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
assert_eq!(detect(&reg, "README"), "plaintext");
assert_eq!(detect(&reg, "unknown.zzz"), "plaintext");
@@ -1007,11 +999,11 @@ mod tests {
}
#[test]
fn astro_server_leaves_typescript_sdk_resolution_to_the_launcher() {
fn astro_server_reads_the_project_typescript() {
let reg = reg();
let ls = &reg.get(&LanguageId::new("astro")).unwrap().language_servers[0];
let tsdk = ls.initialization_options.as_ref().and_then(|o| o.get("typescript")?.get("tsdk")?.as_str());
assert_eq!(tsdk, None);
assert_eq!(tsdk, Some("node_modules/typescript/lib"));
}
#[test]
+3 -44
View File
@@ -195,18 +195,9 @@ impl SyntaxCache {
|| {
self.with_tree(doc, lang, version, text, |g, tree, text| {
let query = g.outline_query()?;
// HTML documents answer from the cached tree, with nested
// <script> declarations.
Some(outline::outline_nested(g, query, tree, text, &|rl, rt| self.syntax.outline(rl, rt)))
Some(outline::outline(g, query, tree, text))
})
// Web containers (Vue SFC / Svelte / JSP) have no grammar of their
// own, hence no cached tree: the syntax layer answers with a fresh
// HTML parse, which the memo covers per version.
// shortcut: no incremental re-parse for containers, upgrade when
// their trees join the cache.
.or_else(|| self.syntax.web_outline(lang, text))
.or_else(|| super::external::outline(lang, text))
.or_else(|| self.syntax.builtin_outline(lang, text))
},
)
}
@@ -265,11 +256,8 @@ impl SyntaxCache {
let Some(result) = compute() else { return Vec::new() };
if let Some(e) = &entry {
let mut e = e.lock();
// Containers without their own grammar memoize their web outline too.
// Never store an answer after the document moves on.
if e.version == version
&& (e.tree.is_some() || (e.lang == *lang && self.syntax.web_grammar_name(lang) == Some("html")))
{
// Not if the document moved on (or its tree was dropped) meanwhile.
if e.version == version && e.tree.is_some() {
*slot(e.memo.at(version, lang)) = Some(Arc::new(result.clone()));
}
}
@@ -486,35 +474,6 @@ mod tests {
assert!(cache.outline(id, &LanguageId::new("plaintext"), 1, &text).is_empty());
}
#[test]
fn web_outline_memo_tracks_versions_without_a_cached_tree() {
for language in ["html", "vue", "svelte", "jsp"] {
let cache = cache();
let id = DocId::next();
let lang = LanguageId::new(language);
let mut doc = Document::new("<script setup lang=ts>const first = 1;</script>");
cache.open(id, &lang, 0);
let first = cache.outline(id, &lang, 0, &doc.text());
let hits = cache.stats().hits;
assert_eq!(cache.outline(id, &lang, 0, &doc.text()), first);
assert_eq!(cache.stats().hits, hits + 1, "{language}: memo hit");
let edits = edit(&mut doc, 0, 0, "<script>const second = 2;</script>\n");
cache.edit(id, doc.version(), &edits);
let second = cache.outline(id, &lang, doc.version(), &doc.text());
assert_ne!(second, first);
assert_eq!(second, cache.syntax().outline(&lang, &doc.text()));
// An older query must not replace the current version's memo.
cache.outline(id, &lang, 0, "<script>const old = 0;</script>");
let hits = cache.stats().hits;
assert_eq!(cache.outline(id, &lang, doc.version(), &doc.text()), second);
assert_eq!(cache.stats().hits, hits + 1);
cache.clear();
assert!(cache.entries.lock().get(&id).unwrap().lock().memo.outline.is_none());
cache.close(id);
}
}
#[test]
fn version_mismatch_and_lifecycle() {
let cache = cache();
-25
View File
@@ -96,19 +96,6 @@ pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
/// Shifts a position from a document suffix that starts at `base` into document
/// coordinates: the suffix's first line continues `base`'s line, later lines
/// add the base line. Used to map inline `<script>` region answers (Vue SFC /
/// HTML) back onto the whole document; positions are 0-based lines / UTF-16 cols.
pub fn shift_pos(pos: Pos, base: Pos) -> Pos {
if pos.line == 0 { Pos::new(base.line, base.col + pos.col) } else { Pos::new(base.line + pos.line, pos.col) }
}
/// [`shift_pos`] for both ends of a range.
pub fn shift_range(range: Range, base: Pos) -> Range {
Range::new(shift_pos(range.start, base), shift_pos(range.end, base))
}
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
let mut units = 0;
for (i, c) in s.char_indices() {
@@ -173,18 +160,6 @@ mod tests {
}
}
#[test]
fn shifts_suffix_positions_into_document_coordinates() {
let base = Pos::new(3, 7);
assert_eq!(shift_pos(Pos::new(0, 2), base), Pos::new(3, 9));
assert_eq!(shift_pos(Pos::new(0, 0), base), base);
assert_eq!(shift_pos(Pos::new(2, 1), base), Pos::new(5, 1));
assert_eq!(
shift_range(Range::new(Pos::new(0, 1), Pos::new(1, 4)), base),
Range::new(Pos::new(3, 8), Pos::new(4, 4))
);
}
#[test]
fn long_minified_line_is_linear() {
// 200k symbols-worth of positions on one 4 MB line.
+1 -50
View File
@@ -9,19 +9,16 @@ mod highlight;
pub(crate) mod lines;
mod literals;
mod outline;
mod rst;
mod selection;
mod sfc;
mod web;
use crate::{FoldRange, LanguageConfig, LanguageRegistry, ScriptRegion, SymbolTag};
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
pub use configuration::ConfigurationKey;
pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, set_external_syntax};
use grammars::{SPECS, Spec};
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
use ide_core::{LanguageId, OutlineItem, Pos, Range};
pub use lines::{shift_pos, shift_range};
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
pub use outline::merge_visibility;
use std::cell::RefCell;
@@ -168,39 +165,13 @@ impl Syntax {
}
pub fn outline(&self, lang: &LanguageId, text: &str) -> Vec<OutlineItem> {
// HTML containers (Vue SFC / Svelte / JSP have no grammar of their own;
// HTML itself parses alike): HTML outline with each inline `<script>`'s
// TS/JS declarations nested under its entry, in document coordinates.
if let Some(items) = self.web_outline(lang, text) {
return items;
}
self.analyze(lang, text, |g, tree, text| {
let query = g.outline_query()?;
Some(outline::outline(g, query, tree, text))
})
.or_else(|| self.builtin_outline(lang, text))
.unwrap_or_default()
}
/// Outline for languages without a grammar that have a built-in parser.
fn builtin_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
(lang.as_str() == "restructuredtext" && text.len() <= MAX_PARSE_BYTES).then(|| rst::outline(text))
}
/// Outline of an HTML container document (html, vue, svelte, jsp): `None`
/// for every other language, CSS containers included.
pub(crate) fn web_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
let g = self.web_grammar(lang)?;
if g.spec.name != "html" || text.len() > MAX_PARSE_BYTES {
return None;
}
let tree = parse(&g.language, text, None)?;
let query = g.outline_query()?;
Some(outline::outline_nested(g, query, &tree, text, &|region_lang, region_text| {
self.outline(region_lang, region_text)
}))
}
pub fn folding(&self, lang: &LanguageId, text: &str) -> Vec<FoldRange> {
let tokens = self.comment_tokens(lang);
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text, tokens)))
@@ -277,19 +248,6 @@ impl Syntax {
self.with_web_tree(lang, text, |_, _, tree| web::anchors(tree, text, name)).unwrap_or_default()
}
/// Inline TS/JS `<script>` regions of a Vue SFC / HTML document, in
/// document order. Empty for other languages or non-JavaScript blocks.
pub fn script_regions(&self, lang: &LanguageId, text: &str) -> Vec<ScriptRegion> {
self.with_web_tree(lang, text, |_g, _css, tree| sfc::script_regions(tree, text)).unwrap_or_default()
}
/// [`Self::script_regions`] of an already parsed document (`ParsedTree`
/// lends the tree and text) — the cache-aware way for other crates, which
/// reuses a cached incremental HTML tree instead of parsing again.
pub fn script_regions_of(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
sfc::script_regions(tree, text)
}
/// Highlighting from the grammar's own query; `None` without one.
pub fn highlights(&self, lang: &LanguageId, text: &str) -> Option<SyntaxHighlights> {
self.analyze(lang, text, |g, tree, text| {
@@ -322,13 +280,6 @@ impl Syntax {
self.grammar(lang).map(|g| g.spec.name)
}
/// Name of the grammar web features parse `lang` with (html for Vue SFC /
/// Svelte / JSP, css for SCSS / Less); `None` when `lang` has its own
/// grammar or no web features. Lets other crates recognize containers.
pub fn web_grammar_name(&self, lang: &LanguageId) -> Option<&'static str> {
self.web_grammar(lang).map(|g| g.spec.name)
}
/// Grammar name and tree-sitter language for `lang`, for engines that parse
/// on their own (structural search patterns).
pub fn ts_language(&self, lang: &LanguageId) -> Option<(&'static str, Language)> {
+2 -65
View File
@@ -13,9 +13,9 @@
//! macro event operator key heading impl extension.
use super::Grammar;
use super::lines::{LineIndex, shift_range};
use super::lines::LineIndex;
use crate::SymbolTag;
use ide_core::{LanguageId, OutlineItem, Pos, SymbolKind, Visibility};
use ide_core::{OutlineItem, SymbolKind, Visibility};
use std::collections::HashMap;
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator, Tree};
@@ -296,69 +296,6 @@ pub(crate) fn outline(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) ->
collect(g, q, tree, text).into_iter().map(convert).collect()
}
/// [`outline`], plus, for the HTML grammar, each inline `<script>` region's
/// TS/JS declarations nested under its `script` entry and shifted to document
/// coordinates (Vue SFC / HTML documents). `block` outlines one region's
/// language, answering in region-local coordinates.
pub(crate) fn outline_nested(
g: &Grammar,
q: &OutlineQuery,
tree: &Tree,
text: &str,
block: &impl Fn(&LanguageId, &str) -> Vec<OutlineItem>,
) -> Vec<OutlineItem> {
let mut items = outline(g, q, tree, text);
if g.spec.name != "html" {
return items;
}
let lines = LineIndex::new(text);
for region in super::sfc::script_regions(tree, text) {
let mut decls = block(&region.language, &region.text);
if decls.is_empty() {
continue;
}
let base = lines.pos(region.base);
shift_outline(&mut decls, base);
if !attach_script(&mut items, base, &mut Some(decls)) {
// The region and the entry come from the same `script_element` node,
// so this means the outline query no longer matches script elements.
tracing::debug!("outline: no script entry for the <script> region at byte {}", region.base);
}
}
items
}
/// Recursively shifts region-local positions in `items` to document coordinates.
fn shift_outline(items: &mut [OutlineItem], base: Pos) {
for item in items {
item.range = shift_range(item.range, base);
item.selection_range = shift_range(item.selection_range, base);
shift_outline(&mut item.children, base);
}
}
/// Nests `decls` under the innermost `script` entry covering `base`. The
/// region and the entry both come from the same `script_element` node, so the
/// entry always exists; a miss means the outline query no longer matches it.
fn attach_script(items: &mut [OutlineItem], base: Pos, decls: &mut Option<Vec<OutlineItem>>) -> bool {
let covers = |range: &ide_core::Range| {
(range.start.line, range.start.col) <= (base.line, base.col)
&& (base.line, base.col) < (range.end.line, range.end.col)
};
for item in items.iter_mut() {
if item.name.eq_ignore_ascii_case("script") && covers(&item.range) {
item.children.extend(decls.take().unwrap_or_default());
return true;
}
}
for item in items.iter_mut() {
if attach_script(&mut item.children, base, decls) {
return true;
}
}
false
}
pub(crate) fn tags(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) -> Vec<SymbolTag> {
fn walk(items: Vec<Built>, container: Option<&str>, out: &mut Vec<SymbolTag>) {
for b in items {
-131
View File
@@ -1,131 +0,0 @@
//! reStructuredText section titles for the outline. No bundled grammar nests
//! sections (tree-sitter-rst emits them flat), so levels follow docutils: each
//! adornment style (character, with or without overline) takes the next level
//! the first time it appears.
use super::lines::LineIndex;
use ide_core::{OutlineItem, SymbolKind};
struct Line<'a> {
start: usize,
text: &'a str,
}
struct Heading {
level: usize,
start: usize,
end: usize,
name_start: usize,
name_end: usize,
}
pub(crate) fn outline(text: &str) -> Vec<OutlineItem> {
let lines: Vec<Line> = text
.split_inclusive('\n')
.scan(0, |at, raw| {
let start = *at;
*at += raw.len();
Some(Line { start, text: raw.trim_end_matches(['\n', '\r']) })
})
.collect();
let mut heads = headings(&lines);
for k in 0..heads.len() {
let end = heads[k + 1..].iter().find(|h| h.level <= heads[k].level).map_or(text.len(), |h| h.start);
heads[k].end = heads[k].start + text[heads[k].start..end].trim_end().len();
}
let index = LineIndex::new(text);
let mut i = 0;
nest(&heads, &mut i, None, text, &index)
}
fn headings(lines: &[Line]) -> Vec<Heading> {
let mut styles: Vec<(char, bool)> = Vec::new();
let mut out = Vec::new();
let mut push = |style: (char, bool), start: usize, title: &Line| {
let level = styles.iter().position(|&s| s == style).unwrap_or_else(|| {
styles.push(style);
styles.len() - 1
});
let name = title.text.trim();
let name_start = title.start + (title.text.len() - title.text.trim_start().len());
out.push(Heading { level, start, end: 0, name_start, name_end: name_start + name.len() });
};
// A title starts a new block: it follows a blank line, the start, or another title.
let mut at_block_start = true;
let mut i = 0;
while i < lines.len() {
let line = &lines[i];
if line.text.trim().is_empty() {
at_block_start = true;
i += 1;
continue;
}
if at_block_start {
if let Some(over) = adornment(line.text) {
if let [title, under, ..] = &lines[i + 1..]
&& !title.text.trim().is_empty()
&& adornment(title.text).is_none()
&& adornment(under.text) == Some(over)
&& fits(title.text.trim_end(), over.1)
{
push((over.0, true), line.start, title);
i += 3;
continue;
}
} else if !line.text.starts_with([' ', '\t'])
&& !starts_body_element(line.text)
&& let Some(under) = lines.get(i + 1).and_then(|l| adornment(l.text))
&& fits(line.text.trim_end(), under.1)
{
push((under.0, false), line.start, line);
i += 2;
continue;
}
}
// Indented lines (literal blocks after `::`, directive bodies, quotes)
// never hold titles: they are skipped here because adornments and
// underlined titles must start in column 0.
at_block_start = false;
i += 1;
}
out
}
/// `(character, length)` of a line made of one repeated ASCII punctuation character.
fn adornment(line: &str) -> Option<(char, usize)> {
let line = line.trim_end();
let c = line.chars().next().filter(char::is_ascii_punctuation)?;
line.chars().all(|x| x == c).then_some((c, line.len()))
}
/// docutils accepts a short adornment (with a warning) unless it is shorter
/// than 4 characters, which reads as ordinary text instead.
// ponytail: counts chars, not display columns; wide CJK titles get a looser check.
fn fits(title: &str, adornment_len: usize) -> bool {
adornment_len >= title.chars().count() || adornment_len >= 4
}
/// Lines docutils parses as another body element before trying a title:
/// explicit markup (`..`, `__`), bullets, field lists, doctests, line blocks.
fn starts_body_element(line: &str) -> bool {
let marker = |m: &str| line == m || line.strip_prefix(m).is_some_and(|r| r.starts_with(' '));
["..", "__", "-", "*", "+", "•", ">>>", "|"].into_iter().any(marker)
|| (line.starts_with(':') && line[1..].find(':').is_some_and(|k| k > 0 && marker(&line[..k + 2])))
}
fn nest(heads: &[Heading], i: &mut usize, parent: Option<usize>, text: &str, index: &LineIndex) -> Vec<OutlineItem> {
let mut items = Vec::new();
while let Some(h) = heads.get(*i).filter(|h| parent.is_none_or(|p| h.level > p)) {
*i += 1;
items.push(OutlineItem {
name: text[h.name_start..h.name_end].to_owned(),
detail: None,
kind: SymbolKind::Key,
range: index.range(h.start, h.end),
selection_range: index.range(h.name_start, h.name_end),
children: nest(heads, i, Some(h.level), text, index),
visibility: None,
});
}
items
}
-92
View File
@@ -1,92 +0,0 @@
//! Inline TS/JS `<script>` regions located with the HTML grammar's
//! `script_element` nodes. The block's `lang` attribute selects the grammar.
//!
//! Blocks whose `type` attribute names anything but JavaScript are skipped
//! (`application/json`, `text/template`, `importmap`, …), as are unknown
//! `lang` values.
use super::web::walk;
use crate::ScriptRegion;
use ide_core::LanguageId;
use tree_sitter::{Node, Tree};
/// The TS/JS grammar a `lang` attribute value names; `None` skips the block.
fn lang_grammar(value: &str) -> Option<&'static str> {
match value {
"ts" | "typescript" => Some("typescript"),
"tsx" => Some("tsx"),
"" | "js" | "jsx" | "javascript" => Some("javascript"),
_ => None,
}
}
/// `true` when a `type` attribute value names JavaScript (or is absent).
fn is_js_type(value: &str) -> bool {
matches!(value, "" | "module" | "text/javascript" | "application/javascript")
}
/// The `<script>` regions of a document parsed with the HTML grammar,
/// in document order. `base` is each block's byte offset into the document.
pub(crate) fn script_regions(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
let mut out = Vec::new();
walk(tree.root_node(), &mut |n| {
if n.kind() != "script_element" {
return true;
}
let mut lang = "javascript";
let mut skip = false;
let mut c = n.walk();
for ch in n.children(&mut c) {
match ch.kind() {
// The start tag precedes the raw text, so the attributes are
// known before the region is taken.
"start_tag" => {
let mut c2 = ch.walk();
for attr in ch.children(&mut c2) {
if attr.kind() != "attribute" {
continue;
}
let (name, value) = attribute(attr, text);
match name.to_ascii_lowercase().as_str() {
"lang" => match lang_grammar(&value.to_ascii_lowercase()) {
Some(g) => lang = g,
None => skip = true,
},
"type" if !is_js_type(&value.trim().to_ascii_lowercase()) => skip = true,
"src" => skip = true,
_ => {}
}
}
}
"raw_text" if !skip => {
out.push(ScriptRegion {
base: ch.start_byte(),
language: LanguageId::new(lang),
text: text[ch.start_byte()..ch.end_byte()].to_string(),
});
}
_ => {}
}
}
false // the raw text is not markup
});
out
}
/// An attribute's `(name, value)`; a bare attribute (`setup`) has an empty value.
fn attribute<'t>(attr: Node<'t>, text: &'t str) -> (&'t str, &'t str) {
let mut c = attr.walk();
let mut name = "";
let mut value = "";
for ch in attr.children(&mut c) {
match ch.kind() {
"attribute_name" => name = ch.utf8_text(text.as_bytes()).unwrap_or(""),
"quoted_attribute_value" => {
value = ch.named_child(0).and_then(|v| v.utf8_text(text.as_bytes()).ok()).unwrap_or("");
}
"attribute_value" => value = ch.utf8_text(text.as_bytes()).unwrap_or(""),
_ => {}
}
}
(name, value)
}
-317
View File
@@ -759,7 +759,6 @@ fn outline_html_css_xml() {
Field body
Field div
Field script
Variable a
",
);
check_outline(
@@ -1410,319 +1409,3 @@ fn spelling_regions_cover_declared_names_only() {
.collect();
assert_eq!(names, ["Bird", "fly", "text"]);
}
// ---------------------------------------------------------------------------
// Script regions of Vue SFCs and HTML documents
#[test]
fn script_regions_skip_typed_blocks() {
let src = "<html><body><script type=\"module\">var x = 1;</script>\
<script type=\"application/json\">{\"a\":1}</script>\
<script type=\"importmap\">{}</script>\
<script>var y = 2;</script></body></html>";
let regions = syntax().script_regions(&lang("html"), src);
assert_eq!(regions.len(), 2, "module and plain scripts; json/importmap skipped");
assert_eq!(regions[0].language.as_str(), "javascript");
}
#[test]
fn script_regions_pick_the_lang_attribute() {
let src = "<script setup lang=\"tsx\">\nconst App = () => <div/>\n</script>\n";
let regions = syntax().script_regions(&lang("vue"), src);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].language.as_str(), "tsx");
assert!(regions[0].text.contains("const App"));
}
#[test]
fn script_regions_empty_for_other_languages() {
assert!(syntax().script_regions(&lang("rust"), "fn main() {}").is_empty());
assert!(syntax().script_regions(&lang("vue"), "<template><div/></template>").is_empty());
}
#[test]
fn script_regions_classify_attributes() {
for language in ["vue", "html"] {
for (attributes, expected) in [
("", Some("javascript")),
("setup lang=ts", Some("typescript")),
("lang='typescript'", Some("typescript")),
("lang=tsx", Some("tsx")),
("lang=js", Some("javascript")),
("lang=jsx", Some("javascript")),
("LANG=TS", Some("typescript")),
("type=module", Some("javascript")),
("TYPE=' text/JAVASCRIPT '", Some("javascript")),
("type=application/javascript", Some("javascript")),
("type=application/json", None),
("type='application/ld+json'", None),
("type=text/template", None),
("type=importmap", None),
("type=speculationrules", None),
("lang=coffee", None),
("lang=ts type=application/json", None),
("src='./external.ts' lang=ts", None),
("SRC=external.js", None),
("src", None),
] {
let src = format!("<script {attributes}>const value = 1;</script>");
let regions = syntax().script_regions(&lang(language), &src);
assert_eq!(regions.len(), usize::from(expected.is_some()), "{language}: {attributes}");
if let Some(expected) = expected {
assert_eq!(regions[0].language.as_str(), expected, "{attributes}");
assert_eq!(regions[0].text, "const value = 1;");
assert_eq!(&src[regions[0].base..regions[0].base + regions[0].text.len()], regions[0].text);
}
}
}
}
#[test]
fn script_regions_preserve_document_offsets_and_line_endings() {
use super::lines::LineIndex;
let src = "<template>中文😀</template>\r\n<script lang=ts>const 名 = '😀';\r\n</script>\r\n<script setup lang=jsx>const View = () => <div/>;</script>";
let regions = syntax().script_regions(&lang("vue"), src);
assert_eq!(regions.len(), 2);
let idx = LineIndex::new(src);
let doc = ide_text::Document::new(src);
for (region, (body, pos)) in
regions.iter().zip([("const 名 = '😀';\r\n", Pos::new(1, 16)), ("const View = () => <div/>;", Pos::new(3, 23))])
{
assert_eq!(region.base, src.find(body).unwrap());
assert_eq!(region.text, body);
assert_eq!(idx.pos(region.base), pos);
assert_eq!(idx.pos(region.base), doc.byte_to_pos(src[..region.base].replace("\r\n", "\n").len()));
assert_eq!(
idx.pos(region.base + body.len()),
doc.byte_to_pos(src[..region.base + body.len()].replace("\r\n", "\n").len())
);
if let Some(name) = body.find('名') {
assert_eq!(
idx.range(region.base + name, region.base + name + '名'.len_utf8()),
Range::new(Pos::new(1, 22), Pos::new(1, 23))
);
}
}
let same_line = "<template>中文😀</template><script>let x = 1;</script>";
let region = &syntax().script_regions(&lang("html"), same_line)[0];
assert_eq!(LineIndex::new(same_line).pos(region.base), Pos::new(0, 33));
}
#[test]
fn script_regions_tolerate_incomplete_markup() {
for src in [
"<script",
"<script lang=",
"<script lang='ts",
"<script>const unfinished =",
"<template><div><script lang=ts>const x = 1;</script>",
"<script></script>",
"<script src='missing.js'>ignored</script><script>const x = 1;",
] {
for region in syntax().script_regions(&lang("vue"), src) {
assert_eq!(src.get(region.base..region.base + region.text.len()), Some(region.text.as_str()));
}
}
}
#[test]
fn script_regions_large_vue_document() {
for count in [2_000, 4_000, 8_000] {
let src =
"<template><div>中文😀</div></template>\r\n<script setup lang=ts>const x = 1;</script>\r\n".repeat(count);
let start = std::time::Instant::now();
let regions = syntax().script_regions(&lang("vue"), &src);
eprintln!("script regions: {} bytes, {count} blocks: {:?}", src.len(), start.elapsed());
assert_eq!(regions.len(), count);
assert!(regions.windows(2).all(|pair| pair[0].base < pair[1].base));
}
}
#[test]
fn script_regions_tolerate_deep_markup() {
let src = "<div>".repeat(1_000) + "<script>const x = 1;</script>";
let regions = syntax().script_regions(&lang("html"), &src);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].base, src.find("const").unwrap());
}
// ---------------------------------------------------------------------------
// Outline with nested <script> declarations (Vue SFC / HTML)
#[test]
fn html_outline_nests_script_declarations() {
let src = "<html><body>\n<script>\nfunction boot() {}\nconst app = 1;\n</script>\n\
<script type=\"application/json\">{\"a\": 1}</script>\n</body></html>";
check_outline(
"html",
src,
"
Field html
Field body
Field script
Function boot
Constant app
Field script
",
);
}
#[test]
fn vue_outline_nests_script_declarations_without_a_grammar() {
let src = "<template>\n <div class=\"app\"/>\n</template>\n\
<script setup lang=\"ts\">\ninterface Props { size: number }\nfunction scale(n: number) {}\n</script>\n\
<style scoped>\n.app { color: red }\n</style>";
check_outline(
"vue",
src,
"
Field template
Field script
Interface Props
Property size
Function scale
Field style
",
);
}
#[test]
fn script_outline_positions_are_document_coordinates() {
use super::lines::LineIndex;
let src = "<template><!-- 😀 --></template>\r\n<script lang=\"ts\">\r\nconst answer = 42;\r\n</script>";
let outline = syntax().outline(&lang("vue"), src);
let decl = &outline.iter().find(|i| i.name == "script").unwrap().children[0];
// Cross-check against the document's own byte -> UTF-16 conversion.
let idx = LineIndex::new(src);
let at = src.find("answer").unwrap();
assert_eq!(decl.selection_range, idx.range(at, at + "answer".len()));
// `@def.constant` captures the declarator (`answer = 42`), not the `const` keyword.
assert_eq!(decl.range, idx.range(at, src.find("42;").unwrap() + 2));
}
#[test]
fn cached_outline_matches_syntax_outline_for_web_documents() {
use ide_core::DocId;
use std::sync::Arc;
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
let id = DocId::next();
let src = "<template><div/></template>\n<script lang=\"ts\">\nconst app = 1;\n</script>\n";
cache.open(id, &lang("vue"), 0);
assert_eq!(cache.outline(id, &lang("vue"), 0, src), syntax().outline(&lang("vue"), src));
cache.open(id, &lang("html"), 0);
assert_eq!(cache.outline(id, &lang("html"), 0, src), syntax().outline(&lang("html"), src));
}
#[test]
fn script_outlines_compose_positions_across_multiple_blocks() {
use super::lines::LineIndex;
let src = "<template>中文😀</template><script lang=ts>/* 中文😀 */ function first() { return 1; }</script>\r\n<script setup lang=ts>/* 中文😀 */ const second = 2;\r\nfunction third() {}\r\n</script>";
let lines = LineIndex::new(src);
for id in ["html", "vue", "svelte", "jsp"] {
let items = syntax().outline(&lang(id), src);
let scripts: Vec<_> = items.iter().filter(|item| item.name == "script").collect();
assert_eq!(scripts.len(), 2, "{id}");
assert_eq!(scripts[0].children.len(), 1);
assert_eq!(scripts[1].children.len(), 2);
for (item, name) in scripts.iter().flat_map(|s| &s.children).zip(["first", "second", "third"]) {
let byte = src.find(name).unwrap();
assert_eq!(item.name, name);
assert_eq!(item.selection_range, lines.range(byte, byte + name.len()), "{id}: {name}");
}
}
}
#[test]
fn script_outline_attaches_to_case_insensitive_html_tags() {
check_outline("html", "<SCRIPT>function boot() {}</SCRIPT>", "Field SCRIPT\n Function boot");
}
#[test]
fn outline_restructuredtext_levels_follow_first_use_of_each_adornment() {
let src = "\
=========
Project
=========
Install
~~~~~~~
From source
-----------
Literal block::
Not a title
-----------
Intro
--
A paragraph
with a line
------------
.. comment
-----------
Usage
~~~~~
.. note::
Indented
--------
Short underline, still a title
------
Underline only
==============
";
check_outline(
"restructuredtext",
src,
"
Key Project
Key Install
Key From source
Key Usage
Key Short underline, still a title
Key Underline only
",
);
let items = syntax().outline(&lang("restructuredtext"), src);
let project = &items[0];
assert_eq!(project.range.start, Pos::new(0, 0));
assert_eq!((project.selection_range.start, project.selection_range.end), (Pos::new(1, 1), Pos::new(1, 8)));
let install = &project.children[0];
assert_eq!((install.range.start, install.range.end), (Pos::new(4, 0), Pos::new(23, 11)));
assert!(syntax().outline(&lang("restructuredtext"), "Title\n\n").is_empty());
}
#[test]
fn outline_restructuredtext_unicode_crlf_and_cached_edits() {
use ide_core::DocId;
use std::sync::Arc;
let src = "=========\r\n 中文😀\r\n=========\r\n\r\n子章节\r\n------\r\n\r\nBody\r\n\r\nOther\r\n------\r\n";
let id = DocId::next();
let language = lang("restructuredtext");
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
cache.open(id, &language, 0);
let items = cache.outline(id, &language, 0, src);
assert_eq!(items, syntax().outline(&language, src));
assert_eq!(items[0].name, "中文😀");
assert_eq!(items[0].selection_range, Range::new(Pos::new(1, 1), Pos::new(1, 5)));
assert_eq!(items[0].children[0].name, "子章节");
assert_eq!(items[0].children[0].selection_range, Range::new(Pos::new(4, 0), Pos::new(4, 3)));
assert_eq!(items[0].children[0].range.end, Pos::new(7, 4));
assert_eq!(items[0].children[1].name, "Other");
let edited = src.replace("子章节", "新章节😀");
let updated = cache.outline(id, &language, 1, &edited);
assert_eq!(updated, syntax().outline(&language, &edited));
assert_eq!(updated[0].children[0].selection_range.end, Pos::new(4, 5));
assert_ne!(items, updated);
}
+7 -14
View File
@@ -81,20 +81,13 @@ fn css_regions<'t>(grammar: &str, tree: &Tree, text: &'t str) -> Vec<(usize, &'t
}
/// Pre-order walk; `f` returns whether to descend.
pub(crate) fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
let mut cursor = n.walk();
loop {
if f(cursor.node()) && cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() {
return;
}
}
fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
if !f(n) {
return;
}
let mut c = n.walk();
for child in n.children(&mut c) {
walk(child, f);
}
}
-210
View File
@@ -1,210 +0,0 @@
//! Arduino sketches need their own board context, including their C/C++ sources.
use crate::LspManager;
use crate::server::Server;
use ide_core::{LanguageId, Location};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(crate) const SERVER: &str = "arduino-language-server";
pub(crate) fn sketch_root(project: &Path, file: &Path) -> Option<PathBuf> {
ide_language::arduino_sketch_root(project, file)
}
pub(crate) fn configure_client_capabilities(capabilities: &mut Value) {
// Arduino's proxy cannot decode the boolean full form, panics on inlayHint (#225),
// and cannot handle clangd's semanticTokens refresh request (#125).
for section in ["textDocument", "workspace"] {
if let Some(section) = capabilities[section].as_object_mut() {
section.remove("semanticTokens");
section.remove("inlayHint");
}
}
// ALS uses go.bug.st/lsp (LSP 3.14) which requires a `range` field on `lsp.TextEdit`.
// Clangd returns `InsertReplaceEdit` (`insert` + `replace`) when `insertReplaceSupport` is advertised,
// causing ALS to drop the connection with "json: undefined required field range into lsp.TextEdit".
if let Some(completion) = capabilities.get_mut("textDocument").and_then(|t| t.get_mut("completion"))
&& let Some(item) = completion.get_mut("completionItem").and_then(|c| c.as_object_mut())
{
item.remove("insertReplaceSupport");
}
}
pub(crate) fn sanitize_server_capabilities(capabilities: &mut Value) {
// Arduino Language Server's request dispatcher advertises documentSymbol and
// workspaceSymbol, but panics with "not implemented" / "unimplemented" when called.
// Tree-sitter handles C++ outline/symbols in OpenIDEA without hitting ALS.
if let Some(obj) = capabilities.as_object_mut() {
obj.remove("documentSymbolProvider");
obj.remove("workspaceSymbolProvider");
}
}
impl LspManager {
pub(crate) fn arduino_servers(&self, language: &LanguageId, path: &Path) -> Option<Vec<Arc<Server>>> {
if !matches!(language.as_str(), "c" | "cpp") {
return None;
}
let configs = self.language_servers(&LanguageId::new("arduino"));
let cfg = configs.iter().find(|cfg| cfg.name == SERVER)?;
let project = self.project_root_for(path);
if sketch_root(&project, path).is_some() {
let server = self.server_for(cfg, &LanguageId::new("arduino"), path);
server.add_language(language);
let mut servers = vec![server];
servers.extend(
self.language_servers(language)
.iter()
.filter(|cfg| cfg.name != "clangd" && cfg.name != SERVER)
.filter(|cfg| {
!cfg.require_root_marker
|| crate::launch::marked_root(&project, path, &cfg.root_markers).is_some()
})
.map(|cfg| self.server_for(cfg, language, path)),
);
return Some(servers);
}
if !path.starts_with(&project)
&& let Some(server) = self.origin_server(&crate::uri::path_to_uri_string(path))
&& server.name == SERVER
{
return Some(vec![server]);
}
None
}
pub(crate) fn note_arduino_sources(&self, server: &Arc<Server>, locations: &[Location]) {
if server.name != SERVER {
return;
}
let mut origins = None;
for location in locations {
let path: &Path = &location.path;
if location.uri.is_none() && !path.starts_with(self.project_root_for(path)) {
origins
.get_or_insert_with(|| self.virtual_origins.lock())
.insert(crate::uri::path_to_uri_string(path), server.id);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_server_allows_diagnostics() {
let registry = ide_language::LanguageRegistry::load(None).unwrap();
let language = registry.get(&LanguageId::new("arduino")).unwrap();
let cfg = language.language_servers.iter().find(|cfg| cfg.name == SERVER).unwrap();
assert!(crate::caps::Restriction::new(SERVER, &cfg.features, &cfg.code_action_kinds).diagnostics());
}
#[tokio::test]
async fn inspection_checks_the_sketch_server_and_root_for_all_sources() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sketch = root.join("examples/blink");
std::fs::create_dir_all(&sketch).unwrap();
std::fs::write(sketch.join("blink.ino"), "").unwrap();
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
let expected_root = sketch.clone();
manager.set_config_hook(Box::new(move |cfg, server_root| {
cfg.command = std::env::current_exe().unwrap().to_string_lossy().into_owned();
if cfg.name != SERVER || server_root != expected_root {
cfg.launch_error = Some("unavailable".into());
}
}));
for name in ["blink.ino", "helper.cpp", "helper.h"] {
assert!(manager.serves_path(&sketch.join(name)), "{name}");
}
assert!(!manager.serves_path(&root.join("main.cpp")));
}
#[test]
fn nested_sketches_keep_separate_board_roots() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let inner = root.join("examples/blink");
std::fs::create_dir_all(inner.join("src")).unwrap();
std::fs::write(root.join("main.ino"), "").unwrap();
std::fs::write(inner.join("blink.ino"), "").unwrap();
assert_eq!(sketch_root(root, &inner.join("blink.ino")), Some(inner.clone()));
assert_eq!(sketch_root(root, &inner.join("src/helper.cpp")), Some(inner));
assert_eq!(sketch_root(root, &root.join("helper.h")), Some(root.to_path_buf()));
assert_eq!(sketch_root(&root.join("examples"), &root.join("other.cpp")), None);
}
#[test]
fn ordinary_cpp_projects_and_ino_named_directories_are_not_sketches() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("not-a-file.ino")).unwrap();
assert_eq!(sketch_root(dir.path(), &dir.path().join("main.cpp")), None);
}
#[tokio::test]
async fn cpp_routing_is_scoped_to_sketches_and_respects_disabled_arduino() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sketch = root.join("examples/blink");
std::fs::create_dir_all(&sketch).unwrap();
std::fs::write(sketch.join("blink.ino"), "").unwrap();
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
let cpp = LanguageId::new("cpp");
let sketch_servers = manager.servers_for(&cpp, &sketch.join("helper.cpp"));
assert_eq!(sketch_servers.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), [SERVER]);
assert_eq!(sketch_servers[0].root, sketch);
assert!(sketch_servers[0].languages().contains(&LanguageId::new("arduino")));
assert_eq!(manager.servers_for(&cpp, &root.join("main.cpp"))[0].name, "clangd");
manager.set_servers_hook(Box::new(
|language, defined| {
if language.as_str() == "arduino" { Vec::new() } else { defined.to_vec() }
},
));
assert_eq!(manager.servers_for(&cpp, &sketch.join("helper.cpp"))[0].name, "clangd");
}
#[test]
fn capabilities_are_sanitized_for_arduino_proxy_quirks() {
let mut client = serde_json::json!({
"textDocument": {
"semanticTokens": { "dynamicRegistration": false },
"inlayHint": { "dynamicRegistration": false },
"completion": {
"completionItem": {
"snippetSupport": true,
"insertReplaceSupport": true
}
}
},
"workspace": {
"semanticTokens": { "refreshSupport": true },
"inlayHint": { "refreshSupport": true }
}
});
configure_client_capabilities(&mut client);
assert!(client["textDocument"].get("semanticTokens").is_none());
assert!(client["textDocument"].get("inlayHint").is_none());
assert!(client["textDocument"]["completion"]["completionItem"].get("insertReplaceSupport").is_none());
assert_eq!(client["textDocument"]["completion"]["completionItem"]["snippetSupport"], true);
assert!(client["workspace"].get("semanticTokens").is_none());
assert!(client["workspace"].get("inlayHint").is_none());
let mut server = serde_json::json!({
"documentSymbolProvider": {},
"workspaceSymbolProvider": {},
"completionProvider": { "triggerCharacters": ["."] },
"hoverProvider": true
});
sanitize_server_capabilities(&mut server);
assert!(server.get("documentSymbolProvider").is_none());
assert!(server.get("workspaceSymbolProvider").is_none());
assert!(server.get("completionProvider").is_some());
assert!(server.get("hoverProvider").is_some());
}
}
-26
View File
@@ -468,25 +468,6 @@ impl ServerCaps {
self.restriction = restriction;
return;
}
// JDT LS registers the commands of bundles that were not loaded when it initialized.
if method == "workspace/executeCommand"
&& let Some(added) = options.and_then(|o| o.get("commands")).and_then(Value::as_array)
{
if !self.raw.get("executeCommandProvider").is_some_and(Value::is_object) {
self.raw["executeCommandProvider"] = json!({ "commands": [] });
}
let provider = &mut self.raw["executeCommandProvider"];
if !provider.get("commands").is_some_and(Value::is_array) {
provider["commands"] = json!([]);
}
let commands = provider["commands"].as_array_mut().expect("commands");
for command in added {
if !commands.contains(command) {
commands.push(command.clone());
}
}
return;
}
if method == "textDocument/didSave" {
let include = options.and_then(|o| o.get("includeText")).and_then(Value::as_bool).unwrap_or(false);
self.save = Some(include);
@@ -585,13 +566,6 @@ mod tests {
assert!(c.file_operation("willRename").is_some());
c.unregister("workspace/willRenameFiles");
assert!(c.file_operation("willRename").is_none());
// Commands registered later join those announced at initialization.
let mut c = ServerCaps::parse(json!({ "executeCommandProvider": { "commands": ["a"] } }));
c.register("workspace/executeCommand", Some(&json!({ "commands": ["b", "a"] })));
assert_eq!(c.commands(), vec!["a", "b"]);
let mut c = ServerCaps::parse(json!({}));
c.register("workspace/executeCommand", Some(&json!({ "commands": ["b"] })));
assert_eq!(c.commands(), vec!["b"]);
}
fn restriction(features: &[&str], kinds: &[&str]) -> Restriction {
+10 -133
View File
@@ -17,30 +17,15 @@ pub type Lookup<'a> = Box<dyn Fn(&Path) -> Option<Arc<DocText>> + Send + Sync +
/// Converts server positions to IDE coordinates. For UTF-16 servers this is
/// the identity; otherwise line text comes from open documents or disk.
type MapLocation<'a> = Box<dyn Fn(Location) -> Option<Location> + Send + Sync + 'a>;
type MapEdit<'a> = Box<dyn Fn(&mut WorkspaceEdit) + Send + Sync + 'a>;
pub struct Conv<'a> {
pub enc: Encoding,
map_location: Option<MapLocation<'a>>,
map_edit: Option<MapEdit<'a>>,
lookup: Lookup<'a>,
cache: Mutex<HashMap<PathBuf, Option<Arc<DocText>>>>,
}
impl<'a> Conv<'a> {
pub fn new(enc: Encoding, lookup: impl Fn(&Path) -> Option<Arc<DocText>> + Send + Sync + 'a) -> Self {
Self { enc, lookup: Box::new(lookup), cache: Mutex::new(HashMap::new()), map_location: None, map_edit: None }
}
pub fn with_injections(
mut self,
location: impl Fn(Location) -> Option<Location> + Send + Sync + 'a,
edit: impl Fn(&mut WorkspaceEdit) + Send + Sync + 'a,
) -> Self {
self.map_location = Some(Box::new(location));
self.map_edit = Some(Box::new(edit));
self
Self { enc, lookup: Box::new(lookup), cache: Mutex::new(HashMap::new()) }
}
#[cfg(test)]
@@ -77,11 +62,7 @@ impl<'a> Conv<'a> {
pub fn location(&self, loc: &lsp::Location) -> Option<Location> {
let (path, uri) = uri_target(loc.uri.as_str());
let range = self.range(&path, loc.range);
let loc = Location { path: path.into(), range, uri };
match &self.map_location {
Some(map) => map(loc),
None => Some(loc),
}
Some(Location { path: path.into(), range, uri })
}
pub fn text_edit(&self, path: &Path, e: lsp::TextEdit) -> TextEdit {
@@ -301,7 +282,6 @@ pub fn completion_item(conv: &Conv, path: &Path, item: lsp::CompletionItem, serv
let mut out = CompletionItem {
label: item.label.clone(),
label_detail: None,
label_description: None,
detail: None,
kind: None,
documentation: None,
@@ -350,9 +330,6 @@ pub fn overlay_completion(conv: &Conv, path: &Path, out: &mut CompletionItem, it
if let Some(d) = label_details.and_then(|d| d.detail) {
out.label_detail = Some(d);
}
if let Some(d) = description.clone() {
out.label_description = Some(d);
}
if let Some(d) = detail.or(description) {
out.detail = Some(d);
}
@@ -429,12 +406,10 @@ pub fn lsp_completion_item(text: &DocText, enc: Encoding, item: &CompletionItem)
});
lsp::CompletionItem {
label: item.label.clone(),
label_details: (item.label_detail.is_some() || item.label_description.is_some()).then(|| {
lsp::CompletionItemLabelDetails {
detail: item.label_detail.clone(),
description: item.label_description.clone(),
}
}),
label_details: item
.label_detail
.clone()
.map(|detail| lsp::CompletionItemLabelDetails { detail: Some(detail), description: None }),
kind: item.kind.map(lsp_completion_kind),
detail: item.detail.clone(),
documentation: item.documentation.as_ref().map(lsp_documentation),
@@ -568,18 +543,10 @@ pub fn lsp_command(c: &Command) -> lsp::Command {
}
}
pub fn code_action(
conv: &Conv,
path: &Path,
a: lsp::CodeActionOrCommand,
server: ServerId,
server_name: &str,
) -> CodeAction {
pub fn code_action(conv: &Conv, a: lsp::CodeActionOrCommand, server: ServerId) -> CodeAction {
match a {
lsp::CodeActionOrCommand::Command(c) => CodeAction {
title: c.title.clone(),
diagnostics: Vec::new(),
original_diagnostics: None,
kind: None,
is_preferred: false,
disabled_reason: None,
@@ -590,13 +557,6 @@ pub fn code_action(
},
lsp::CodeActionOrCommand::CodeAction(a) => CodeAction {
title: a.title,
original_diagnostics: a.diagnostics.as_ref().and_then(|d| serde_json::to_string(d).ok()),
diagnostics: a
.diagnostics
.unwrap_or_default()
.into_iter()
.map(|d| diagnostic(conv, path, d, server_name))
.collect(),
kind: a.kind.map(|k| k.as_str().to_owned()),
is_preferred: a.is_preferred.unwrap_or(false),
disabled_reason: a.disabled.map(|d| d.reason),
@@ -612,7 +572,7 @@ pub fn lsp_code_action(a: &CodeAction) -> lsp::CodeAction {
lsp::CodeAction {
title: a.title.clone(),
kind: a.kind.clone().map(lsp::CodeActionKind::from),
diagnostics: a.original_diagnostics.as_deref().and_then(|d| serde_json::from_str(d).ok()),
diagnostics: None,
edit: None,
command: a.command.as_ref().map(lsp_command),
is_preferred: a.is_preferred.then_some(true),
@@ -702,9 +662,6 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
}
}
let mut edit = WorkspaceEdit { files, ..Default::default() };
if let Some(map) = &conv.map_edit {
map(&mut edit);
}
edit.deduplicate();
edit
}
@@ -872,13 +829,7 @@ pub fn workspace_symbols(conv: &Conv, r: lsp::WorkspaceSymbolResponse) -> Vec<Wo
lsp::OneOf::Left(l) => conv.location(&l)?,
lsp::OneOf::Right(w) => {
let (path, uri) = uri_target(w.uri.as_str());
{
let loc = Location { path: path.into(), range: Range::default(), uri };
match &conv.map_location {
Some(map) => map(loc)?,
None => loc,
}
}
Location { path: path.into(), range: Range::default(), uri }
}
};
Some(WorkspaceSymbol { name: s.name, kind: symbol_kind(s.kind), container: s.container_name, location })
@@ -998,7 +949,6 @@ pub fn document_link(conv: &Conv, path: &Path, l: lsp::DocumentLink) -> Option<D
let target = target.as_str();
let (file, url) = match uri_to_path(target) {
Some(p) => (Some(p), None),
None if target.starts_with("openidea-target:") => (Some(PathBuf::from(target.split('#').next()?)), None),
None => (None, Some(target.to_owned())),
};
let fragment = file
@@ -1088,66 +1038,6 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn code_action_diagnostics_use_ide_coordinates_and_round_trip_for_resolve() {
let path = Path::new("/go.mod");
let conv = Conv::new(Encoding::Utf8, |_| Some(Arc::new(DocText::new("😀 dependency"))));
let raw: lsp::Diagnostic = serde_json::from_value(json!({
"range": { "start": { "line": 0, "character": 5 }, "end": { "line": 0, "character": 15 } },
"message": "github.com/sergi/go-diff is not used in this module",
"severity": 2,
"code": 123,
"source": "go mod tidy",
"data": { "fix": "remove" }
}))
.unwrap();
let incoming = lsp::CodeAction {
title: "Remove dependency: github.com/sergi/go-diff".into(),
diagnostics: Some(vec![raw.clone()]),
..Default::default()
};
let action = code_action(&conv, path, lsp::CodeActionOrCommand::CodeAction(incoming), ServerId(1), "gopls");
assert_eq!(action.diagnostics, vec![diagnostic(&conv, path, raw.clone(), "gopls")]);
assert_eq!(action.diagnostics[0].range, Range::new(Pos::new(0, 3), Pos::new(0, 13)));
assert_eq!(action.diagnostics[0].code.as_deref(), Some("123"));
let action: CodeAction = serde_json::from_value(serde_json::to_value(action).unwrap()).unwrap();
assert_eq!(lsp_code_action(&action).diagnostics, Some(vec![raw.clone()]));
let resolved = merge_code_action(&conv, action, lsp::CodeAction::default());
assert_eq!(lsp_code_action(&resolved).diagnostics, Some(vec![raw]));
assert_eq!(resolved.diagnostics.len(), 1);
}
#[test]
fn code_actions_without_diagnostics_keep_them_absent_for_resolve() {
let conv = Conv::utf16();
for incoming in [
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { title: "Fix".into(), ..Default::default() }),
lsp::CodeActionOrCommand::Command(lsp::Command {
title: "Fix".into(),
command: "fix".into(),
arguments: None,
}),
] {
let action = code_action(&conv, Path::new("/a"), incoming, ServerId(1), "srv");
assert!(action.diagnostics.is_empty());
assert!(lsp_code_action(&action).diagnostics.is_none());
}
let raw = lsp::Diagnostic::new_simple(lsp::Range::default(), "problem".into());
let expected = diagnostic(&conv, Path::new("/a"), raw.clone(), "srv");
let action = code_action(
&conv,
Path::new("/a"),
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
diagnostics: Some(vec![raw]),
..Default::default()
}),
ServerId(1),
"srv",
);
assert_eq!(action.diagnostics, vec![expected]);
assert_eq!(action.diagnostics[0].source.as_deref(), Some("srv"));
}
#[test]
fn merged_rename_edits_keep_the_first_servers_ones() {
let edit = |l: u32, c: u32, text: &str| TextEdit {
@@ -1290,8 +1180,7 @@ mod tests {
assert_eq!(a.insert_text, "println!($0)");
assert!(a.is_snippet);
assert_eq!(a.label_detail.as_deref(), Some("(…)"));
assert_eq!(a.label_description.as_deref(), Some("std"));
assert_eq!(a.detail.as_deref(), Some("std"), "no `detail` from the server: the description stands in");
assert_eq!(a.detail.as_deref(), Some("std"));
assert_eq!(a.kind, Some(CompletionKind::Function));
assert_eq!(a.replace_range, Some(Range::new(p(0, 4), p(0, 6))));
assert_eq!(a.replace_range_tab, Some(Range::new(p(0, 4), p(0, 9))));
@@ -1307,9 +1196,6 @@ mod tests {
let back = lsp_completion_item(&text, Encoding::Utf16, &a);
assert_eq!(back.data, Some(json!({"k": 1})));
assert!(matches!(back.text_edit, Some(lsp::CompletionTextEdit::InsertAndReplace(_))));
let details = back.label_details.expect("label details survive the resolve round trip");
assert_eq!(details.detail.as_deref(), Some("(…)"));
assert_eq!(details.description.as_deref(), Some("std"));
}
#[test]
@@ -1340,10 +1226,6 @@ mod tests {
let item = completion_item(&Conv::utf16(), Path::new("/Main.java"), items[0].clone(), ServerId(1));
assert_eq!(item.replace_range, Some(Range::new(p(2, 19), p(2, end))));
assert_eq!(item.replace_range_tab, None);
// The list shows `print (Object obj)` and `void`; the long signature is only for the docs panel.
assert_eq!(item.label_detail.as_deref(), Some("(Object obj)"));
assert_eq!(item.label_description.as_deref(), Some("void"));
assert_eq!(item.detail.as_deref(), Some("PrintStream.print(Object obj) : void"));
assert_eq!(item.filter_text.as_deref(), Some("print(Object obj) : void"));
assert_eq!(item.insert_text, "print(${0});");
assert!(item.is_snippet);
@@ -1560,11 +1442,6 @@ mod tests {
assert_eq!(file.path.as_deref(), Some(Path::new("/p/a b.md")));
assert_eq!(file.fragment.as_deref(), Some("L12"));
assert_eq!(file.range, Range::new(p(0, 6), p(0, 15)));
let virtual_link =
document_link(&conv, here, link(Some("openidea-target://target/7/usr/lib/a.php#L2"))).unwrap();
assert_eq!(virtual_link.path.as_deref(), Some(Path::new("openidea-target://target/7/usr/lib/a.php")));
assert_eq!(virtual_link.fragment.as_deref(), Some("L2"));
assert!(virtual_link.url.is_none());
let web = document_link(&conv, here, link(Some("https://example.com/#top"))).unwrap();
assert_eq!((web.path, web.url.as_deref(), web.fragment), (None, Some("https://example.com/#top"), None));
assert!(document_link(&conv, here, link(None)).is_none());
-9
View File
@@ -139,9 +139,6 @@ impl ClientHandler {
}
};
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
if !mgr.accepts_injected_diagnostics(&path, params.version) {
return;
}
// Files opened only for a request (references search) are not the user's problems.
if (self.server.external_diagnostics.load(std::sync::atomic::Ordering::Acquire)
&& !mgr.docs.lock().values().any(|d| d.path == path))
@@ -268,12 +265,6 @@ impl Handler for ClientHandler {
"window/showMessage" => self.show_message(&mgr, &params),
"window/logMessage" => {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
if self.server.name == "jdtls"
&& text.contains("Can't use Java ")
&& text.contains(" to import Gradle project ")
{
self.show_message(&mgr, &json!({ "type": 1, "message": text.lines().next().unwrap_or(text) }));
}
match params.get("type").and_then(Value::as_u64) {
Some(1) if self.server.name == "jdtls" && text.contains(crate::jdtls::reserved_names::IMPORT_FAILURE) => {
tracing::warn!("[{}] {text}", self.server.name);
-769
View File
@@ -1,769 +0,0 @@
//! Masked file documents for plugin-provided embedded languages.
use crate::{
LspManager, convert,
server::Server,
text::Encoding,
types::{Command, CompletionItem},
};
use ide_core::{DocId, FileEdit, IdeError, IdeResult, LanguageId, Location, Pos, Range, WorkspaceEdit};
use parking_lot::{Mutex, RwLock};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicU64, Ordering};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
pub const MAX_INJECTED_DOCUMENT_BYTES: usize = 2 << 20;
const MAX_DOCUMENTS: usize = 32;
const MARKER: &str = ".openidea-injected-";
const ORIGIN: &str = "$openideaInjection";
pub(super) const COMMAND: &str = "$openideaInjectedCommand";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InjectedRange {
pub range: Range,
pub language: LanguageId,
}
/// Runs on a blocking worker, with the host's unsaved snapshot.
pub type InjectionProvider = dyn Fn(&Path, &LanguageId, u32, &str) -> Vec<InjectedRange> + Send + Sync;
#[derive(Clone)]
struct Document {
host: DocId,
host_path: PathBuf,
version: u32,
path: PathBuf,
injection: InjectedRange,
active: bool,
}
#[derive(Default)]
pub(super) struct State {
pub gate: Mutex<()>,
provider: RwLock<Option<Arc<InjectionProvider>>>,
documents: RwLock<HashMap<DocId, Document>>,
refreshed: Mutex<HashMap<DocId, u32>>,
refresh: tokio::sync::Mutex<()>,
generation: AtomicU64,
}
fn reserved(path: &Path) -> bool {
path.file_name().is_some_and(|s| {
let name = s.to_string_lossy();
name.rsplit_once(MARKER)
.and_then(|(_, suffix)| suffix.split_once('.'))
.is_some_and(|(id, _)| !id.is_empty() && id.bytes().all(|c| c.is_ascii_digit()))
}) && !path.exists()
}
fn contains(range: Range, pos: Pos) -> bool {
range.start <= pos && pos < range.end
}
fn clip(bounds: Range, range: Range) -> Option<Range> {
if range.start > range.end {
return None;
}
if range.is_empty() {
return contains(bounds, range.start).then_some(range);
}
let start = bounds.start.max(range.start);
let end = bounds.end.min(range.end);
(start < end).then_some(Range::new(start, end))
}
fn confined(bounds: Range, range: Range) -> bool {
range.start <= range.end && bounds.start <= range.start && range.end <= bounds.end
}
fn mask(text: &str, range: Range) -> String {
let mut at = Pos::default();
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if contains(range, at) || matches!(c, '\n' | '\r') {
out.push(c);
} else {
out.extend(std::iter::repeat_n(' ', c.len_utf16()));
}
if c == '\n' {
at.line += 1;
at.col = 0;
} else if c != '\r' {
at.col += c.len_utf16() as u32;
}
}
out
}
impl LspManager {
pub fn set_injection_provider(&self, provider: Arc<InjectionProvider>) {
*self.injections.provider.write() = Some(provider);
}
pub(super) fn schedule_injections(&self, doc: DocId) {
if self.injections.provider.read().is_none() || self.is_injected_doc(doc) {
return;
}
let Some(me) = self.me.upgrade() else { return };
let Ok(snap) = self.snapshot(doc) else { return };
self.runtime.spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
if me.snapshot(doc).is_ok_and(|s| s.version == snap.version) {
let _ = me.refresh_injections(doc).await;
}
});
}
/// Refresh on demand as well as after edits: point requests need not wait for the debounce.
pub async fn refresh_injections(&self, doc: DocId) -> IdeResult<()> {
let _refresh = self.injections.refresh.lock().await;
let generation = self.injections.generation.load(Ordering::Acquire);
let Ok(snap) = self.snapshot(doc) else { return Ok(()) };
if self.is_injected_doc(doc) || self.injections.refreshed.lock().get(&doc) == Some(&(snap.version as u32)) {
return Ok(());
}
let provider = self.injections.provider.read().clone();
let ranges = if snap.text.as_str().len() <= MAX_INJECTED_DOCUMENT_BYTES && !uri_is_virtual(&snap.path) {
if let Some(provider) = provider {
let (path, language, text, version) = (snap.path, snap.language, snap.text, snap.version);
tokio::task::spawn_blocking(move || provider(&path, &language, version as u32, text.as_str()))
.await
.map_err(|e| IdeError::unavailable(e.to_string()))?
} else {
Vec::new()
}
} else {
Vec::new()
};
if let Some(me) = self.me.upgrade() {
tokio::task::spawn_blocking(move || me.set_injections_at(doc, snap.version as u32, ranges, generation))
.await
.map_err(|e| IdeError::unavailable(e.to_string()))?;
}
if self.injection_generation() != generation || !self.snapshot(doc).is_ok_and(|s| s.version == snap.version) {
return Err(IdeError::cancelled());
}
Ok(())
}
/// Revision of plugin providers used to reject an obsolete refresh.
pub fn injection_generation(&self) -> u64 {
self.injections.generation.load(Ordering::Acquire)
}
pub fn set_injections(&self, host: DocId, version: u32, ranges: Vec<InjectedRange>) {
self.set_injections_at(host, version, ranges, self.injection_generation());
}
pub fn set_injections_at(&self, host: DocId, version: u32, mut ranges: Vec<InjectedRange>, generation: u64) {
let _gate = self.injections.gate.lock();
if generation != self.injection_generation() {
return;
}
let Ok(snap) = self.snapshot(host) else { return };
if snap.version as u32 != version || self.is_injected_doc(host) {
return;
}
if snap.text.as_str().len() > MAX_INJECTED_DOCUMENT_BYTES {
ranges.clear();
}
ranges.sort_by_key(|r| r.range);
let mut end = Pos::default();
ranges.retain(|i| {
let r = i.range;
let valid_pos = |p: Pos| {
(p.line as usize) < snap.text.line_count()
&& snap.text.lsp_to_ide(Encoding::Utf8, snap.text.ide_to_lsp(Encoding::Utf8, p)) == p
};
if r.start >= r.end
|| r.start < end
|| !valid_pos(r.start)
|| !valid_pos(r.end)
|| i.language == snap.language
|| self.language_servers(&i.language).is_empty()
{
return false;
}
end = r.end;
true
});
ranges.truncate(MAX_DOCUMENTS);
let mut old: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(_, d)| d.host == host)
.map(|(id, d)| (*id, d.clone()))
.collect();
old.sort_by_key(|(_, d)| d.injection.range);
let mut retained = Vec::new();
for (slot, injection) in ranges.into_iter().enumerate() {
let existing =
old.iter().position(|(id, d)| !retained.contains(id) && d.injection == injection).or_else(|| {
old.get(slot)
.filter(|(id, d)| !retained.contains(id) && d.injection.language == injection.language)
.map(|_| slot)
});
let text = mask(snap.text.as_str(), injection.range);
if let Some(index) = existing {
let (id, mut d) = old[index].clone();
if d.injection != injection {
self.clear_injected_diagnostics(&d);
}
d.version = version;
d.injection = injection;
d.active = true;
self.injections.documents.write().insert(id, d);
self.sync_injected(id, &text);
retained.push(id);
} else {
let Some(cfg) = self.languages.get(&injection.language) else { continue };
let ext = cfg
.extensions
.iter()
.find(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()))
.map(String::as_str)
.unwrap_or("txt");
let mut id = DocId::next();
let path = loop {
let path = snap.path.with_file_name(format!(
".{}{MARKER}{}.{}",
snap.path.file_name().unwrap_or_default().to_string_lossy(),
id.0,
ext
));
if !path.exists() {
break path;
}
id = DocId::next();
};
self.injections.documents.write().insert(
id,
Document {
host,
host_path: snap.path.clone(),
version,
path: path.clone(),
injection: injection.clone(),
active: true,
},
);
let servers = self.servers_for(&injection.language, &path);
self.attach(
id,
path.clone(),
crate::uri::path_to_uri(&path),
&injection.language,
version,
&text,
servers,
);
retained.push(id);
}
}
for (id, d) in old {
if !retained.contains(&id) {
self.injections.documents.write().remove(&id);
self.close_document(id);
self.clear_injected_diagnostics(&d);
}
}
self.injections.refreshed.lock().insert(host, version);
}
/// Plugin enable/disable/reload changes the ranges even without a buffer edit.
pub fn injection_providers_changed(&self) {
let _gate = self.injections.gate.lock();
self.injections.generation.fetch_add(1, Ordering::AcqRel);
self.injections.refreshed.lock().clear();
let hosts: Vec<_> = self.docs.lock().keys().copied().collect();
for doc in hosts {
if !self.is_injected_doc(doc) {
self.close_injections(doc);
self.schedule_injections(doc);
}
}
}
pub(super) fn map_injected_event(&self, mut event: ide_core::IdeEvent) -> Option<ide_core::IdeEvent> {
if let ide_core::IdeEvent::Diagnostics { path, .. } = &mut event
&& reserved(path)
{
let docs = self.injections.documents.read();
let d = docs.values().find(|d| d.path == path.as_ref())?;
*path = d.host_path.clone().into();
}
Some(event)
}
pub(super) fn injected_request_stamp(&self, params: &Value) -> Option<(DocId, i32)> {
let path = crate::uri::uri_to_path(params.pointer("/textDocument/uri")?.as_str()?)?;
if !reserved(&path) {
return None;
}
self.docs.lock().iter().find(|(_, d)| d.path == path).map(|(id, d)| (*id, d.version))
}
pub(super) fn check_injected_request(&self, doc: DocId, version: i32) -> IdeResult<()> {
let active = self.injections.documents.read().get(&doc).is_some_and(|d| d.active);
if !active || !self.snapshot(doc).is_ok_and(|s| s.version == version) {
return Err(IdeError::cancelled());
}
Ok(())
}
pub(super) fn accepts_injected_diagnostics(&self, path: &Path, version: Option<i32>) -> bool {
if !reserved(path) {
return true;
}
let docs = self.injections.documents.read();
docs.iter().any(|(id, d)| {
d.active && d.path == path && self.snapshot(*id).is_ok_and(|s| version.is_none_or(|v| s.version == v))
})
}
pub fn injected_document(&self, host: DocId, pos: Pos) -> DocId {
let version = self.snapshot(host).ok().map(|s| s.version as u32);
self.injections
.documents
.read()
.iter()
.find(|(_, d)| d.host == host && d.active && Some(d.version) == version && contains(d.injection.range, pos))
.map_or(host, |(id, _)| *id)
}
pub fn injected_request_range(&self, doc: DocId, range: Range) -> Range {
self.clip_injected_range(doc, range).unwrap_or(range)
}
pub(super) fn is_injected_doc(&self, doc: DocId) -> bool {
self.injections.documents.read().contains_key(&doc)
}
pub(super) fn clip_injected_range(&self, doc: DocId, range: Range) -> Option<Range> {
match self.injections.documents.read().get(&doc) {
Some(d) if d.active => clip(d.injection.range, range),
Some(_) => None,
None => Some(range),
}
}
fn sync_injected(&self, id: DocId, text: &str) {
let mut docs = self.docs.lock();
let Some(d) = docs.get_mut(&id) else { return };
if d.text.as_str() == text {
return;
}
d.version += 1;
Arc::make_mut(&mut d.text).set(text);
for a in &d.servers {
let Some(run) = a.server.running() else { continue };
if a.opened_gen != Some(run.generation) || run.caps.read().sync == crate::SyncKind::None {
continue;
}
let _ = run.peer.notify(
"textDocument/didChange",
json!({
"textDocument": { "uri": d.uri, "version": d.version }, "contentChanges": [{ "text": text }]
}),
);
}
drop(docs);
self.schedule_pull(id, None, Duration::ZERO);
}
pub(super) fn invalidate_injections(&self, host: DocId) {
self.injections.refreshed.lock().remove(&host);
let old: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(_, d)| d.host == host)
.map(|(id, d)| (*id, d.clone()))
.collect();
if old.is_empty() {
return;
}
for (id, d) in old {
if let Some(d) = self.injections.documents.write().get_mut(&id) {
d.active = false;
}
self.clear_injected_diagnostics(&d);
}
}
pub(super) fn close_injections(&self, host: DocId) {
self.injections.refreshed.lock().remove(&host);
let old: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(_, d)| d.host == host)
.map(|(id, d)| (*id, d.clone()))
.collect();
for (id, d) in old {
self.injections.documents.write().remove(&id);
self.close_document(id);
self.clear_injected_diagnostics(&d);
}
}
pub(super) fn map_injected_location(&self, mut loc: Location) -> Option<Location> {
let docs = self.injections.documents.read();
if let Some(d) = docs.values().find(|d| d.path == loc.path.as_ref()) {
if !d.active {
return None;
}
loc.range = clip(d.injection.range, loc.range)?;
loc.path = d.host_path.clone().into();
loc.uri = None;
Some(loc)
} else {
(!reserved(&loc.path)).then_some(loc)
}
}
pub(super) fn map_injected_edit(&self, edit: &mut WorkspaceEdit) {
let docs = self.injections.documents.read();
edit.files.retain_mut(|f| match f {
FileEdit::Edit { path, edits, version } => {
if let Some(d) = docs.values().find(|d| d.path == path.as_ref()) {
if !d.active {
return false;
}
// Clipping replacements changes their meaning; discard edits crossing a boundary.
edits.retain(|e| confined(d.injection.range, e.range));
*path = d.host_path.clone().into();
*version = Some(d.version as i32);
!edits.is_empty()
} else {
!reserved(path)
}
}
FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => {
!reserved(path) && !docs.values().any(|d| d.path == path.as_ref())
}
FileEdit::Rename { from, to } => {
!reserved(from)
&& !reserved(to)
&& !docs.values().any(|d| d.path == from.as_ref() || d.path == to.as_ref())
}
});
}
pub(super) fn provides_injected_command(&self, host: DocId, server: Option<ide_core::ServerId>) -> bool {
let ids: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(id, d)| d.host == host || **id == host)
.map(|(id, _)| *id)
.collect();
let docs = self.docs.lock();
ids.iter()
.any(|id| docs.get(id).is_some_and(|d| d.servers.iter().any(|a| server.is_none_or(|s| a.server.id == s))))
}
pub(super) fn tag_injected_command(&self, doc: DocId, server: ide_core::ServerId, command: &mut Option<Command>) {
let Some(original) = command.take() else { return };
if self.is_injected_doc(doc) {
let mut data = Some(serde_json::to_value(&original).expect("command is JSON"));
self.tag_injected_origin(doc, &mut data);
*command = Some(Command {
title: original.title,
command: COMMAND.into(),
arguments: vec![json!({ "origin": data, "server": server })],
});
} else {
*command = Some(original);
}
}
pub(super) fn injected_command(
&self,
host: DocId,
command: Command,
) -> IdeResult<(DocId, Command, Option<ide_core::ServerId>)> {
if command.command != COMMAND {
return Ok((host, command, None));
}
let payload = command.arguments.first().ok_or_else(IdeError::cancelled)?;
let origin = payload.pointer("/origin/$openideaInjection").ok_or_else(IdeError::cancelled)?;
let doc =
origin["doc"].as_u64().and_then(|id| u32::try_from(id).ok()).map(DocId).ok_or_else(IdeError::cancelled)?;
// Acceptance may already have edited the host. Keep the server/document identity;
// execute refreshes its mask first, and cannot revive a disappeared injection.
if !self.injections.documents.read().get(&doc).is_some_and(|d| d.host == host || doc == host) {
return Err(IdeError::cancelled());
}
let command = serde_json::from_value(origin["data"].clone()).map_err(|_| IdeError::cancelled())?;
let server =
payload["server"].as_u64().map(|id| ide_core::ServerId(id as u32)).ok_or_else(IdeError::cancelled)?;
Ok((doc, command, Some(server)))
}
pub(super) fn tag_injected_origin(&self, doc: DocId, data: &mut Option<Value>) {
if let Some(d) = self.injections.documents.read().get(&doc) {
*data = Some(json!({ ORIGIN: { "doc": doc, "version": d.version, "data": data.take() } }));
}
}
pub(super) fn resolve_injected_origin(&self, host: DocId, data: &mut Option<Value>) -> IdeResult<DocId> {
let Some(origin) = data.as_ref().and_then(|v| v.get(ORIGIN)) else { return Ok(host) };
let doc = origin["doc"].as_u64().map(|id| DocId(id as u32)).ok_or_else(IdeError::cancelled)?;
let docs = self.injections.documents.read();
let d = docs
.get(&doc)
.filter(|d| {
(d.host == host || doc == host) && d.active && origin["version"].as_u64() == Some(d.version.into())
})
.ok_or_else(IdeError::cancelled)?;
if self.snapshot(d.host)?.version as u32 != d.version {
return Err(IdeError::cancelled());
}
*data = origin.get("data").filter(|v| !v.is_null()).cloned();
Ok(doc)
}
pub(super) fn bound_completion(&self, doc: DocId, item: &mut CompletionItem) -> bool {
let docs = self.injections.documents.read();
let Some(d) = docs.get(&doc) else { return !self.snapshot(doc).is_ok_and(|s| reserved(&s.path)) };
if !d.active {
return false;
}
for r in [&mut item.replace_range, &mut item.replace_range_tab].into_iter().flatten() {
let Some(clipped) = clip(d.injection.range, *r) else { return false };
*r = clipped;
}
item.additional_edits.retain(|e| confined(d.injection.range, e.range));
drop(docs);
self.tag_injected_command(doc, item.server, &mut item.command);
self.tag_injected_origin(doc, &mut item.data);
true
}
fn clear_injected_diagnostics(&self, d: &Document) {
let servers = self.servers.lock().clone();
for server in servers {
server.set_diagnostics(d.path.clone(), Vec::new());
server.clear_pulled(&d.path);
let items = server.diagnostics_for(&d.host_path);
let enc = server.running().map(|r| r.caps.read().encoding).unwrap_or_default();
self.emit_diagnostics(&server, d.host_path.clone(), items, enc);
}
}
/// One event per host/source includes every fragment and the server's native host diagnostics.
pub(super) fn injected_diagnostics(
&self,
server: &Server,
path: &Path,
enc: Encoding,
native: Vec<lsp_types::Diagnostic>,
) -> Option<(PathBuf, Vec<ide_core::Diagnostic>)> {
let docs: Vec<_> = self.injections.documents.read().values().cloned().collect();
let host_path = if let Some(d) = docs.iter().find(|d| d.path == path) {
d.host_path.clone()
} else if reserved(path) {
return None;
} else {
path.to_owned()
};
let conv = self.conv(enc);
let native = if path == host_path { native } else { server.diagnostics_for(&host_path) };
let mut items: Vec<_> =
native.into_iter().map(|d| convert::diagnostic(&conv, &host_path, d, &server.name)).collect();
for d in docs.iter().filter(|d| d.active && d.host_path == host_path) {
items.extend(server.diagnostics_for(&d.path).into_iter().filter_map(|diag| {
let mut diag = convert::diagnostic(&conv, &d.path, diag, &server.name);
if !confined(d.injection.range, diag.range)
|| (diag.range.is_empty() && !contains(d.injection.range, diag.range.start))
{
return None;
}
diag.source = Some(server.name.clone());
Some(diag)
}));
}
(!reserved(&host_path)).then_some((host_path, items))
}
}
fn uri_is_virtual(path: &Path) -> bool {
ide_core::is_virtual_uri(&path.to_string_lossy())
}
#[cfg(test)]
mod tests {
use super::*;
struct Missing;
impl crate::Launcher for Missing {
fn launch(
&self,
_: &ide_language::LanguageServerConfig,
_: &Path,
_: Arc<dyn ide_jsonrpc::Handler>,
_: Box<dyn Fn(String) + Send + Sync>,
) -> Result<ide_jsonrpc::Peer, crate::LaunchError> {
Err(crate::LaunchError::Missing)
}
}
fn manager() -> (Arc<LspManager>, tempfile::TempDir, Arc<Mutex<Vec<ide_core::IdeEvent>>>) {
let root = tempfile::tempdir().unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let output = events.clone();
let mgr = LspManager::with_launcher(
root.path().to_path_buf(),
Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
Arc::new(move |e| output.lock().push(e)),
Arc::new(Missing),
);
(mgr, root, events)
}
fn injection(a: u32, b: u32) -> InjectedRange {
InjectedRange { range: Range::new(Pos::new(0, a), Pos::new(0, b)), language: LanguageId::new("sql") }
}
#[test]
fn routing_mapping_edits_and_limits() {
let (mgr, root, _) = manager();
let host = DocId::next();
let path = root.path().join("a.java");
mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "😀 select tail");
mgr.set_injections(host, 1, vec![injection(3, 9)]);
let embedded = mgr.injected_document(host, Pos::new(0, 3));
assert_ne!(host, embedded);
assert_eq!(mgr.injected_document(host, Pos::new(0, 2)), host);
assert_eq!(mgr.injected_document(host, Pos::new(0, 9)), host);
let virtual_path = mgr.snapshot(embedded).unwrap().path;
assert_eq!(virtual_path.parent(), path.parent());
assert_eq!(virtual_path.extension().unwrap(), "sql");
assert!(!virtual_path.exists());
let loc = Location::file(virtual_path.clone(), Range::new(Pos::default(), Pos::new(0, 99)));
let mapped = mgr.map_injected_location(loc).unwrap();
assert_eq!(mapped.path.0, path);
assert_eq!(mapped.range, injection(3, 9).range);
let outside = Location::file(virtual_path.clone(), Range::new(Pos::default(), Pos::new(0, 2)));
assert!(mgr.map_injected_location(outside).is_none());
let real = root.path().join(".real.openidea-injected-123.sql");
std::fs::write(&real, "real file").unwrap();
let other = Location::file(real, Range::default());
assert_eq!(mgr.map_injected_location(other.clone()), Some(other));
let mut edit = WorkspaceEdit {
files: vec![
FileEdit::Edit {
path: virtual_path.clone().into(),
edits: vec![
ide_core::TextEdit { range: injection(3, 9).range, new_text: "SELECT".into() },
ide_core::TextEdit { range: injection(0, 9).range, new_text: "bad".into() },
],
version: Some(999),
},
FileEdit::Create { path: virtual_path.clone().into(), overwrite: true },
FileEdit::Rename { from: path.clone().into(), to: virtual_path.clone().into() },
FileEdit::Delete { path: virtual_path.clone().into(), recursive: false },
],
..Default::default()
};
mgr.map_injected_edit(&mut edit);
assert_eq!(edit.files.len(), 1);
assert!(
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, version: Some(1) } if p.0 == path && edits.len() == 1)
);
mgr.set_injections(host, 0, vec![]);
assert_ne!(mgr.injected_document(host, Pos::new(0, 5)), host, "stale refresh ignored");
mgr.set_injections(host, 1, vec![]);
assert_eq!(mgr.injected_document(host, Pos::new(0, 5)), host);
assert!(mgr.map_injected_location(Location::file(virtual_path, injection(3, 9).range)).is_none());
mgr.did_open(host, &path, &LanguageId::plain_text(), 2, &"a ".repeat(40));
mgr.set_injections(host, 2, (0..40).map(|n| injection(n * 2, n * 2 + 1)).collect());
assert_eq!(mgr.injections.documents.read().len(), 32);
mgr.did_close(host);
assert!(mgr.injections.documents.read().is_empty());
mgr.did_open(host, &path, &LanguageId::plain_text(), 3, &"a".repeat(MAX_INJECTED_DOCUMENT_BYTES + 1));
mgr.set_injections(host, 3, vec![injection(0, 1)]);
assert!(mgr.injections.documents.read().is_empty());
}
#[test]
fn diagnostics_merge_fragments_native_source_and_clear() {
let (mgr, root, events) = manager();
let host = DocId::next();
let path = root.path().join("a.java");
mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "xxsqlxxsql");
mgr.set_injections(host, 1, vec![injection(2, 5), injection(7, 10)]);
let a = mgr.snapshot(mgr.injected_document(host, Pos::new(0, 2))).unwrap();
let b = mgr.snapshot(mgr.injected_document(host, Pos::new(0, 7))).unwrap();
let server = a.servers[0].clone();
let diag = |start, end, message: &str| lsp_types::Diagnostic {
range: lsp_types::Range::new(lsp_types::Position::new(0, start), lsp_types::Position::new(0, end)),
message: message.into(),
source: Some("untrusted-source".into()),
..Default::default()
};
server.set_diagnostics(path.clone(), vec![diag(0, 1, "native")]);
server.set_diagnostics(a.path.clone(), vec![diag(2, 5, "a"), diag(0, 1, "outside"), diag(4, 6, "crosses")]);
let items = server.set_diagnostics(b.path.clone(), vec![diag(7, 10, "b")]);
mgr.emit_diagnostics(&server, b.path, items, Encoding::Utf16);
let latest = || {
events
.lock()
.iter()
.rev()
.find_map(|e| match e {
ide_core::IdeEvent::Diagnostics { path: p, source, items }
if p.0 == path && source == &server.name =>
{
Some(items.clone())
}
_ => None,
})
.unwrap()
};
assert_eq!(latest().iter().map(|d| d.message.as_str()).collect::<Vec<_>>().len(), 3);
assert!(
latest()
.iter()
.filter(|d| d.message != "native")
.all(|d| d.source.as_deref() == Some(server.name.as_str()))
);
mgr.set_injections(host, 1, vec![injection(7, 10)]);
assert_eq!(latest().iter().map(|d| d.message.clone()).collect::<Vec<_>>(), ["native", "b"]);
mgr.set_injections(host, 1, vec![]);
assert_eq!(latest()[0].message, "native");
assert_eq!(latest().len(), 1);
assert!(
events.lock().iter().all(|e| !matches!(e, ide_core::IdeEvent::Diagnostics { path, .. } if reserved(path)))
);
}
#[test]
fn masking_preserves_utf16_crlf_and_line_edges() {
let text = "😀éSELECT\r\n中sql\r\nend";
let range = Range::new(Pos::new(0, 3), Pos::new(1, 4));
assert_eq!(mask(text, range), " SELECT\r\n中sql\r\n ");
assert_eq!(mask(text, range).encode_utf16().count(), text.encode_utf16().count());
assert_eq!(mask("a\nsql\nz", Range::new(Pos::new(1, 0), Pos::new(2, 0))), " \nsql\n ");
assert_eq!(mask("sql", Range::new(Pos::new(0, 0), Pos::new(0, 3))), "sql");
}
#[test]
fn boundaries_are_half_open_and_results_clip() {
let r = Range::new(Pos::new(1, 3), Pos::new(2, 5));
assert!(contains(r, r.start));
assert!(!contains(r, r.end));
assert!(!contains(r, Pos::new(1, 2)));
assert_eq!(clip(r, Range::new(Pos::new(0, 0), Pos::new(3, 0))), Some(r));
assert_eq!(clip(r, Range::point(r.end)), None);
assert_eq!(clip(r, Range::new(Pos::new(0, 0), r.start)), None);
}
}
-11
View File
@@ -63,17 +63,6 @@ impl LspManager {
/// run doesn't prompt to install every language it meets.
pub fn serves_path(&self, path: &Path) -> bool {
let lang = self.languages.detect(path, None);
let arduino = if lang.as_str() == "arduino" {
Some(self.servers_for(&lang, path))
} else {
self.arduino_servers(&lang, path)
};
if let Some(servers) = arduino {
return servers.iter().any(|server| {
let cfg = self.configured(server);
cfg.launch_error.is_none() && self.launcher.available(&cfg, &server.root)
});
}
self.language_servers(&lang).iter().any(|ls| {
if ls.require_root_marker
&& crate::launch::marked_root(&self.project_root, path, &ls.root_markers).is_none()
+20 -48
View File
@@ -627,11 +627,7 @@ impl LspManager {
.docs
.lock()
.values()
.filter(|d| {
d.language.as_str() == "java"
&& d.path.is_absolute()
&& d.servers.iter().any(|a| a.server.id == server.id)
})
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
.map(|d| crate::uri::path_to_uri_string(&d.path))
.collect();
if uris.is_empty() {
@@ -656,50 +652,26 @@ impl LspManager {
/// started if needed and waited for.
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
let java = LanguageId::new("java");
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
loop {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
let server = self
.servers_for(&java, path)
.into_iter()
.find(|s| s.name == SERVER)
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
self.ensure_started(&server);
match cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? {
Some(run) => Ok((server, run)),
None => {
let status = server.status();
Err(IdeError::unavailable(match status {
ide_core::event::ServerStatus::Missing => {
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
}
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
}
_ => format!("jdtls is not ready ({status:?})"),
}))
}
let server = self
.servers_for(&java, path)
.into_iter()
.find(|s| s.name == SERVER)
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
self.ensure_started(&server);
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if let Some(run) = cancellable_wait(cancel, server.wait_ready(remaining)).await? {
return Ok((server, run));
}
let status = server.status();
if !matches!(
status,
ide_core::event::ServerStatus::Missing
| ide_core::event::ServerStatus::Misconfigured
| ide_core::event::ServerStatus::Crashed
) && tokio::time::Instant::now() < deadline
&& let Ok(_restart) =
cancellable_wait(cancel, tokio::time::timeout_at(deadline, self.reconfigure_lock.lock())).await?
{
// A restart publishes Down while reaping its old JVM. Its successor may
// also have a new identity after changing the shared workspace.
if let Some(current) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER)
&& current.root.is_dir()
&& (current.id != server.id || current.is_starting() || current.running().is_some())
{
continue;
}
}
let status = server.status();
return Err(IdeError::unavailable(match status {
ide_core::event::ServerStatus::Missing => {
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
}
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
}
_ => format!("jdtls is not ready ({status:?})"),
}));
}
}
-30
View File
@@ -95,33 +95,6 @@ impl Launcher for ProcessLauncher {
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
if let Some(target) = &config.target {
let argv =
format!("argv: {:?}", std::iter::once(&config.command).chain(config.args.iter()).collect::<Vec<_>>());
on_stderr(argv.clone());
let binary = config
.pinned
.as_ref()
.ok_or_else(|| LaunchError::Config("target wrapper has no executable pin".into()))?
.verify()
.map_err(LaunchError::Failed)?
.to_owned();
let env = config.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<Vec<_>>();
return Peer::spawn_transport(
&binary.to_string_lossy(),
&config.args,
&target.project_root,
&env,
handler,
on_stderr,
ide_jsonrpc::TransportOptions {
transform: Some(Arc::new(crate::target::TargetTransform(target.clone()))),
..Default::default()
},
)
.map(|peer| (peer, Some(binary)))
.map_err(|e| LaunchError::Failed(format!("{argv}: {e}")));
}
let search = server_search_path(config);
let binary = match &config.pinned {
// A plugin's server: the approved program, unchanged since.
@@ -152,9 +125,6 @@ impl Launcher for ProcessLauncher {
}
fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool {
if config.target.is_some() {
return true;
}
if let Some(pin) = &config.pinned {
return pin.path.is_file();
}
Loaded 100 of 900 files, more files were not shown because too many files have changed in this diff. Show more