mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-10 02:31:27 -10:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2095945f40 | ||
|
|
2955cdcf9c | ||
|
|
5a48beab07 | ||
|
|
dfa610beb0 | ||
|
|
e86134ce83 | ||
|
|
4bc08a1084 | ||
|
|
cb2f249db5 | ||
|
|
2dd57d095f | ||
|
|
5ab5c8c677 | ||
|
|
14aa612038 | ||
|
|
ab5f71b442 | ||
|
|
c5124b809d | ||
|
|
a206ac0ca5 | ||
|
|
b12fabd472 | ||
|
|
e77ee39047 | ||
|
|
1a008fd17e | ||
|
|
d2603facdf | ||
|
|
89c7ae7d4c | ||
|
|
89b178f9aa | ||
|
|
c5c5930a75 | ||
|
|
40be4e4c17 | ||
|
|
502cc348ba | ||
|
|
eb72b78abb | ||
|
|
542584abb8 | ||
|
|
0ccc1e687d | ||
|
|
92b527ab0e | ||
|
|
0e81c6aeb1 | ||
|
|
f8e15a7980 | ||
|
|
d17f6755f3 | ||
|
|
f1c10f4d84 | ||
|
|
54d35d8a67 | ||
|
|
b6b09eb968 | ||
|
|
36fae164a8 | ||
|
|
8c21a1e088 | ||
|
|
ecba8aff68 | ||
|
|
c90d3a7a8e | ||
|
|
115d789091 | ||
|
|
fb92a82101 | ||
|
|
7829bee55c | ||
|
|
13b590f2c7 | ||
|
|
8d5504b376 | ||
|
|
60b19b2ffb | ||
|
|
73b3a99699 | ||
|
|
95843ea12d | ||
|
|
3dac5b8bdc | ||
|
|
fd8b4ff14c | ||
|
|
f70eb13f75 | ||
|
|
337bf4b1ca | ||
|
|
047cac209b | ||
|
|
c5647a5934 | ||
|
|
1f2d86f793 | ||
|
|
998b478de3 |
No files matched your search
@@ -22,6 +22,8 @@ env:
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
# The schedule is for this repository's release; forks can still run it by hand.
|
||||
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
|
||||
Generated
+148
-1
@@ -60,6 +60,20 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@@ -616,6 +630,12 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borrow-or-share"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
@@ -690,6 +710,12 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
@@ -2030,6 +2056,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
@@ -2187,6 +2222,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faster-hex"
|
||||
version = "0.10.1"
|
||||
@@ -2287,6 +2333,17 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5"
|
||||
dependencies = [
|
||||
"borrow-or-share",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -2346,6 +2403,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fraction"
|
||||
version = "0.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-set-times"
|
||||
version = "0.20.3"
|
||||
@@ -4206,6 +4273,7 @@ dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4238,6 +4306,8 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
@@ -4256,7 +4326,9 @@ name = "ide-db"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
@@ -4274,6 +4346,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
@@ -4380,7 +4453,9 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-lite",
|
||||
"regex-syntax",
|
||||
@@ -4587,6 +4662,7 @@ name = "ide-plugin"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
"ide-extension",
|
||||
"parking_lot",
|
||||
@@ -4598,6 +4674,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
@@ -4620,6 +4697,7 @@ dependencies = [
|
||||
name = "ide-remote"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -5287,6 +5365,32 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"base64 0.22.1",
|
||||
"bytecount",
|
||||
"email_address",
|
||||
"fancy-regex",
|
||||
"fraction",
|
||||
"idna",
|
||||
"itoa",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"referencing",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-protocol"
|
||||
version = "2.0.3"
|
||||
@@ -5520,7 +5624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"fluent-uri",
|
||||
"fluent-uri 0.1.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -5862,6 +5966,12 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-cmp"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@@ -6539,6 +6649,12 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.14.0"
|
||||
@@ -7306,6 +7422,20 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri 0.3.2",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regalloc2"
|
||||
version = "0.15.2"
|
||||
@@ -10496,6 +10626,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid-simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
|
||||
dependencies = [
|
||||
"outref",
|
||||
"uuid",
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -10520,6 +10661,12 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vsimd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
|
||||
+54
-35
@@ -100,7 +100,7 @@ struct Shared {
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// One pre-session command lookup per provider, including failed attempts.
|
||||
/// One pre-session lookup of commands and settings per provider, including failed attempts.
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
@@ -153,7 +153,7 @@ impl Shared {
|
||||
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_commands(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
let mut ready = {
|
||||
let mut fetches = self.command_fetches.lock();
|
||||
fetches
|
||||
@@ -163,14 +163,14 @@ impl Shared {
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok(commands) =
|
||||
native::claude::commands(spec.clone(), shared.paths.project_root.clone()).await
|
||||
if let Ok((commands, options)) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": options }));
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
if shared.known_commands(&spec.id).is_empty() {
|
||||
shared.remember(&spec.id, None, Some(&commands), None);
|
||||
}
|
||||
shared.remember_discovered(&spec.id, &options, &commands);
|
||||
shared.emit(AgentEvent::Providers);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -187,6 +187,10 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_config(&self, provider: &str) -> bool {
|
||||
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -199,37 +203,52 @@ impl Shared {
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
) {
|
||||
let snapshot = {
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
if let Some(c) = config {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
self.remember_inner(provider, config, commands, model, false);
|
||||
}
|
||||
|
||||
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
|
||||
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
|
||||
}
|
||||
|
||||
fn remember_inner(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: Option<&[AgentConfigOption]>,
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
discovered: bool,
|
||||
) {
|
||||
// Keep the update and its disk write ordered; a session report wins over discovery.
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) =
|
||||
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
|
||||
{
|
||||
known.commands.insert(root, c.to_vec());
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
serde_json::to_vec_pretty(&*cache)
|
||||
};
|
||||
if let Ok(bytes) = snapshot
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) = commands.filter(|c| {
|
||||
!c.is_empty()
|
||||
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
|
||||
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
|
||||
}) {
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
|
||||
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
|
||||
{
|
||||
tracing::warn!("saving agent settings: {e}");
|
||||
@@ -458,10 +477,10 @@ impl AgentManager {
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& offered.is_empty()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider))
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_commands(spec).await;
|
||||
self.shared.fetch_settings(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
|
||||
@@ -69,8 +69,9 @@ pub(crate) fn connect(
|
||||
Ok(super::serve(shim, client))
|
||||
}
|
||||
|
||||
/// Discover commands without a prompt, ACP session, or pending session file.
|
||||
pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Value>, String> {
|
||||
/// Discover the slash commands and the model, mode and effort options without a prompt,
|
||||
/// ACP session, or pending session file.
|
||||
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
@@ -79,28 +80,52 @@ pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Val
|
||||
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
|
||||
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
|
||||
let _stop = io.kill.clone().drop_guard();
|
||||
let request = json!({ "type": "control_request", "request_id": "commands",
|
||||
"request": { "subtype": "initialize", "perTaskStopAffordance": true } });
|
||||
io.stdin.write_all(format!("{request}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
io.stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
let mut lines = BufReader::new(io.stdout).lines();
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == "commands" {
|
||||
let response = &msg["response"];
|
||||
return if response["subtype"] == "success" {
|
||||
Ok(Settings::from_cli(&response["response"], &Value::Null).commands)
|
||||
} else {
|
||||
Err("Claude Code did not initialize".into())
|
||||
};
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
let init = control(
|
||||
&mut io.stdin,
|
||||
&mut lines,
|
||||
"commands",
|
||||
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
|
||||
)
|
||||
.await?
|
||||
.ok_or("Claude Code did not initialize")?;
|
||||
// The effort and model the user configured; without them the options show the defaults.
|
||||
let applied = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let settings = Settings::from_cli(&init, &applied);
|
||||
Ok((settings.commands.clone(), settings.options()))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
|
||||
async fn control(
|
||||
stdin: &mut (impl AsyncWriteExt + Unpin),
|
||||
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
|
||||
id: &str,
|
||||
request: Value,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let line = json!({ "type": "control_request", "request_id": id, "request": request });
|
||||
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
|
||||
let response = &msg["response"];
|
||||
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
}
|
||||
|
||||
struct Config {
|
||||
pub program: String,
|
||||
/// Extra arguments (a custom agent's), before the session's own.
|
||||
|
||||
+112
-10
@@ -371,7 +371,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for outcome in ["success", "error", "exit", "timeout", "missing"] {
|
||||
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let script = root.join("fake-claude");
|
||||
@@ -380,6 +380,7 @@ async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
r#"#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys, time
|
||||
root = pathlib.Path.cwd()
|
||||
(root / 'pid').write_text(str(os.getpid()))
|
||||
with (root / 'launches').open('a') as f:
|
||||
f.write(json.dumps(sys.argv[1:]) + '\n')
|
||||
request = json.loads(sys.stdin.readline())
|
||||
@@ -391,16 +392,34 @@ if outcome == 'exit':
|
||||
if outcome == 'timeout':
|
||||
time.sleep(60)
|
||||
response = {'subtype': outcome, 'request_id': request['request_id']}
|
||||
if outcome == 'success':
|
||||
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
|
||||
response['subtype'] = 'success'
|
||||
response['response'] = {'commands': [
|
||||
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]}
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
|
||||
'models': [
|
||||
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
|
||||
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
|
||||
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
|
||||
if outcome == 'empty':
|
||||
response['response'] = {}
|
||||
else:
|
||||
response['error'] = 'Authentication required'
|
||||
if outcome == 'session_report':
|
||||
(root / 'initialized').touch()
|
||||
while not (root / 'continue').exists():
|
||||
time.sleep(0.01)
|
||||
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
|
||||
for line in sys.stdin:
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(line)
|
||||
request = json.loads(line)
|
||||
if request['request']['subtype'] == 'get_settings':
|
||||
if outcome == 'settings_timeout':
|
||||
time.sleep(60)
|
||||
reply = {'subtype': 'success', 'request_id': request['request_id'],
|
||||
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
|
||||
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -417,19 +436,59 @@ for line in sys.stdin:
|
||||
}
|
||||
spec.env = vec![("OUTCOME".into(), outcome.into())];
|
||||
}
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None));
|
||||
let concurrent = async {
|
||||
if outcome == "session_report" {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !root.join("initialized").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let reported = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
|
||||
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
std::fs::write(root.join("continue"), "").unwrap();
|
||||
}
|
||||
mgr.commands("claude", None).await
|
||||
};
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
|
||||
let first = first.unwrap();
|
||||
assert_eq!(first, concurrent.unwrap());
|
||||
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
|
||||
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
|
||||
if outcome == "success" {
|
||||
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
|
||||
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
|
||||
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
|
||||
let restored = manager(root, Arc::new(FakeAgent::default()));
|
||||
assert_eq!(first, restored.commands("claude", None).await.unwrap());
|
||||
// The same handshake lists the models, so the composer has them before any task.
|
||||
for mgr in [&mgr, &restored] {
|
||||
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();
|
||||
assert_eq!(model.current.as_deref(), Some("default"));
|
||||
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
|
||||
assert_eq!(values, ["default", "custom-opus"]);
|
||||
let effort =
|
||||
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
|
||||
assert_eq!(
|
||||
effort.unwrap().current.as_deref(),
|
||||
Some(match outcome {
|
||||
"settings_timeout" => "default",
|
||||
"session_report" => "low",
|
||||
_ => "high",
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(names, ["triage"], "{outcome}");
|
||||
assert!(!root.join("models.json").exists());
|
||||
assert_eq!(root.join("models.json").exists(), outcome == "empty");
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
|
||||
}
|
||||
assert!(mgr.tasks.read().is_empty());
|
||||
assert!(!root.join("store").exists());
|
||||
@@ -439,10 +498,25 @@ for line in sys.stdin:
|
||||
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
|
||||
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
|
||||
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
|
||||
assert_eq!(requests.lines().count(), 1, "only initialize, no prompt or other control request");
|
||||
let request: Value = serde_json::from_str(requests.trim()).unwrap();
|
||||
assert_eq!(request["type"], "control_request");
|
||||
assert_eq!(request["request"]["subtype"], "initialize");
|
||||
let subtypes: Vec<String> = requests
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
// The handshake and the settings read: no prompt or other control request.
|
||||
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
|
||||
&["initialize", "get_settings"]
|
||||
} else {
|
||||
&["initialize"]
|
||||
};
|
||||
assert_eq!(subtypes, expected, "{outcome}");
|
||||
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("discovery process must exit");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2358,3 +2432,31 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
|
||||
"args":["--mcp-stdio","--project","/project"],"env":[]})]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
for discovery_first in [false, true] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let options = |model: &str, effort: &str| {
|
||||
config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
|
||||
"options": [{ "value": "saved" }, { "value": "discovered" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
|
||||
"options": [{ "value": "low" }, { "value": "high" }] }
|
||||
] }))
|
||||
};
|
||||
let saved = options("saved", "low");
|
||||
let discovered = options("discovered", "high");
|
||||
if discovery_first {
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
}
|
||||
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
for mgr in [&mgr, &restored] {
|
||||
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
|
||||
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '"' || c == '\'' {
|
||||
out.push(chars.by_ref().take_while(|&n| n != c).collect());
|
||||
}
|
||||
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..];
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -565,11 +565,12 @@ 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 with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
/// 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)> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
|
||||
for line in text.lines().flat_map(statements) {
|
||||
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) {
|
||||
@@ -591,6 +592,7 @@ pub fn script_dependencies(text: &str) -> Vec<(String, 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, '.' | '-' | '_' | '+'))
|
||||
};
|
||||
@@ -602,11 +604,354 @@ pub fn script_dependencies(text: &str) -> Vec<(String, 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);
|
||||
@@ -624,6 +969,8 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
catalogs.sort();
|
||||
out.extend(catalogs);
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -808,7 +1155,7 @@ dependencies {
|
||||
classpath 'not:a:library'
|
||||
}
|
||||
"#;
|
||||
let deps = script_dependencies(script);
|
||||
let deps = script_dependencies(script, &BTreeMap::new());
|
||||
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
@@ -822,6 +1169,291 @@ 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]
|
||||
|
||||
@@ -147,7 +147,8 @@ 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.
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
|
||||
/// including the scripts they pull in with `apply from`.
|
||||
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(m) = &model.maven {
|
||||
@@ -177,11 +178,26 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
}
|
||||
}
|
||||
for g in &model.gradle {
|
||||
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 }
|
||||
}));
|
||||
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,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
@@ -312,4 +328,39 @@ 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"]);
|
||||
}
|
||||
}
|
||||
@@ -818,6 +818,40 @@ 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);
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
percent-encoding = "2"
|
||||
encoding_rs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -22,4 +23,4 @@ tempfile.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_System_IO"] }
|
||||
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"] }
|
||||
@@ -20,6 +20,8 @@ pub enum McpUiRequest {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum IdeEvent {
|
||||
/// Project data source settings changed; reload the Database tree.
|
||||
DbSourcesChanged,
|
||||
McpRequest {
|
||||
id: u32,
|
||||
request: McpUiRequest,
|
||||
|
||||
+128
-10
@@ -4,10 +4,12 @@ use std::collections::VecDeque;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
#[cfg(any(not(unix), test))]
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_WRITES: usize = 1024;
|
||||
#[cfg(any(not(unix), test))]
|
||||
const RETRY: Duration = Duration::from_millis(4);
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -28,10 +30,11 @@ pub struct InputWriter {
|
||||
}
|
||||
|
||||
impl InputWriter {
|
||||
/// Unix writers must first be made nonblocking with `nonblocking`.
|
||||
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
|
||||
pub fn new(
|
||||
name: String,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
|
||||
on_error: impl FnOnce(io::Error) + Send + 'static,
|
||||
) -> io::Result<Self> {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
@@ -74,11 +77,19 @@ impl InputWriter {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
#[cfg(unix)]
|
||||
match wait_writable() {
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
result => result?,
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
@@ -168,14 +179,46 @@ impl Drop for InputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// PTY clones share file status flags, so readers must also handle WouldBlock.
|
||||
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
|
||||
/// status flags, so readers must also handle WouldBlock.
|
||||
#[cfg(unix)]
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
let wait = readiness(fd, libc::POLLOUT)?;
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
Ok(wait)
|
||||
}
|
||||
|
||||
/// Wait for output from a nonblocking PTY without delaying the child's next read.
|
||||
#[cfg(unix)]
|
||||
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
readiness(fd, libc::POLLIN)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
// Own a duplicate so the hook remains valid if the caller closes its fd.
|
||||
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if duplicate == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fcntl returned a new, owned descriptor.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
|
||||
Ok(move || {
|
||||
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
|
||||
// Readiness wakes immediately; the timeout only bounds abort latency.
|
||||
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if pollfd.revents & libc::POLLNVAL != 0 {
|
||||
return Err(io::Error::from_raw_os_error(libc::EBADF));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -221,6 +264,11 @@ mod tests {
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -243,6 +291,11 @@ mod tests {
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -256,6 +309,26 @@ mod tests {
|
||||
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn readiness_errors_are_reported_and_reject_following_writes() {
|
||||
let (dropped, _rx) = mpsc::channel();
|
||||
let (errors, error) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-error-input".into(),
|
||||
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|
||||
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
|
||||
move |e| errors.send(e.raw_os_error()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write(b"input").unwrap();
|
||||
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
|
||||
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
|
||||
let state = writer.shared.0.lock().unwrap();
|
||||
assert!(state.queue.is_empty());
|
||||
assert_eq!(state.bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_writes_are_also_bounded() {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
@@ -266,14 +339,59 @@ mod tests {
|
||||
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_input_resumes_without_timer_delays() {
|
||||
use std::io::Read;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let (pipe, mut peer) = UnixStream::pair().unwrap();
|
||||
let capacity: libc::c_int = 1024;
|
||||
// Keep the OS buffer small enough that a timer per buffer takes seconds.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::setsockopt(
|
||||
pipe.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_SNDBUF,
|
||||
(&capacity as *const libc::c_int).cast(),
|
||||
std::mem::size_of_val(&capacity) as libc::socklen_t,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let writer =
|
||||
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
writer.finish();
|
||||
// Start with a full buffer, then make the fd writable from another thread.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let started = Instant::now();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
peer.read_to_end(&mut output).unwrap();
|
||||
tx.send(output).unwrap();
|
||||
});
|
||||
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
|
||||
assert_eq!(output, bytes);
|
||||
reader.join().unwrap();
|
||||
eprintln!("4 MiB input drained in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (pipe, _peer) = UnixStream::pair().unwrap();
|
||||
nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer = InputWriter::new("test-pipe-input".into(), Box::new(pipe), |e| panic!("{e}")).unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer =
|
||||
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(!writer.worker.as_ref().unwrap().is_finished());
|
||||
|
||||
@@ -16,6 +16,8 @@ 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;
|
||||
pub mod project_roots;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
|
||||
#[cfg(windows)]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
|
||||
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
|
||||
|
||||
let mut info =
|
||||
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
|
||||
// SAFETY: `info` is initialized and declares the size of the writable structure.
|
||||
let status = unsafe { RtlGetVersion(&mut info) };
|
||||
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn windows_build_is_platform_specific() {
|
||||
let build = super::windows_build_number();
|
||||
#[cfg(windows)]
|
||||
assert!(build.is_some_and(|build| build > 0));
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(build, None);
|
||||
}
|
||||
}
|
||||
@@ -104,12 +104,21 @@ 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_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.
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree_of(pid: u32) {
|
||||
let started = windows_tree::descendants(pid);
|
||||
// When the child started nothing, killing it alone is the whole job:
|
||||
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
|
||||
// same outcome, and the callers kill the child themselves.
|
||||
if started.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
@@ -130,13 +139,48 @@ mod windows_tree {
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
|
||||
TerminateProcess, WaitForSingleObject,
|
||||
};
|
||||
|
||||
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
|
||||
pub struct ProcessTree {
|
||||
processes: Vec<OwnedHandle>,
|
||||
}
|
||||
|
||||
impl ProcessTree {
|
||||
pub fn capture(pid: u32) -> Self {
|
||||
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
|
||||
let children = descendants_with(pid, PROCESS_TERMINATE);
|
||||
Self { processes: std::iter::once(root).chain(children).collect() }
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.processes.iter().any(|process| {
|
||||
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
|
||||
unsafe {
|
||||
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests termination children first; poll `is_running` until termination completes.
|
||||
pub fn terminate(&self) {
|
||||
for process in self.processes.iter().rev() {
|
||||
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
|
||||
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every process `root` started, directly or not, opened so it can be waited for. A
|
||||
/// parent's id is reused once it is gone, so a process counts only if it started after
|
||||
/// its parent.
|
||||
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
|
||||
descendants_with(root, 0)
|
||||
}
|
||||
|
||||
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
|
||||
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
|
||||
let processes = processes();
|
||||
let mut seen = HashSet::from([root]);
|
||||
@@ -147,7 +191,7 @@ mod windows_tree {
|
||||
if ppid != parent || !seen.insert(pid) {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = open(pid) else { continue };
|
||||
let Some(handle) = open_with(pid, access) else { continue };
|
||||
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
|
||||
parents.push((pid, at));
|
||||
found.push(handle);
|
||||
@@ -187,8 +231,12 @@ mod windows_tree {
|
||||
}
|
||||
|
||||
fn open(pid: u32) -> Option<OwnedHandle> {
|
||||
open_with(pid, 0)
|
||||
}
|
||||
|
||||
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
|
||||
// SAFETY: as above.
|
||||
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
|
||||
}
|
||||
|
||||
@@ -191,6 +191,19 @@ pub struct RunTarget {
|
||||
pub image_user: bool,
|
||||
}
|
||||
|
||||
/// A plugin-owned argv wrapper for project runs (API 1.4).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRunTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub workdir_args: Vec<String>,
|
||||
pub container_root: Option<String>,
|
||||
pub commands: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -48,6 +48,8 @@ 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.
|
||||
@@ -139,6 +141,14 @@ 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 {
|
||||
@@ -233,6 +243,7 @@ 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(),
|
||||
@@ -629,6 +640,8 @@ impl Default for FileSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguagesSettings {
|
||||
/// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy.
|
||||
pub plugin_formatters: BTreeMap<String, String>,
|
||||
pub python: PythonSettings,
|
||||
pub node: NodeSettings,
|
||||
pub php: PhpSettings,
|
||||
@@ -646,6 +659,7 @@ pub struct LanguagesSettings {
|
||||
impl Default for LanguagesSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_formatters: BTreeMap::new(),
|
||||
python: PythonSettings::default(),
|
||||
node: NodeSettings::default(),
|
||||
php: PhpSettings::default(),
|
||||
@@ -767,6 +781,8 @@ impl Default for PythonSettings {
|
||||
pub struct PhpSettings {
|
||||
/// `php` CLI executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// PHP version the language server checks the code against (`7.4`); empty = from
|
||||
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
|
||||
pub language_level: String,
|
||||
@@ -794,6 +810,8 @@ pub struct RubySettings {
|
||||
pub struct NodeSettings {
|
||||
/// `node` executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
|
||||
pub package_manager: String,
|
||||
}
|
||||
|
||||
@@ -272,6 +272,8 @@ impl Default for PythonDebuggerSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunSettings {
|
||||
/// Target for configurations without an explicit target. Empty = local.
|
||||
pub default_target: String,
|
||||
pub confirm_rerun: bool,
|
||||
/// Ask before closing a Run tab whose process is still running.
|
||||
pub confirm_terminate: bool,
|
||||
@@ -282,7 +284,13 @@ pub struct RunSettings {
|
||||
|
||||
impl Default for RunSettings {
|
||||
fn default() -> Self {
|
||||
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
|
||||
Self {
|
||||
default_target: String::new(),
|
||||
confirm_rerun: true,
|
||||
confirm_terminate: true,
|
||||
show_on_start: true,
|
||||
termination_timeout_ms: 3000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,5 +24,7 @@ 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"] }
|
||||
+151
-83
@@ -31,6 +31,7 @@ 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>,
|
||||
@@ -51,6 +52,7 @@ 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(),
|
||||
@@ -134,6 +136,7 @@ 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(),
|
||||
@@ -166,6 +169,11 @@ 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())
|
||||
@@ -394,6 +402,9 @@ 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 {
|
||||
@@ -439,7 +450,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
if spec.adapter == DEBUGPY_TOOL {
|
||||
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
|
||||
prepare_debugpy(&mut spec).await?;
|
||||
}
|
||||
if is_plugin_bundle(&spec.command) {
|
||||
@@ -459,95 +470,140 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.ok_or_else(|| missing(&spec))?
|
||||
}
|
||||
};
|
||||
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")));
|
||||
}
|
||||
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 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 (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!("the retry loop returns on its last attempt")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
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")));
|
||||
}
|
||||
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,
|
||||
@@ -775,4 +831,16 @@ 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")));
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ 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,
|
||||
@@ -106,9 +107,22 @@ 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,
|
||||
@@ -117,6 +131,7 @@ 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),
|
||||
@@ -187,7 +202,11 @@ impl Connection {
|
||||
if self.is_closed() {
|
||||
return Err(DapError::Closed);
|
||||
}
|
||||
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
|
||||
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()))?;
|
||||
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
|
||||
self.inner.out.send(body).map_err(|_| DapError::Closed)
|
||||
}
|
||||
@@ -246,13 +265,16 @@ impl Connection {
|
||||
|
||||
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
let mut 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 {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod jdwp;
|
||||
pub mod kotlin;
|
||||
mod kotlin_jdwp;
|
||||
mod session;
|
||||
pub mod target;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
use connection::{Connection, DapError};
|
||||
@@ -607,11 +608,22 @@ 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())
|
||||
&& let Err(e) = std::fs::remove_file(entry.path())
|
||||
{
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -685,6 +697,17 @@ 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
|
||||
@@ -699,6 +722,22 @@ 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()
|
||||
@@ -847,11 +886,22 @@ 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?;
|
||||
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 stderr = io.stderr.clone();
|
||||
let (conn, incoming) = {
|
||||
let stderr = stderr.clone();
|
||||
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
|
||||
Connection::start_with_target(
|
||||
io.reader,
|
||||
io.writer,
|
||||
Box::new(move |line| adapter::push_line(&stderr, line)),
|
||||
spec.target.clone(),
|
||||
)
|
||||
};
|
||||
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
|
||||
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
|
||||
@@ -892,6 +942,9 @@ 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");
|
||||
@@ -902,6 +955,17 @@ 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
|
||||
};
|
||||
@@ -939,8 +1003,8 @@ impl DebugManager {
|
||||
"pathFormat": "path",
|
||||
"supportsVariableType": true,
|
||||
"supportsVariablePaging": false,
|
||||
"supportsRunInTerminalRequest": true,
|
||||
"supportsStartDebuggingRequest": true,
|
||||
"supportsRunInTerminalRequest": s.spec.target.is_none(),
|
||||
"supportsStartDebuggingRequest": s.spec.target.is_none(),
|
||||
"supportsProgressReporting": true,
|
||||
"supportsInvalidatedEvent": false,
|
||||
"supportsMemoryReferences": false,
|
||||
@@ -1066,6 +1130,9 @@ impl DebugManager {
|
||||
|
||||
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
|
||||
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
|
||||
if terminate {
|
||||
crate::session::remember_debuggee(s).await;
|
||||
}
|
||||
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
|
||||
// ends that DAP session, so every line the program writes while shutting down is lost.
|
||||
// Ask the program to exit ourselves and keep the adapter attached until it has drained
|
||||
|
||||
+103
-18
@@ -59,6 +59,8 @@ pub(crate) struct Session {
|
||||
/// adapter may report the launcher shell, whose child is the program).
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
|
||||
#[cfg(windows)]
|
||||
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
|
||||
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
@@ -111,6 +113,8 @@ impl Session {
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
stop_pids: parking_lot::Mutex::new(Vec::new()),
|
||||
#[cfg(windows)]
|
||||
stop_tree: Mutex::new(None),
|
||||
pending_restore: Mutex::default(),
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
@@ -298,6 +302,21 @@ impl Session {
|
||||
/// How long Stop waits for a launched program to exit before reporting the session ended.
|
||||
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
|
||||
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
|
||||
s.stop_tree.lock().get_or_insert(tree);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = s;
|
||||
}
|
||||
|
||||
/// The program and everything it started: an adapter may report the launcher shell, whose
|
||||
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
|
||||
#[cfg(unix)]
|
||||
@@ -349,12 +368,10 @@ pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new("taskkill");
|
||||
cmd.args(["/PID", &pid.to_string(), "/T"]);
|
||||
if force {
|
||||
cmd.arg("/F");
|
||||
// Console programs have no external graceful signal; the adapter gets its chance first.
|
||||
if force && let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
let _ = cmd.status();
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = (pid, force);
|
||||
@@ -391,7 +408,18 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
remember_debuggee(s).await;
|
||||
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
while s.stop_tree.lock().as_ref().is_some_and(|tree| tree.is_running()) {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
|
||||
signal_debuggee(s, true);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
}
|
||||
@@ -579,6 +607,10 @@ 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);
|
||||
@@ -630,9 +662,15 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
if let Some(fd) = pair.master.as_raw_fd() {
|
||||
ide_core::input::nonblocking(fd).map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
}
|
||||
let wait_writable = ide_core::input::nonblocking(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_readable = ide_core::input::readable(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
|
||||
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
|
||||
@@ -662,16 +700,27 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
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}"))?;
|
||||
let writer = ide_core::input::InputWriter::new(format!("openidea-debug-input-{}", s.id.0), writer, |e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
})
|
||||
let writer = ide_core::input::InputWriter::new(
|
||||
format!("openidea-debug-input-{}", s.id.0),
|
||||
writer,
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
|e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
},
|
||||
)
|
||||
.inspect_err(|_| {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|e| format!("terminal input worker: {e}"))?;
|
||||
let pid = child.process_id();
|
||||
forward_terminal_output(s, reader);
|
||||
forward_terminal_output(
|
||||
s,
|
||||
reader,
|
||||
#[cfg(unix)]
|
||||
wait_readable,
|
||||
);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
@@ -680,7 +729,11 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
}
|
||||
|
||||
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
fn forward_terminal_output(
|
||||
s: &Session,
|
||||
mut reader: Box<dyn std::io::Read + Send>,
|
||||
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
|
||||
) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
@@ -692,6 +745,14 @@ fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send
|
||||
Ok(n) => pending.extend_from_slice(&buf[..n]),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = wait_readable()
|
||||
&& e.kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
continue;
|
||||
}
|
||||
@@ -762,7 +823,6 @@ 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)
|
||||
@@ -775,7 +835,9 @@ 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"))
|
||||
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
|
||||
// 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("-cp")
|
||||
.arg(classpath);
|
||||
let modules = strings("modulePaths");
|
||||
@@ -808,14 +870,18 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
}
|
||||
let mut stdout =
|
||||
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
// 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 {
|
||||
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 Ok(());
|
||||
return parse_jdwp_port(&line).ok_or_else(|| {
|
||||
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
|
||||
});
|
||||
}
|
||||
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
|
||||
}
|
||||
@@ -829,3 +895,22 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,93 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
type Log = Arc<Mutex<Vec<Value>>>;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "child process for Windows stop tests"]
|
||||
fn windows_debuggee_process() {
|
||||
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
|
||||
return;
|
||||
}
|
||||
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
let mut child = ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let _ = child.wait();
|
||||
return;
|
||||
}
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
println!("LISTENING {}", listener.local_addr().unwrap().port());
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn windows_stop_releases_the_debuggees_port_before_returning() {
|
||||
use std::io::BufRead;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
|
||||
impl Drop for Child {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tree) = &self.1 {
|
||||
tree.terminate();
|
||||
}
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
for (second_stop, wrapper, supports_terminate) in
|
||||
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
|
||||
{
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = Child(command.spawn().unwrap(), None);
|
||||
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
|
||||
.lines()
|
||||
.map(Result::unwrap)
|
||||
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
|
||||
.unwrap();
|
||||
let pid = child.0.id();
|
||||
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
|
||||
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let stop = tokio::spawn({
|
||||
let manager = h.manager.clone();
|
||||
async move { manager.stop(id).await }
|
||||
});
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while session.stop_tree.lock().is_none() && !stop.is_finished() {
|
||||
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(!stop.is_finished(), "Stop must wait for the live program");
|
||||
if wrapper {
|
||||
assert!(session.stop_tree.lock().is_some());
|
||||
child.0.kill().unwrap();
|
||||
child.0.wait().unwrap();
|
||||
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
|
||||
}
|
||||
if second_stop {
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
let limit = if second_stop { 10 } else { 25 };
|
||||
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
|
||||
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
|
||||
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Script {
|
||||
/// Send a `runInTerminal` reverse request when `launch` arrives.
|
||||
@@ -1166,6 +1253,7 @@ 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(),
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
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
|
||||
@@ -93,9 +93,23 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
|
||||
.await
|
||||
.unwrap();
|
||||
let id = manager
|
||||
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
|
||||
Some(adapter.clone())
|
||||
})
|
||||
.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(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("start: {e:?}"));
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! 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();
|
||||
}
|
||||
@@ -7,10 +7,13 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
blake3.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
|
||||
+25
-13
@@ -7,6 +7,7 @@ pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
@@ -381,7 +382,9 @@ impl DbManager {
|
||||
/// Where the saved passwords of data source `id` live: app-wide for a global one.
|
||||
fn saved_for(&self, id: &str) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
|
||||
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
|
||||
store
|
||||
}
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
@@ -412,19 +415,28 @@ impl DbManager {
|
||||
/// Saves the data source list; connections of removed or changed sources are closed.
|
||||
/// Global ones (`global`) go to the app-wide list, the others to the project's.
|
||||
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
|
||||
None => {}
|
||||
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
|
||||
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
let old = {
|
||||
let _guard = plugin::EDIT.lock();
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => {
|
||||
return Err(IdeError::invalid("global data sources are not available here"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
old
|
||||
};
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Ownership checks and surgical updates of plugin-created project sources.
|
||||
use crate::{DataSource, DbManager, source};
|
||||
use ide_core::{IdeError, IdeResult, project_dir};
|
||||
use std::path::Path;
|
||||
|
||||
// All DbManagers of the same root share this file. Never hold this across an await.
|
||||
pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||
|
||||
/// Host-owned connection id, unambiguous and safe for console paths.
|
||||
pub fn source_id(plugin: &str, id: &str) -> String {
|
||||
format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex())
|
||||
}
|
||||
|
||||
fn owned(source: &DataSource, plugin: &str, id: &str) -> bool {
|
||||
source.id == source_id(plugin, id)
|
||||
&& source.plugin_owner.as_deref() == Some(plugin)
|
||||
&& source.plugin_source_id.as_deref() == Some(id)
|
||||
&& !source.global
|
||||
}
|
||||
|
||||
/// Edits just matching array tables, retaining unrelated entries, comments and formatting.
|
||||
fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult<Vec<DataSource>> {
|
||||
let path = project_dir::path(root, "data-sources.toml");
|
||||
if let Some(ancestor) = path.ancestors().find(|p| p.exists())
|
||||
&& root.exists()
|
||||
&& !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?)
|
||||
{
|
||||
return Err(IdeError::invalid("data sources file is outside the project"));
|
||||
}
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let old = source::load(root)?;
|
||||
let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?;
|
||||
let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect();
|
||||
fn line_span(text: &str, span: std::ops::Range<usize>, out: &mut Vec<std::ops::Range<usize>>) {
|
||||
let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1);
|
||||
let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1);
|
||||
while end < text.len() {
|
||||
let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1);
|
||||
if !text[end..next].trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
out.push(start..end);
|
||||
}
|
||||
fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec<std::ops::Range<usize>>) {
|
||||
if !table.is_implicit()
|
||||
&& let Some(span) = table.span()
|
||||
{
|
||||
line_span(text, span, out);
|
||||
}
|
||||
for (_, item) in table.iter() {
|
||||
if let Some(span) = item.as_value().and_then(toml_edit::Value::span) {
|
||||
line_span(text, span, out);
|
||||
}
|
||||
if let Some(table) = item.as_table() {
|
||||
spans(table, text, out);
|
||||
}
|
||||
if let Some(tables) = item.as_array_of_tables() {
|
||||
for table in tables.iter() {
|
||||
spans(table, text, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) {
|
||||
return Err(IdeError::invalid("plugin sources require [[data-source]] tables"));
|
||||
}
|
||||
let mut ranges = vec![];
|
||||
if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) {
|
||||
for (table, source) in tables.iter().zip(&old) {
|
||||
if remove(source) {
|
||||
spans(table, &text, &mut ranges);
|
||||
}
|
||||
}
|
||||
}
|
||||
ranges.sort_by_key(|r| r.start);
|
||||
let mut merged: Vec<std::ops::Range<usize>> = vec![];
|
||||
for range in ranges {
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& range.start <= last.end
|
||||
{
|
||||
last.end = last.end.max(range.end);
|
||||
continue;
|
||||
}
|
||||
merged.push(range);
|
||||
}
|
||||
let mut output = text.clone();
|
||||
for range in merged.into_iter().rev() {
|
||||
output.replace_range(range, "");
|
||||
}
|
||||
if let Some(add) = add {
|
||||
let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
value.as_table_mut().unwrap().remove("global");
|
||||
let value =
|
||||
toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))]));
|
||||
if !output.is_empty() && !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?);
|
||||
}
|
||||
if !removed.is_empty() || add.is_some() {
|
||||
let dir = path.parent().unwrap();
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&tmp, output)?;
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Removes only this plugin's sources, including when their project is closed at uninstall.
|
||||
pub fn remove_all(root: &Path, plugin: &str) -> IdeResult<Vec<DataSource>> {
|
||||
let _guard = EDIT.lock();
|
||||
update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None)
|
||||
}
|
||||
|
||||
impl DbManager {
|
||||
/// Lists private source ids without exposing any connection settings.
|
||||
pub fn plugin_sources(&self, plugin: &str) -> IdeResult<Vec<String>> {
|
||||
Ok(source::load(&self.root)?
|
||||
.into_iter()
|
||||
.filter(|s| s.plugin_owner.as_deref() == Some(plugin))
|
||||
.filter_map(|s| s.plugin_source_id)
|
||||
.collect())
|
||||
}
|
||||
/// Checks the stored owner, even if a user-created source uses the host's id format.
|
||||
pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult<DataSource> {
|
||||
source::load(&self.root)?
|
||||
.into_iter()
|
||||
.find(|s| owned(s, plugin, id))
|
||||
.ok_or_else(|| IdeError::not_found("no owned data source with this id"))
|
||||
}
|
||||
/// Replaces only an owned source and disconnects its old pools.
|
||||
pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> {
|
||||
source.id = source_id(plugin, id);
|
||||
source.plugin_owner = Some(plugin.into());
|
||||
source.plugin_source_id = Some(id.into());
|
||||
source.global = false;
|
||||
{
|
||||
let _guard = EDIT.lock();
|
||||
let all = self.sources()?;
|
||||
if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) {
|
||||
return Err(IdeError::not_found("no owned data source with this id"));
|
||||
}
|
||||
if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 {
|
||||
return Err(IdeError::invalid("at most 64 sources per plugin and project"));
|
||||
}
|
||||
update(&self.root, |s| owned(s, plugin, id), Some(&source))?;
|
||||
}
|
||||
self.disconnect_source(&source.id).await;
|
||||
Ok(())
|
||||
}
|
||||
/// Deletes an owned source and all its saved/session credentials.
|
||||
pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> {
|
||||
let source = {
|
||||
let _guard = EDIT.lock();
|
||||
let source = self.plugin_source(plugin, id)?;
|
||||
update(&self.root, |s| owned(s, plugin, id), None)?;
|
||||
source
|
||||
};
|
||||
self.forget_plugin_source(&source.id).await;
|
||||
Ok(())
|
||||
}
|
||||
/// Completes cleanup after removal from the file.
|
||||
pub async fn forget_plugin_source(&self, id: &str) {
|
||||
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
self.saved_passwords.delete(&key);
|
||||
}
|
||||
self.disconnect_source(id).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{DbDriver, secrets::SecretStore};
|
||||
#[tokio::test]
|
||||
async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n";
|
||||
let file = root.join(".openidea/data-sources.toml");
|
||||
std::fs::write(&file, original).unwrap();
|
||||
let secrets = tmp.path().join("passwords.json");
|
||||
let db = DbManager::new(
|
||||
root.clone(),
|
||||
secrets.clone(),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
);
|
||||
let settings =
|
||||
DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() };
|
||||
db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap();
|
||||
assert!(std::fs::read_to_string(&file).unwrap().starts_with(original));
|
||||
db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap();
|
||||
assert_eq!(db.plugin_sources("a").unwrap(), ["local"]);
|
||||
assert!(db.plugin_source("a", "user").is_err());
|
||||
assert!(db.remove_plugin_source("a", "user").await.is_err());
|
||||
let source = db.plugin_source("a", "local").unwrap();
|
||||
db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap();
|
||||
let store = SecretStore::new(secrets, &root);
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
|
||||
db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap();
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
|
||||
db.remove_plugin_source("a", "local").await.unwrap();
|
||||
assert!(store.get(&source.id).is_none());
|
||||
assert_eq!(db.plugin_sources("b").unwrap(), ["local"]);
|
||||
remove_all(&root, "b").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let global = tmp.path().join("global");
|
||||
let passwords = tmp.path().join("passwords.json");
|
||||
let db = DbManager::new(
|
||||
root.clone(),
|
||||
passwords.clone(),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
)
|
||||
.with_global_sources(global.clone());
|
||||
db.upsert_plugin_source(
|
||||
"plugin",
|
||||
"local",
|
||||
DataSource { name: "Owned".into(), save_password: true, ..Default::default() },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let owned = db.plugin_source("plugin", "local").unwrap();
|
||||
source::save_global(
|
||||
&global,
|
||||
&[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }],
|
||||
)
|
||||
.unwrap();
|
||||
let global_store = SecretStore::new(passwords.clone(), &global);
|
||||
global_store.set(&owned.id, "user-secret").unwrap();
|
||||
db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap();
|
||||
assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret"));
|
||||
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
|
||||
db.remove_plugin_source("plugin", "local").await.unwrap();
|
||||
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn a_user_source_cannot_be_claimed_by_predicting_its_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db = DbManager::new(
|
||||
tmp.path().join("project"),
|
||||
tmp.path().join("passwords.json"),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
);
|
||||
let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() };
|
||||
db.save_sources(vec![source.clone()]).await.unwrap();
|
||||
assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err());
|
||||
assert_eq!(db.sources().unwrap(), [source]);
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,12 @@ pub struct DataSource {
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
/// Plugin id owning this source; absent for user-managed sources.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_owner: Option<String>,
|
||||
/// Id private to the owner, never used as a connection target.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_source_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -219,6 +225,8 @@ impl Default for DataSource {
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
plugin_owner: None,
|
||||
plugin_source_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,32 @@ 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(
|
||||
|
||||
+181
-19
@@ -41,6 +41,15 @@ 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 {
|
||||
@@ -80,6 +89,10 @@ 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)]
|
||||
@@ -90,6 +103,46 @@ 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.
|
||||
@@ -186,17 +239,20 @@ 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 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();
|
||||
let all_labels = parse_labels(&r.labels);
|
||||
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).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() }
|
||||
@@ -212,10 +268,13 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
|
||||
status: r.status,
|
||||
ports: r.ports,
|
||||
created_at: r.created_at,
|
||||
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_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_files: files,
|
||||
mounts: split_csv(&r.mounts),
|
||||
networks: split_csv(&r.networks),
|
||||
labels: all_labels,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -258,17 +317,23 @@ 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| DockerVolume {
|
||||
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
mountpoint: r.mountpoint,
|
||||
.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,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -283,24 +348,121 @@ 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| 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,
|
||||
.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,
|
||||
}
|
||||
})
|
||||
.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,6 +1,9 @@
|
||||
//! Parsing of recorded `docker … --format '{{json .}}'` output.
|
||||
|
||||
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
|
||||
use ide_docker::{
|
||||
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
|
||||
parse_volumes,
|
||||
};
|
||||
|
||||
const PS: &str = include_str!("fixtures/ps.jsonl");
|
||||
const IMAGES: &str = include_str!("fixtures/images.jsonl");
|
||||
@@ -22,11 +25,16 @@ 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]
|
||||
@@ -44,9 +52,44 @@ 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");
|
||||
}
|
||||
@@ -20,6 +20,10 @@ pub enum Capability {
|
||||
EditorWrite,
|
||||
/// Create and replace files inside the project root.
|
||||
ProjectWrite,
|
||||
/// Run arbitrary IDE actions and other plugins' commands.
|
||||
IdeCommands,
|
||||
/// Manage only its own project database sources and write their credentials.
|
||||
DatabaseSources,
|
||||
VcsRead,
|
||||
VcsWrite,
|
||||
/// The plugin's own key-value store and data directory.
|
||||
@@ -32,8 +36,12 @@ pub enum Capability {
|
||||
InputEvents,
|
||||
/// The system clipboard, from the plugin's web views.
|
||||
Clipboard,
|
||||
/// HTTP(S) to one host (`*.example.com` matches subdomains).
|
||||
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
|
||||
Net(String),
|
||||
/// Unencrypted HTTP to one host.
|
||||
NetHttp(String),
|
||||
/// A local socket or named pipe on the project machine.
|
||||
Ipc(String),
|
||||
/// Run one external program. Full code execution: never fine-grained.
|
||||
Process(String),
|
||||
/// Read files outside the project under one path.
|
||||
@@ -60,12 +68,14 @@ impl Capability {
|
||||
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
|
||||
Self::EditorWrite
|
||||
| Self::ProjectWrite
|
||||
| Self::DatabaseSources
|
||||
| Self::VcsWrite
|
||||
| Self::Net(_)
|
||||
| Self::NetHttp(_)
|
||||
| Self::Secrets
|
||||
| Self::InputEvents
|
||||
| Self::Clipboard => Risk::Medium,
|
||||
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +92,8 @@ impl Capability {
|
||||
Self::EditorRead => "Read the focused editor's file and selection".into(),
|
||||
Self::EditorWrite => "Change text in the editor".into(),
|
||||
Self::ProjectWrite => "Create and change files in the open project".into(),
|
||||
Self::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(),
|
||||
Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".into(),
|
||||
Self::VcsRead => "Read version control state".into(),
|
||||
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
|
||||
Self::Storage => "Store its own data".into(),
|
||||
@@ -92,6 +104,12 @@ impl Capability {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
}
|
||||
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
|
||||
Self::NetHttp(host) => {
|
||||
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
|
||||
}
|
||||
Self::Ipc(path) => {
|
||||
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
|
||||
}
|
||||
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
|
||||
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
|
||||
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
|
||||
@@ -100,8 +118,14 @@ impl Capability {
|
||||
|
||||
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
|
||||
pub fn covers(&self, wanted: &Capability) -> bool {
|
||||
if self == wanted {
|
||||
return true;
|
||||
}
|
||||
match (self, wanted) {
|
||||
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
|
||||
(Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path),
|
||||
(Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => {
|
||||
host_matches(pattern, host)
|
||||
}
|
||||
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
|
||||
std::path::Path::new(path).starts_with(std::path::Path::new(base))
|
||||
}
|
||||
@@ -120,6 +144,75 @@ pub fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a socket path; a grant may use `*` in its last segment only.
|
||||
pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> {
|
||||
let pipe = name.starts_with(r"\\.\pipe\");
|
||||
let unix = name.starts_with('/')
|
||||
|| name.starts_with("~/")
|
||||
|| name.starts_with("$TMPDIR/")
|
||||
|| name.starts_with("$XDG_RUNTIME_DIR/");
|
||||
let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect();
|
||||
if !(pipe || unix)
|
||||
|| name.contains(['\0', '?'])
|
||||
|| name.len() > 4096
|
||||
|| parts.last().is_none_or(|p| p.is_empty())
|
||||
|| parts.iter().any(|p| matches!(*p, "." | ".."))
|
||||
|| parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*'))
|
||||
|| (!pattern && name.contains('*'))
|
||||
{
|
||||
return Err(
|
||||
"ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expands the three supported host-local prefixes; no shell is involved.
|
||||
pub fn expand_ipc_name(name: &str) -> Result<String, String> {
|
||||
validate_ipc_name(name, true)?;
|
||||
for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] {
|
||||
if let Some(rest) = name.strip_prefix(prefix) {
|
||||
let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?;
|
||||
let expanded = format!("{}/{rest}", base.trim_end_matches('/'));
|
||||
validate_ipc_name(&expanded, true)?;
|
||||
return Ok(expanded);
|
||||
}
|
||||
}
|
||||
Ok(name.into())
|
||||
}
|
||||
|
||||
fn ipc_matches(pattern: &str, name: &str) -> bool {
|
||||
let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false };
|
||||
if validate_ipc_name(&name, false).is_err() {
|
||||
return false;
|
||||
}
|
||||
let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' };
|
||||
let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if parent != other {
|
||||
return false;
|
||||
}
|
||||
let mut rest = name;
|
||||
let mut pieces = pattern.split('*');
|
||||
let first = pieces.next().unwrap_or_default();
|
||||
let Some(tail) = rest.strip_prefix(first) else { return false };
|
||||
rest = tail;
|
||||
let pieces: Vec<_> = pieces.collect();
|
||||
if pieces.is_empty() {
|
||||
return rest.is_empty();
|
||||
}
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
if i + 1 == pieces.len() {
|
||||
return rest.ends_with(piece);
|
||||
}
|
||||
let Some(at) = rest.find(piece) else { return false };
|
||||
rest = &rest[at + piece.len()..];
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
impl fmt::Display for Capability {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -127,6 +220,8 @@ impl fmt::Display for Capability {
|
||||
Self::EditorRead => f.write_str("editor.read"),
|
||||
Self::EditorWrite => f.write_str("editor.write"),
|
||||
Self::ProjectWrite => f.write_str("project.write"),
|
||||
Self::IdeCommands => f.write_str("ide.commands"),
|
||||
Self::DatabaseSources => f.write_str("database.sources"),
|
||||
Self::VcsRead => f.write_str("vcs.read"),
|
||||
Self::VcsWrite => f.write_str("vcs.write"),
|
||||
Self::Storage => f.write_str("storage"),
|
||||
@@ -135,6 +230,8 @@ impl fmt::Display for Capability {
|
||||
Self::InputEvents => f.write_str("events.input"),
|
||||
Self::Clipboard => f.write_str("clipboard"),
|
||||
Self::Net(h) => write!(f, "net:{h}"),
|
||||
Self::NetHttp(h) => write!(f, "net.http:{h}"),
|
||||
Self::Ipc(p) => write!(f, "ipc:{p}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
Self::FsRead(p) => write!(f, "fs.read:{p}"),
|
||||
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
|
||||
@@ -153,7 +250,7 @@ impl FromStr for Capability {
|
||||
return Err(format!("capability {s:?} needs a value after ':'"));
|
||||
}
|
||||
return match kind {
|
||||
"net" => {
|
||||
"net" | "net.http" => {
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
@@ -162,7 +259,19 @@ impl FromStr for Capability {
|
||||
&& label.len() <= 63
|
||||
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
});
|
||||
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
|
||||
if valid {
|
||||
Ok(if kind == "net" {
|
||||
Self::Net(arg.to_ascii_lowercase())
|
||||
} else {
|
||||
Self::NetHttp(arg.to_ascii_lowercase())
|
||||
})
|
||||
} else {
|
||||
Err(format!("invalid host in {s:?}"))
|
||||
}
|
||||
}
|
||||
"ipc" => {
|
||||
validate_ipc_name(arg, true)?;
|
||||
Ok(Self::Ipc(arg.to_owned()))
|
||||
}
|
||||
"process" => {
|
||||
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
|
||||
@@ -184,6 +293,8 @@ impl FromStr for Capability {
|
||||
"editor.read" | "editor-read" => Self::EditorRead,
|
||||
"editor.write" | "editor-write" => Self::EditorWrite,
|
||||
"project.write" => Self::ProjectWrite,
|
||||
"ide.commands" => Self::IdeCommands,
|
||||
"database.sources" => Self::DatabaseSources,
|
||||
"vcs.read" => Self::VcsRead,
|
||||
"vcs.write" => Self::VcsWrite,
|
||||
"storage" => Self::Storage,
|
||||
@@ -292,3 +403,71 @@ mod tests {
|
||||
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod api14_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() {
|
||||
let http: Capability = "net.http:*.example.com".parse().unwrap();
|
||||
assert_eq!(http.to_string(), "net.http:*.example.com");
|
||||
assert_eq!(http.risk(), Risk::Medium);
|
||||
assert!(http.describe().contains("unencrypted"));
|
||||
assert!(http.covers(&"net.http:a.example.com".parse().unwrap()));
|
||||
assert!(!http.covers(&"net:a.example.com".parse().unwrap()));
|
||||
assert!(!"net:*.example.com".parse::<Capability>().unwrap().covers(&"net.http:a.example.com".parse().unwrap()));
|
||||
let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap();
|
||||
assert_eq!(socket.risk(), Risk::High);
|
||||
assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into())));
|
||||
assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into())));
|
||||
assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into())));
|
||||
assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::<Capability>().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*");
|
||||
assert!(
|
||||
r"ipc:\\.\pipe\discord-*"
|
||||
.parse::<Capability>()
|
||||
.unwrap()
|
||||
.covers(&Capability::Ipc(r"\\.\pipe\discord-0".into()))
|
||||
);
|
||||
for invalid in
|
||||
["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"]
|
||||
{
|
||||
assert!(invalid.parse::<Capability>().is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ide_commands_is_explicit_and_high_risk() {
|
||||
let cap: Capability = "ide.commands".parse().unwrap();
|
||||
assert_eq!(cap, Capability::IdeCommands);
|
||||
assert_eq!(cap.to_string(), "ide.commands");
|
||||
assert_eq!(cap.risk(), Risk::High);
|
||||
assert!(!cap.auto_migrates());
|
||||
assert!(!Capability::VcsWrite.covers(&cap));
|
||||
assert!(!Capability::Process("git".into()).covers(&cap));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod database_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() {
|
||||
let cap: Capability = "database.sources".parse().unwrap();
|
||||
assert_eq!(cap, Capability::DatabaseSources);
|
||||
assert_eq!(cap.to_string(), "database.sources");
|
||||
assert_eq!(cap.risk(), Risk::Medium);
|
||||
assert!(!cap.auto_migrates());
|
||||
assert!(!Capability::ProjectRead.covers(&cap));
|
||||
assert!(!Capability::ProjectWrite.covers(&cap));
|
||||
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
|
||||
let text = format!(
|
||||
"id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']"
|
||||
);
|
||||
assert_eq!(crate::Manifest::parse(&text).is_ok(), valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,12 @@ pub struct Grant {
|
||||
/// `process:<program>` → the program confirmed for it.
|
||||
#[serde(default)]
|
||||
pub binaries: BTreeMap<String, BinaryGrant>,
|
||||
/// Project root → process grant → the executable approved for that project.
|
||||
#[serde(default)]
|
||||
pub project_binaries: BTreeMap<PathBuf, BTreeMap<String, BinaryGrant>>,
|
||||
/// Explicit global executable choices, retained for the next re-approval.
|
||||
#[serde(default)]
|
||||
pub program_choices: BTreeMap<String, PathBuf>,
|
||||
/// Migrated from 0.1: capabilities not granted yet are asked for on first
|
||||
/// use instead of blocking the plugin from loading.
|
||||
#[serde(default)]
|
||||
@@ -248,6 +254,9 @@ impl Ledger {
|
||||
} else {
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
for binaries in grant.project_binaries.values_mut() {
|
||||
binaries.remove(&s);
|
||||
}
|
||||
}
|
||||
grant.granted.remove(&s)
|
||||
}
|
||||
@@ -260,6 +269,58 @@ impl Ledger {
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's explicit global executable choice, if any.
|
||||
pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> {
|
||||
self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path)
|
||||
}
|
||||
/// Remembers or resets an explicit global executable choice after confirmation.
|
||||
pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
if let Some(path) = path {
|
||||
grant.program_choices.insert(cap.to_string(), path.into());
|
||||
} else {
|
||||
grant.program_choices.remove(&cap.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a project-specific program choice after user confirmation.
|
||||
pub fn set_project_binary(
|
||||
&mut self,
|
||||
subject: &Subject,
|
||||
cap: &Capability,
|
||||
root: &Path,
|
||||
binary: Option<BinaryGrant>,
|
||||
) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
if let Some(binary) = binary {
|
||||
grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary);
|
||||
} else if let Some(binaries) = grant.project_binaries.get_mut(root) {
|
||||
binaries.remove(&cap.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The project override when present, otherwise the global approved program.
|
||||
pub fn check_binary_in_project(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
cap: &Capability,
|
||||
root: Option<&Path>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
|
||||
if !grant.has(cap) {
|
||||
return Err(format!("{cap} was not granted"));
|
||||
}
|
||||
if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string()))
|
||||
{
|
||||
return match file_hash(&bin.path) {
|
||||
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
|
||||
_ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())),
|
||||
};
|
||||
}
|
||||
self.check_binary(subject, cap)
|
||||
}
|
||||
|
||||
/// Checks a `process:` grant at call time: the program confirmed for it,
|
||||
/// unchanged since. Errors name what to do.
|
||||
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
|
||||
@@ -306,6 +367,16 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
|
||||
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
|
||||
}
|
||||
|
||||
/// Pins an explicitly selected executable, retaining a multicall shim's filename.
|
||||
pub fn approve_program(path: &Path) -> Result<BinaryGrant, String> {
|
||||
if !path.is_absolute() || !is_executable(path) {
|
||||
return Err("choose an absolute executable file".into());
|
||||
}
|
||||
let path = canonical_dir(path);
|
||||
let hash = file_hash(&path).map_err(|e| e.to_string())?;
|
||||
Ok(BinaryGrant { path, hash })
|
||||
}
|
||||
|
||||
/// Resolves a `process:` program to an absolute path once, at grant time:
|
||||
/// absolute names are taken as is, others are looked up in `path_var`.
|
||||
/// The directory is canonicalized but the file name kept as found: `cargo`,
|
||||
|
||||
@@ -24,7 +24,7 @@ mod registry;
|
||||
mod ui;
|
||||
mod when;
|
||||
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name};
|
||||
pub use index::ContributionIndex;
|
||||
pub use manifest::{
|
||||
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
|
||||
|
||||
@@ -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.3.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.
|
||||
@@ -144,8 +144,20 @@ impl ActivationEvent {
|
||||
|
||||
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
|
||||
/// `scheduled` comes from `on-schedule` instead.
|
||||
pub const EVENT_KINDS: &[&str] =
|
||||
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
|
||||
pub const EVENT_KINDS: &[&str] = &[
|
||||
"run-exited",
|
||||
"terminal-exited",
|
||||
"content-tab-action",
|
||||
"file-closed",
|
||||
"settings-changed",
|
||||
"input-mode-changed",
|
||||
"file-opened",
|
||||
"file-saved",
|
||||
"files-changed",
|
||||
"editor-focused",
|
||||
"action-performed",
|
||||
"vcs-changed",
|
||||
];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -317,6 +329,12 @@ impl Manifest {
|
||||
check_relative(icon, "icon")?;
|
||||
}
|
||||
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
|
||||
if matches!(
|
||||
cap,
|
||||
Capability::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_)
|
||||
) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 4)?;
|
||||
}
|
||||
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 3)?;
|
||||
}
|
||||
@@ -332,6 +350,10 @@ impl Manifest {
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for event in &self.activation {
|
||||
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
|
||||
self.needs_api("`on-event:files-changed`", 3)?;
|
||||
}
|
||||
@@ -655,3 +677,44 @@ mod tests {
|
||||
assert!(!engine_compatible("0.10", true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod api14_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn new_capabilities_and_activations_require_api_1_4() {
|
||||
for declaration in [
|
||||
"[capabilities]\nrequired = [\"net.http:example.com\"]",
|
||||
"[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]",
|
||||
"activation = [\"on-event:file-closed\"]",
|
||||
"activation = [\"on-event:settings-changed\"]",
|
||||
"activation = [\"on-event:input-mode-changed\"]",
|
||||
"activation = [\"on-event:run-exited\"]",
|
||||
"activation = [\"on-event:terminal-exited\"]",
|
||||
"activation = [\"on-event:content-tab-action\"]",
|
||||
] {
|
||||
for engine in ["^1.0", "^1.3", "^1.4"] {
|
||||
let manifest = format!(
|
||||
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
|
||||
);
|
||||
let parsed = Manifest::parse(&manifest);
|
||||
if engine == "^1.4" {
|
||||
parsed.unwrap();
|
||||
} else {
|
||||
assert!(parsed.unwrap_err().contains("new in plugin API 1.4"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commands_api_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ide_commands_requires_api_1_4() {
|
||||
let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n";
|
||||
assert!(Manifest::parse(manifest).unwrap_err().contains("1.4"));
|
||||
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ name = "openidea-server"
|
||||
path = "src/bin/server.rs"
|
||||
|
||||
[dependencies]
|
||||
percent-encoding = "2"
|
||||
encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-mcp.workspace = true
|
||||
@@ -52,6 +53,7 @@ ide-tools = { path = "../ide-tools" }
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
jsonschema = { version = "0.33", default-features = false }
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
@@ -130,6 +130,38 @@ pub async fn format(
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let language = project.languages.get(&snap.language);
|
||||
if options.tool.is_none()
|
||||
&& let Some(id) = settings.languages.plugin_formatters.get(snap.language.as_str()).filter(|s| !s.is_empty())
|
||||
{
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let plugins = crate::plugins::editor_plugin_host()
|
||||
.ok_or_else(|| IdeError::not_found("The selected plugin formatter is unavailable"))?;
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
let id = id.clone();
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
plugins.language_format(
|
||||
&id,
|
||||
&d,
|
||||
range,
|
||||
ide_plugin::language_features::FormatOptions {
|
||||
tab_size: options.tab_size,
|
||||
insert_spaces: options.insert_spaces,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
|
||||
if options.tool.is_none()
|
||||
&& snap.path.extension().is_some_and(|e| e == "php")
|
||||
|
||||
@@ -55,6 +55,8 @@ pub struct HostConfig {
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
|
||||
plugin_host: Arc<RwLock<std::sync::Weak<plugins::PluginHost>>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
pub trust: trust::TrustStore,
|
||||
@@ -99,6 +101,14 @@ pub struct Host {
|
||||
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
impl Host {
|
||||
/// Windows whose current project has this root.
|
||||
pub(crate) fn windows_for_root(&self, root: &std::path::Path) -> Vec<String> {
|
||||
let mut windows: Vec<_> =
|
||||
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
|
||||
windows.sort();
|
||||
windows
|
||||
}
|
||||
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig {
|
||||
config_dir,
|
||||
@@ -115,6 +125,8 @@ 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(),
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
@@ -166,6 +178,19 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Associates the plugin runtime that executes this host's project operations.
|
||||
/// Publishes only the executable of the running built-in MCP service.
|
||||
pub fn set_mcp_executable(&self, executable: Option<PathBuf>) {
|
||||
*self.mcp_executable.write() = executable;
|
||||
}
|
||||
|
||||
pub fn set_plugin_host(&self, plugins: &Arc<plugins::PluginHost>) {
|
||||
*self.plugin_host.write() = Arc::downgrade(plugins);
|
||||
}
|
||||
pub(crate) fn plugin_host(&self) -> Option<Arc<plugins::PluginHost>> {
|
||||
self.plugin_host.read().upgrade()
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
@@ -231,6 +256,11 @@ impl Host {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
if self.windows_for_root(&project.root).is_empty()
|
||||
&& let Some(plugins) = self.plugin_host()
|
||||
{
|
||||
plugins.withdraw_project_run_targets(&project.root);
|
||||
}
|
||||
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
|
||||
project.plugin_diagnostics.close();
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
|
||||
@@ -75,7 +75,11 @@ impl ToolRegistry for Registry {
|
||||
}
|
||||
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
|
||||
let (_, project) = self.target(root)?;
|
||||
Ok(tools().into_iter().map(|t| t.metadata).filter(|t| allowed(t, project.is_trusted())).collect())
|
||||
let mut all: Vec<_> = tools().into_iter().map(|t| t.metadata).collect();
|
||||
if let Some(plugins) = self.0.plugin_host() {
|
||||
all.extend(plugins.mcp_tools(root).into_iter().map(|(_, _, tool)| tool));
|
||||
}
|
||||
Ok(all.into_iter().filter(|t| allowed(t, project.is_trusted())).collect())
|
||||
}
|
||||
fn call(
|
||||
&self,
|
||||
@@ -95,6 +99,12 @@ impl ToolRegistry for Registry {
|
||||
client: &str,
|
||||
) -> Result<String, ToolCallError> {
|
||||
let (window, project) = self.target(root).map_err(ToolCallError::Failed)?;
|
||||
if let Some(plugins) = self.0.plugin_host()
|
||||
&& let Some((plugin, tool, metadata)) =
|
||||
plugins.mcp_tools(root).into_iter().find(|(_, _, t)| t.name == name && allowed(t, project.is_trusted()))
|
||||
{
|
||||
return self.call_plugin(window, project, plugins, plugin, tool, metadata, arguments, cancel, client);
|
||||
}
|
||||
let tool = tools()
|
||||
.into_iter()
|
||||
.find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted()))
|
||||
@@ -129,6 +139,51 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn call_plugin(
|
||||
&self,
|
||||
window: String,
|
||||
project: Arc<crate::Project>,
|
||||
plugins: Arc<crate::plugins::PluginHost>,
|
||||
plugin: String,
|
||||
tool: String,
|
||||
metadata: ide_mcp::Tool,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
client: &str,
|
||||
) -> Result<String, ToolCallError> {
|
||||
let run = || -> ide_core::IdeResult<String> {
|
||||
let args = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let call = tool::Call {
|
||||
host: self.0.clone(),
|
||||
project: project.clone(),
|
||||
window: window.clone(),
|
||||
args,
|
||||
cancel: cancel.clone(),
|
||||
client: client.into(),
|
||||
tool: metadata.name,
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if metadata.destructive {
|
||||
self.0.runtime.block_on(call.confirm(&metadata.description, None))?;
|
||||
}
|
||||
if !metadata.read_only {
|
||||
project.require_trust()?;
|
||||
}
|
||||
let invocation = ide_plugin::Invocation {
|
||||
client: Some(window),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
plugins.call_mcp_tool(&plugin, &tool, arguments, invocation, &cancel)
|
||||
};
|
||||
run().map_err(|e| ToolCallError::Failed(e.message))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -91,7 +91,7 @@ impl Reports {
|
||||
pub(super) fn focused_window(&self) -> Option<String> {
|
||||
self.0.read().focused.clone()
|
||||
}
|
||||
pub(super) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
pub(crate) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
let state = self.0.read();
|
||||
let mut paths = Vec::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
@@ -103,10 +103,10 @@ impl Reports {
|
||||
}
|
||||
paths
|
||||
}
|
||||
pub(super) fn focused(&self, window: &str) -> Option<FocusedEditor> {
|
||||
pub(crate) fn focused(&self, window: &str) -> Option<FocusedEditor> {
|
||||
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
|
||||
}
|
||||
pub(super) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
|
||||
pub(crate) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
|
||||
let state = self.0.read();
|
||||
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
|
||||
@@ -169,7 +169,12 @@ pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
|
||||
/// Replaces the project's data sources with `sources`.
|
||||
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.save_sources(sources).await
|
||||
db.save_sources(sources).await?;
|
||||
let root = host.project(window)?.root.clone();
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
project.sink.emit(ide_core::IdeEvent::DbSourcesChanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remembers the password of data source `id` for the session, and in the OS
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod output;
|
||||
mod target;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
@@ -9,6 +10,15 @@ use std::path::PathBuf;
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
host.require_trust(window)?;
|
||||
let 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).await;
|
||||
}
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -351,6 +361,14 @@ 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(
|
||||
@@ -492,6 +510,7 @@ 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
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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,
|
||||
) -> 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 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");
|
||||
// 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
|
||||
}
|
||||
@@ -52,6 +52,20 @@ pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: L
|
||||
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
|
||||
let lsp = if lsp.as_ref().is_none_or(|v| v.is_empty()) {
|
||||
if let Some((plugins, d)) =
|
||||
crate::ops::lsp::plugin_providers(host, window, doc, crate::plugins::ProviderKind::DocumentSymbols)
|
||||
{
|
||||
let items =
|
||||
crate::ops::lsp::plugin_call(move || plugins.language_document_symbols(&d)).await.unwrap_or_default();
|
||||
if items.is_empty() { lsp } else { Some(crate::plugins::language_features::outline(items)) }
|
||||
} else {
|
||||
lsp
|
||||
}
|
||||
} else {
|
||||
lsp
|
||||
};
|
||||
let lsp = lsp.filter(|v| !v.is_empty());
|
||||
blocking(move || {
|
||||
let snap = match project.documents.text_snapshot(doc) {
|
||||
Ok(snap) => snap,
|
||||
@@ -80,7 +94,27 @@ pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
|
||||
let mut folds = project.syntax.folding(doc, &snap.language, snap.version, &snap.text);
|
||||
if let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::Folding) {
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: project.documents.identity(doc)?.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
folds.extend(plugins.language_folding(&d).into_iter().map(|f| FoldRange {
|
||||
start_line: f.start_line,
|
||||
end_line: f.end_line,
|
||||
kind: match f.kind.as_str() {
|
||||
"comment" => ide_language::FoldKind::Comment,
|
||||
"imports" => ide_language::FoldKind::Imports,
|
||||
_ => ide_language::FoldKind::Region,
|
||||
},
|
||||
}));
|
||||
folds.sort_by_key(|f| (f.start_line, f.end_line));
|
||||
folds.dedup_by_key(|f| (f.start_line, f.end_line));
|
||||
}
|
||||
Ok(folds)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -105,6 +139,21 @@ pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset:
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if !snap.large
|
||||
&& let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::SelectionRanges)
|
||||
{
|
||||
let at = ide_text::Document::new(&snap.text).offset_to_pos(offset);
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: project.documents.identity(doc)?.0,
|
||||
language: snap.language.0.clone(),
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text.clone(),
|
||||
};
|
||||
let ranges = plugins.language_selection_ranges(&d, at);
|
||||
if !ranges.is_empty() {
|
||||
return Ok(ranges);
|
||||
}
|
||||
}
|
||||
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
|
||||
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
|
||||
})
|
||||
|
||||
+411
-34
@@ -4,11 +4,39 @@ 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, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_core::{DocId, IdeError, 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,
|
||||
@@ -41,8 +69,8 @@ pub async fn lsp_completion(
|
||||
trigger: Option<String>,
|
||||
) -> IdeResult<CompletionList> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let result = lsp.completion(target, 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 };
|
||||
@@ -56,7 +84,7 @@ pub async fn lsp_completion(
|
||||
}
|
||||
|
||||
/// The plugin host and the document, when some plugin provides `kind`.
|
||||
fn plugin_providers(
|
||||
pub(crate) fn plugin_providers(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
@@ -74,10 +102,48 @@ fn plugin_providers(
|
||||
}
|
||||
|
||||
/// Plugin calls block (the plugin's actor thread does the work).
|
||||
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
pub(crate) async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
tokio::task::spawn_blocking(f).await.ok()
|
||||
}
|
||||
|
||||
fn provider_server<T: Default>(server: IdeResult<T>) -> IdeResult<T> {
|
||||
match server {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Ok(T::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_language_locations(mut server: Vec<Location>, plugin: Vec<ide_plugin::Location>) -> Vec<Location> {
|
||||
let mut seen: std::collections::HashSet<_> = server.iter().cloned().collect();
|
||||
for l in plugin {
|
||||
let loc = Location::file(l.path, l.range);
|
||||
if seen.insert(loc.clone()) {
|
||||
server.push(loc);
|
||||
}
|
||||
}
|
||||
server
|
||||
}
|
||||
fn merge_language_highlights(
|
||||
mut server: Vec<Highlight>,
|
||||
plugin: Vec<ide_plugin::language_features::Highlight>,
|
||||
) -> Vec<Highlight> {
|
||||
let mut seen: std::collections::HashSet<_> = server.iter().map(|h| h.range).collect();
|
||||
for h in plugin {
|
||||
if seen.insert(h.range) {
|
||||
server.push(Highlight {
|
||||
range: h.range,
|
||||
kind: match h.kind.as_str() {
|
||||
"read" => HighlightKind::Read,
|
||||
"write" => HighlightKind::Write,
|
||||
_ => HighlightKind::Text,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
server
|
||||
}
|
||||
|
||||
/// Items from plugins carry no server: commands don't apply, and a code
|
||||
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
|
||||
const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
@@ -200,14 +266,14 @@ pub async fn lsp_hover(
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<Hover>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
let result = lsp.hover(target, pos, cancel).await;
|
||||
host.end_request(request);
|
||||
return result;
|
||||
};
|
||||
let (result, hovers) =
|
||||
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
|
||||
tokio::join!(lsp.hover(target, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
|
||||
host.end_request(request);
|
||||
let hovers = hovers.unwrap_or_default();
|
||||
if hovers.is_empty() {
|
||||
@@ -222,11 +288,28 @@ pub async fn lsp_hover(
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
host.project(window)?.lsp.signature_help(doc, pos).await
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
let server = host.project(window)?.lsp.signature_help(target, pos).await;
|
||||
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
|
||||
return server;
|
||||
}
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SignatureHelp) else {
|
||||
return server;
|
||||
};
|
||||
let help = plugin_call(move || plugins.language_signature_help(&d, pos)).await.flatten();
|
||||
match help {
|
||||
Some(h) => {
|
||||
let _ = provider_server(server)?;
|
||||
Ok(Some(crate::plugins::language_features::signature_help(h)))
|
||||
}
|
||||
None => server,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
if kind == GotoKind::Definition
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
if target == doc
|
||||
&& kind == GotoKind::Definition
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
let project = host.project(window)?;
|
||||
@@ -237,7 +320,24 @@ 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(doc, pos, kind).await;
|
||||
let result = host.project(window)?.lsp.goto(target, pos, kind).await;
|
||||
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
|
||||
let k =
|
||||
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, k) else {
|
||||
return result;
|
||||
};
|
||||
let locations = plugin_call(move || {
|
||||
if kind == GotoKind::Implementation {
|
||||
plugins.language_implementation(&d, pos)
|
||||
} else {
|
||||
plugins.language_type_definition(&d, pos)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
return Ok(merge_language_locations(provider_server(result)?, locations));
|
||||
}
|
||||
if kind != GotoKind::Definition {
|
||||
return result;
|
||||
}
|
||||
@@ -355,9 +455,61 @@ pub async fn lsp_implementations(
|
||||
positions: Vec<Pos>,
|
||||
) -> IdeResult<Vec<Vec<Location>>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.implementations(doc, &positions, cancel).await;
|
||||
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"))
|
||||
};
|
||||
host.end_request(request);
|
||||
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
|
||||
let count = positions.len();
|
||||
let found = plugin_call(move || {
|
||||
positions.into_iter().map(|pos| plugins.language_implementation(&d, pos)).collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut out = provider_server(result)?;
|
||||
out.resize_with(count, Vec::new);
|
||||
for (items, found) in out.iter_mut().zip(found) {
|
||||
*items = merge_language_locations(std::mem::take(items), found);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -373,13 +525,17 @@ pub async fn lsp_goto_implementation(
|
||||
on_partial: super::Channel<Vec<Location>>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let result = lsp
|
||||
.goto_implementation(doc, pos, cancel, &|locs| {
|
||||
.goto_implementation(target, pos, cancel, &|locs| {
|
||||
let _ = on_partial.send(locs);
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
|
||||
let found = plugin_call(move || plugins.language_implementation(&d, pos)).await.unwrap_or_default();
|
||||
return Ok(merge_language_locations(provider_server(result)?, found));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -389,6 +545,42 @@ 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(®istration.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
|
||||
}
|
||||
|
||||
@@ -400,8 +592,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, `project_wide`: without `libraries`, the dependencies and the JDK
|
||||
/// are not searched; with it, those found in the sources arrive on `on_partial` before
|
||||
/// 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
|
||||
/// the slower search for the rest, and the result has them all. Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -418,9 +610,9 @@ pub async fn lsp_references(
|
||||
) -> IdeResult<References> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let result = async {
|
||||
let symbol = indexed_symbol(host, window, doc, pos).await?;
|
||||
let symbol = if target == doc { indexed_symbol(host, window, doc, pos).await? } else { None };
|
||||
if let Some(symbol) = &symbol {
|
||||
let project = project.clone();
|
||||
let snapshot = symbol.clone();
|
||||
@@ -430,7 +622,7 @@ pub async fn lsp_references(
|
||||
}
|
||||
let result = lsp
|
||||
.references_streaming(
|
||||
doc,
|
||||
target,
|
||||
pos,
|
||||
include_declaration,
|
||||
project_wide,
|
||||
@@ -505,10 +697,11 @@ fn validate_indexed_symbol(
|
||||
let stale = || {
|
||||
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
|
||||
};
|
||||
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
|
||||
let path = dunce::simplified(&file.path);
|
||||
if ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()).ok().as_deref() != Some(path) {
|
||||
return Err(stale());
|
||||
}
|
||||
let text = if let Some(doc) = project.documents.by_path(&file.path) {
|
||||
let text = if let Some(doc) = project.documents.by_path(path) {
|
||||
let snapshot = project.documents.text_snapshot(doc)?;
|
||||
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
|
||||
return Err(stale());
|
||||
@@ -517,7 +710,7 @@ fn validate_indexed_symbol(
|
||||
} else {
|
||||
let loaded = project
|
||||
.documents
|
||||
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
|
||||
.load_file_bounded(path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
|
||||
.map_err(|_| stale())?;
|
||||
if loaded.binary || loaded.large {
|
||||
return Err(stale());
|
||||
@@ -555,7 +748,13 @@ 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>> {
|
||||
host.project(window)?.lsp.document_highlights(doc, pos).await
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
let server = host.project(window)?.lsp.document_highlights(target, pos).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
|
||||
return server;
|
||||
};
|
||||
let items = plugin_call(move || plugins.language_document_highlights(&d, pos)).await.unwrap_or_default();
|
||||
Ok(merge_language_highlights(provider_server(server)?, items))
|
||||
}
|
||||
|
||||
pub async fn lsp_workspace_symbols(
|
||||
@@ -581,7 +780,9 @@ 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 result = project.lsp.code_actions(doc, range, only.clone()).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 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(),
|
||||
@@ -597,7 +798,7 @@ pub async fn lsp_code_actions(
|
||||
let actions = if plugin_actions.is_empty() {
|
||||
result?
|
||||
} else {
|
||||
let mut out = result.unwrap_or_default();
|
||||
let mut out = provider_server(result)?;
|
||||
out.extend(plugin_actions);
|
||||
out
|
||||
};
|
||||
@@ -718,8 +919,9 @@ 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 cancel = host.begin_request(request);
|
||||
let result = project.lsp.code_action_query(doc, range, only, cancel).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;
|
||||
host.end_request(request);
|
||||
let mut result = result?;
|
||||
result.actions = prepare_actions(project, doc, version, result.actions).await?;
|
||||
@@ -1105,12 +1307,52 @@ pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeRe
|
||||
}
|
||||
|
||||
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
|
||||
host.project(window)?.lsp.code_lens(doc).await
|
||||
let server = host.project(window)?.lsp.code_lens(doc).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::CodeLenses) else {
|
||||
return server;
|
||||
};
|
||||
let items = plugin_call(move || plugins.language_code_lenses(&d)).await.unwrap_or_default();
|
||||
let mut out = provider_server(server)?;
|
||||
out.extend(items.into_iter().map(|(plugin, i)| CodeLens {
|
||||
range: i.range,
|
||||
command: Some(Command {
|
||||
title: i.title,
|
||||
command: format!("openidea.plugin-language:{plugin}:{}", i.command),
|
||||
arguments: vec![serde_json::json!(serde_json::to_string(&i.argument).unwrap_or_default())],
|
||||
}),
|
||||
}));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
|
||||
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
|
||||
host.project(window)?.lsp.document_links(doc).await
|
||||
let server = host.project(window)?.lsp.document_links(doc).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentLinks) else {
|
||||
return server;
|
||||
};
|
||||
let root = host.project(window)?.root.clone();
|
||||
let items = plugin_call(move || plugins.language_document_links(&d)).await.unwrap_or_default();
|
||||
let mut out = provider_server(server)?;
|
||||
out.extend(items.into_iter().filter_map(|i| {
|
||||
let url = i.target.starts_with("https://") || i.target.starts_with("http://");
|
||||
if !url && ide_core::is_virtual_uri(&i.target) {
|
||||
return None;
|
||||
}
|
||||
Some(DocumentLink {
|
||||
range: i.range,
|
||||
path: (!url).then(|| {
|
||||
{
|
||||
let path = std::path::PathBuf::from(&i.target);
|
||||
if path.is_absolute() { path } else { root.join(path) }
|
||||
}
|
||||
.into()
|
||||
}),
|
||||
url: url.then_some(i.target),
|
||||
fragment: None,
|
||||
tooltip: None,
|
||||
})
|
||||
}));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn lsp_call_hierarchy(
|
||||
@@ -1200,6 +1442,34 @@ pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResul
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
|
||||
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
|
||||
if let Some(rest) = command.command.strip_prefix("openidea.plugin-language:") {
|
||||
let (plugin, command_id) =
|
||||
rest.split_once(':').ok_or_else(|| ide_core::IdeError::invalid("Invalid plugin command"))?;
|
||||
let plugins = crate::plugins::editor_plugin_host()
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("Plugin host unavailable"))?;
|
||||
let argument = command
|
||||
.arguments
|
||||
.first()
|
||||
.and_then(|a| a.as_str())
|
||||
.and_then(|s| serde_json::from_str::<Option<String>>(s).ok())
|
||||
.flatten();
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let invocation = ide_plugin::Invocation {
|
||||
client: Some(window.into()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: Some(ide_plugin::EditorContext {
|
||||
path: snap.path.0.clone(),
|
||||
language: snap.language.0,
|
||||
selections: vec![],
|
||||
}),
|
||||
selected_paths: vec![snap.path.0],
|
||||
..Default::default()
|
||||
};
|
||||
let (plugin, command_id) = (plugin.to_owned(), command_id.to_owned());
|
||||
return blocking(move || plugins.run_language_command(&plugin, &command_id, argument.as_deref(), invocation))
|
||||
.await;
|
||||
}
|
||||
host.project(window)?.lsp.execute_command(doc, command).await
|
||||
}
|
||||
|
||||
@@ -1262,7 +1532,88 @@ pub async fn lsp_execute_code_action(
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
/// Versioned plugin visuals, refreshed after the editor synchronizes its buffer.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginLanguageVisuals {
|
||||
pub version: u64,
|
||||
pub injections: Vec<ide_plugin::language_features::Injection>,
|
||||
pub colors: Vec<(String, ide_plugin::language_features::ColorInfo)>,
|
||||
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();
|
||||
blocking(move || {
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
let mut out =
|
||||
PluginLanguageVisuals { version: d.version, injections: vec![], colors: vec![], gutter_actions: vec![] };
|
||||
if d.text.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
out.injections = plugins.provide_injections(&d);
|
||||
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
|
||||
}
|
||||
pub async fn plugin_color_presentations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
plugin: String,
|
||||
color: ide_plugin::language_features::ColorInfo,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let snap = host.project(window)?.documents.snapshot(doc)?;
|
||||
blocking(move || {
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
if !crate::plugins::language_features::valid_range(&d, color.range) {
|
||||
return Err(ide_core::IdeError::invalid("Invalid color range"));
|
||||
}
|
||||
Ok(crate::plugins::editor_plugin_host()
|
||||
.map(|p| p.language_color_presentations(&plugin, &d, color))
|
||||
.unwrap_or_default())
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub fn plugin_formatters() -> Vec<crate::plugins::language_features::FormatterChoice> {
|
||||
crate::plugins::editor_plugin_host().map(|p| p.formatter_choices()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add(
|
||||
"plugin_language_visuals",
|
||||
|c| async move { plugin_language_visuals(&c.host, &c.window, c.arg("doc")?).await },
|
||||
);
|
||||
d.add("plugin_color_presentations", |c| async move {
|
||||
plugin_color_presentations(&c.host, &c.window, c.arg("doc")?, c.arg("plugin")?, c.arg("color")?).await
|
||||
});
|
||||
d.add("plugin_formatters", |_c| async move { Ok(plugin_formatters()) });
|
||||
d.add("lsp_completion", |c| async move {
|
||||
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
|
||||
});
|
||||
@@ -1408,6 +1759,32 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn language_features_merge_without_overwriting_lsp_ranges_or_masking_errors() {
|
||||
use super::*;
|
||||
let range = Range::new(Pos::default(), Pos::new(0, 1));
|
||||
let existing = Location::file("/p/a".into(), range);
|
||||
let merged = merge_language_locations(
|
||||
vec![existing.clone()],
|
||||
vec![
|
||||
ide_plugin::Location { path: "/p/a".into(), range },
|
||||
ide_plugin::Location { path: "/p/b".into(), range },
|
||||
],
|
||||
);
|
||||
assert_eq!(merged.len(), 2);
|
||||
assert_eq!(merged[0], existing);
|
||||
let merged = merge_language_highlights(
|
||||
vec![Highlight { range, kind: HighlightKind::Read }],
|
||||
vec![ide_plugin::language_features::Highlight { range, kind: "write".into() }],
|
||||
);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].kind, HighlightKind::Read);
|
||||
assert!(
|
||||
provider_server::<Vec<Location>>(Err(ide_core::IdeError::unavailable("no server"))).unwrap().is_empty()
|
||||
);
|
||||
assert!(provider_server::<Vec<Location>>(Err(ide_core::IdeError::cancelled())).is_err());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -1430,11 +1807,13 @@ mod tests {
|
||||
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
|
||||
use ide_core::{FileEdit, LanguageId};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let shared = root.join("shared.idx");
|
||||
let native = root.join("native.txt");
|
||||
std::fs::write(&shared, "old old").unwrap();
|
||||
std::fs::write(&native, "old").unwrap();
|
||||
// Exercise legacy verbatim index paths alongside ordinary native URI paths.
|
||||
let shared = std::fs::canonicalize(shared).unwrap();
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
|
||||
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
|
||||
let symbol = IndexedSymbol {
|
||||
@@ -1446,8 +1825,6 @@ mod tests {
|
||||
ranges: vec![range, other],
|
||||
}],
|
||||
};
|
||||
// Native file URIs have ordinary Windows paths; index snapshots retain
|
||||
// canonical verbatim paths. Both identify the same file and edit batch.
|
||||
let server_shared = dunce::simplified(&shared).to_path_buf();
|
||||
let server_native = dunce::simplified(&native).to_path_buf();
|
||||
#[cfg(windows)]
|
||||
@@ -1771,7 +2148,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(&root).unwrap();
|
||||
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
|
||||
title: "Create view".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! 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"))
|
||||
}
|
||||
@@ -98,3 +98,5 @@ impl Host {
|
||||
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod lsp_target;
|
||||
@@ -181,7 +181,7 @@ pub fn set_settings(
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
let toolchain = ["languages", "tools", "languageServers", "languageServerTargets", "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.
|
||||
|
||||
@@ -9,6 +9,9 @@ use std::time::Duration;
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.refresh_run_configurations(&project.root);
|
||||
}
|
||||
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
|
||||
}
|
||||
|
||||
@@ -199,8 +202,48 @@ 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> {
|
||||
let operation = config.extras.params.get("plugin.operation").cloned();
|
||||
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);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn start_inner(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
) -> IdeResult<RunInfo> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if config.extras.target.is_none() {
|
||||
config.extras.target = ide_sdk::apply::run_target(&config, &project.settings.effective()).map(str::to_owned);
|
||||
}
|
||||
if config.detected
|
||||
&& config.id.starts_with(ide_run::EXTERNAL_PREFIX)
|
||||
&& let Some(plugins) = host.plugin_host()
|
||||
{
|
||||
let provider = plugins.run_detection();
|
||||
let binary =
|
||||
provider.launch(&config).map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?;
|
||||
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
provider
|
||||
.detect(&project.root)
|
||||
.into_iter()
|
||||
.find(|c| c.id == config.id)
|
||||
.ok_or_else(|| IdeError::not_found("plugin configuration no longer available"))?
|
||||
.command
|
||||
} else {
|
||||
binary.to_string_lossy().into_owned()
|
||||
};
|
||||
config.detected = false;
|
||||
}
|
||||
let mut sink = project.sink.clone();
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
sink = plugins.run_operation_sink(window, &mut config, sink)?;
|
||||
}
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
@@ -210,6 +253,25 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
}
|
||||
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
if id.starts_with("plugin-target:") {
|
||||
let plugins =
|
||||
host.plugin_host().ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let root = project.root.clone();
|
||||
let id = id.to_owned();
|
||||
let config = host.blocking(move || plugins.prepare_run_target(&root, &id, &config)).await?;
|
||||
project.require_trust()?;
|
||||
return host.processes.start(
|
||||
&project.root,
|
||||
&config,
|
||||
&StartOptions {
|
||||
encoding: Some(settings.console.encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: Vec::new(),
|
||||
ruby_reporter: None,
|
||||
},
|
||||
sink,
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind != RunTargetKind::Ssh {
|
||||
// A local run of the docker CLI: the command is resolved in the container, and
|
||||
@@ -227,15 +289,12 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
&project.root,
|
||||
&config,
|
||||
&options,
|
||||
project.sink.clone(),
|
||||
sink.clone(),
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let out = on_output.clone();
|
||||
return host
|
||||
.remote
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
|
||||
}
|
||||
let mut config = config;
|
||||
let (root, settings) = settings_for(host, &project, &mut config)?;
|
||||
@@ -286,7 +345,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
ruby_reporter: ruby_reporter_dir(host, &config),
|
||||
};
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
|
||||
let previous = sink;
|
||||
let root = project.root.clone();
|
||||
@@ -317,6 +375,9 @@ fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathB
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.run_operation_stopped(id);
|
||||
}
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
|
||||
@@ -218,12 +218,13 @@ 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 mut plan = ide_build::web::build(
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
|
||||
)?;
|
||||
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)?;
|
||||
plan.env.extend(config.env.clone());
|
||||
let mut build = RunConfig {
|
||||
id: format!("tomcat-build:{}", config.id),
|
||||
|
||||
@@ -34,67 +34,12 @@ pub async fn start(
|
||||
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
|
||||
// ignored names are never searched.
|
||||
query.exclude.extend(project.search_excludes());
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let cancel = host.begin_request(request);
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let syntax = host.syntax();
|
||||
let summary = host
|
||||
.blocking(move || {
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
Some(ProjectFileSet::All) | None => true,
|
||||
};
|
||||
// Comments and string literals for the search context filter.
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
let found = syntax.literals(&language, text)?;
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.search_text_of(p),
|
||||
&cancel,
|
||||
&|mut file| {
|
||||
if dir != &root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
events(SearchEvent::File { file });
|
||||
},
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
total.elapsed_ms += s.elapsed_ms;
|
||||
total.limit_hit |= s.limit_hit;
|
||||
total.cancelled |= s.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
search_project(&project, &syntax, &query, &cancel, &|file| events(SearchEvent::File { file }))
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
@@ -102,5 +47,69 @@ pub async fn start(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared Find in Files engine: project excludes are added by the caller.
|
||||
pub(crate) fn search_project(
|
||||
project: &crate::project::Project,
|
||||
syntax: &ide_language::Syntax,
|
||||
query: &SearchQuery,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
emit: &(dyn Fn(FileMatches) + Sync),
|
||||
) -> IdeResult<SearchSummary> {
|
||||
let root = &project.root;
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
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 {
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
_ => true,
|
||||
};
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = project.languages.detect(path, text.lines().next());
|
||||
Some(syntax.literals(&language, text)?.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(root).filter(|_| include_root).chain(&content_roots) {
|
||||
let mut remaining = query.clone();
|
||||
let max = if query.max_matches == 0 { 10_000 } else { query.max_matches };
|
||||
remaining.max_matches = max.saturating_sub(total.matches).max(1);
|
||||
let summary = ide_search::search_in_context(
|
||||
dir,
|
||||
&remaining,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| project.documents.search_text_of(p),
|
||||
cancel,
|
||||
&|mut file| {
|
||||
if dir != root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
emit(file);
|
||||
},
|
||||
)?;
|
||||
total.files += summary.files;
|
||||
total.matches += summary.matches;
|
||||
total.elapsed_ms += summary.elapsed_ms;
|
||||
total.limit_hit |= summary.limit_hit || total.matches >= max;
|
||||
total.cancelled |= summary.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
+1373
-29
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
||||
//! One-shot OAuth redirects, always bound on the desktop's IPv4 loopback.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::HostError;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Ipv4Addr, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(super) trait Service: Send + Sync {
|
||||
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError>;
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError>;
|
||||
fn close(&self, plugin: &str, id: u64);
|
||||
fn clear(&self, plugin: &str);
|
||||
fn forget_window(&self, window: &str);
|
||||
}
|
||||
struct Entry {
|
||||
window: String,
|
||||
path: String,
|
||||
listener: Mutex<Option<TcpListener>>,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Loopbacks {
|
||||
next: AtomicU64,
|
||||
entries: Mutex<HashMap<(String, u64), Arc<Entry>>>,
|
||||
}
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
fn respond(stream: &mut TcpStream, ok: bool) {
|
||||
let body = if ok { "You can close this tab." } else { "Not found." };
|
||||
let status = if ok { "200 OK" } else { "404 Not Found" };
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
impl Loopbacks {
|
||||
fn remove_where(&self, predicate: impl Fn(&str, &Entry) -> bool) {
|
||||
self.entries.lock().retain(|(p, _), entry| {
|
||||
if !predicate(p, entry) {
|
||||
return true;
|
||||
}
|
||||
entry.cancel.cancel();
|
||||
entry.listener.lock().take();
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Service for Loopbacks {
|
||||
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError> {
|
||||
let path = path.trim_start_matches('/');
|
||||
if path.len() > 1024
|
||||
|| path.bytes().any(|b| !(b.is_ascii_alphanumeric() || b"/-._~".contains(&b)))
|
||||
|| path.split('/').any(|s| matches!(s, "." | ".."))
|
||||
{
|
||||
return Err(HostError::Invalid("loopback path must be a URL path without query or fragment".into()));
|
||||
}
|
||||
let mut entries = self.entries.lock();
|
||||
if entries.keys().filter(|(p, _)| p == plugin).count() >= 2 {
|
||||
return Err(HostError::Invalid("at most two open loopbacks per plugin".into()));
|
||||
}
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(failed)?;
|
||||
listener.set_nonblocking(true).map_err(failed)?;
|
||||
let uri = format!("http://127.0.0.1:{}/{path}", listener.local_addr().map_err(failed)?.port());
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
(plugin.into(), id),
|
||||
Arc::new(Entry {
|
||||
window: window.into(),
|
||||
path: format!("/{path}"),
|
||||
listener: Mutex::new(Some(listener)),
|
||||
cancel: CancellationToken::new(),
|
||||
}),
|
||||
);
|
||||
Ok((id, uri))
|
||||
}
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let entry = self
|
||||
.entries
|
||||
.lock()
|
||||
.get(&(plugin.into(), id))
|
||||
.cloned()
|
||||
.ok_or_else(|| HostError::NotFound("loopback".into()))?;
|
||||
let listener =
|
||||
entry.listener.lock().take().ok_or_else(|| HostError::Invalid("loopback already awaited".into()))?;
|
||||
let deadline = Instant::now() + timeout;
|
||||
let result = (|| loop {
|
||||
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((mut stream, address)) => {
|
||||
if !address.ip().is_loopback() {
|
||||
continue;
|
||||
}
|
||||
stream.set_nonblocking(true).map_err(failed)?;
|
||||
let mut head = Vec::new();
|
||||
loop {
|
||||
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut buf = [0; 1024];
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
head.extend_from_slice(&buf[..n]);
|
||||
if head.windows(4).any(|w| w == b"\r\n\r\n") || head.len() > 8192 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5))
|
||||
}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
}
|
||||
let line = std::str::from_utf8(&head).unwrap_or_default().lines().next().unwrap_or_default();
|
||||
let target = line.strip_prefix("GET ").and_then(|s| s.split_once(' ')).map(|(target, _)| target);
|
||||
let query = target.and_then(|target| {
|
||||
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
||||
(path == entry.path && head.len() <= 8192).then(|| query.to_owned())
|
||||
});
|
||||
stream.set_nonblocking(false).map_err(failed)?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(100))).map_err(failed)?;
|
||||
respond(&mut stream, query.is_some());
|
||||
if query.is_some() {
|
||||
return Ok(query);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
})();
|
||||
self.close(plugin, id);
|
||||
result
|
||||
}
|
||||
fn close(&self, plugin: &str, id: u64) {
|
||||
let entry = self.entries.lock().remove(&(plugin.into(), id));
|
||||
if let Some(entry) = entry {
|
||||
entry.cancel.cancel();
|
||||
entry.listener.lock().take();
|
||||
}
|
||||
}
|
||||
fn clear(&self, plugin: &str) {
|
||||
self.remove_where(|p, _| p == plugin || p.starts_with("remote:") && p.ends_with(&format!(":{plugin}")));
|
||||
}
|
||||
fn forget_window(&self, window: &str) {
|
||||
self.remove_where(|_, entry| entry.window == window);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn one_shot_raw_query_limits_timeout_and_cleanup() {
|
||||
let service = Arc::new(Loopbacks::default());
|
||||
let (id, uri) = service.begin("a", "main", "oauth/callback").unwrap();
|
||||
assert!(uri.starts_with("http://127.0.0.1:"));
|
||||
let (other, _) = service.begin("a", "main", "").unwrap();
|
||||
assert!(service.begin("a", "main", "third").is_err());
|
||||
assert_eq!(service.wait("a", other, Duration::ZERO, &CancellationToken::new()).unwrap(), None);
|
||||
let host = uri.strip_prefix("http://").unwrap().split('/').next().unwrap().to_owned();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut stream = TcpStream::connect(host).unwrap();
|
||||
stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=x HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
|
||||
let mut answer = String::new();
|
||||
stream.read_to_string(&mut answer).unwrap();
|
||||
assert!(answer.contains("You can close this tab"));
|
||||
});
|
||||
assert_eq!(
|
||||
service.wait("a", id, Duration::from_secs(2), &CancellationToken::new()).unwrap(),
|
||||
Some("code=a%2Bb&state=x".into())
|
||||
);
|
||||
worker.join().unwrap();
|
||||
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
|
||||
let (id, _) = service.begin("a", "main", "callback").unwrap();
|
||||
service.forget_window("main");
|
||||
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Database ownership stays on the project host; credentials never pass back to guests.
|
||||
use super::api::PluginApi;
|
||||
use super::{PluginHost, WindowRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult};
|
||||
use ide_db::{DataSource, DbDriver, DbProperty};
|
||||
use ide_plugin::{HostError, PluginDataSource};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct OwnedProject {
|
||||
root: PathBuf,
|
||||
sources: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
pub(super) fn settings(source: PluginDataSource) -> Result<DataSource, HostError> {
|
||||
let bad = || HostError::Invalid("invalid non-secret database source settings".into());
|
||||
if source.id.is_empty()
|
||||
|| source.id.len() > 128
|
||||
|| source.name.trim().is_empty()
|
||||
|| source.name.len() > 256
|
||||
|| source.id.chars().chain(source.name.chars()).any(char::is_control)
|
||||
|| source.options.len() > 128
|
||||
|| [&source.host, &source.database, &source.user, &source.url]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|v| v.len() > 16_384 || v.contains('\0'))
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
let driver = match source.driver.as_str() {
|
||||
"sqlite" => DbDriver::Sqlite,
|
||||
"postgres" => DbDriver::Postgres,
|
||||
"mysql" => DbDriver::Mysql,
|
||||
"sqlServer" => DbDriver::SqlServer,
|
||||
"clickhouse" => DbDriver::Clickhouse,
|
||||
"oracle" => DbDriver::Oracle,
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
let secret = |key: &str| {
|
||||
let key = key.to_ascii_lowercase();
|
||||
["password", "passwd", "pwd", "token", "secret", "credential"].iter().any(|s| key.contains(s))
|
||||
};
|
||||
if source.options.iter().any(|(k, v)| {
|
||||
k.is_empty() || k.len() > 256 || v.len() > 16_384 || k.contains('\0') || v.contains('\0') || secret(k)
|
||||
}) {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut out = DataSource {
|
||||
name: source.name,
|
||||
driver,
|
||||
host: source.host.unwrap_or_else(|| "localhost".into()),
|
||||
port: source.port,
|
||||
database: source.database.unwrap_or_default(),
|
||||
user: source.user.unwrap_or_default(),
|
||||
save_password: true,
|
||||
properties: source.options.into_iter().map(|(name, value)| DbProperty { name, value }).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
if driver == DbDriver::Sqlite {
|
||||
out.file = Some(out.database.clone().into());
|
||||
}
|
||||
if let Some(url) = source.url.filter(|u| !u.is_empty()) {
|
||||
if url.contains(';') {
|
||||
return Err(bad());
|
||||
}
|
||||
let parsed = ide_db::source::parse_url(&url).map_err(|_| bad())?;
|
||||
if parsed.password.is_some() || parsed.params.iter().any(|(k, _)| secret(k)) {
|
||||
return Err(bad());
|
||||
}
|
||||
// Case-insensitive URL credential keys must not bypass the parser's lowercase password key.
|
||||
if let Some((_, query)) = url.split_once('?') {
|
||||
for part in query.split('&') {
|
||||
let key = part.split('=').next().unwrap_or_default();
|
||||
// Parsing a synthetic URL decodes percent-encoded parameter names as well.
|
||||
let probe = ide_db::source::parse_url(&format!("postgres://host/db?{key}=x")).map_err(|_| bad())?;
|
||||
if probe.password.is_some() || probe.params.iter().any(|(k, _)| secret(k)) {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.url_only = true;
|
||||
out.url = url;
|
||||
out.resolved().map_err(|_| bad())?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
pub(super) fn database_host(&self, root: &Path) -> Result<(Arc<crate::Host>, Arc<ide_db::DbManager>), HostError> {
|
||||
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
|
||||
if !workspace.projects().iter().any(|r| r == root) {
|
||||
return Err(HostError::NotFound("project is not open".into()));
|
||||
}
|
||||
if !workspace.trusted(root) {
|
||||
return Err(HostError::Denied("project is not trusted".into()));
|
||||
}
|
||||
ide_plugin::resolve_new_in_root(root, ".openidea/data-sources.toml").map_err(HostError::Invalid)?;
|
||||
let host = workspace
|
||||
.database_host()
|
||||
.ok_or_else(|| HostError::Invalid("Database subsystem is unavailable on this host".into()))?;
|
||||
let db = host.projects().into_iter().find(|p| p.root == root).ok_or(HostError::Cancelled)?.db.clone();
|
||||
Ok((host, db))
|
||||
}
|
||||
pub(super) fn remember_database_project(&self, root: &Path, id: &str) -> Result<(), HostError> {
|
||||
let file = self.shared.data_dir.join(".database-projects").join(format!("{}.json", self.pkg.id()));
|
||||
let mut projects: Vec<OwnedProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(e.to_string()))?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => vec![],
|
||||
Err(e) => return Err(HostError::Failed(e.to_string())),
|
||||
};
|
||||
if !projects.iter().any(|p| p.root == root) {
|
||||
projects.push(OwnedProject { root: root.into(), sources: Default::default() });
|
||||
}
|
||||
let project = projects.iter_mut().find(|p| p.root == root).unwrap();
|
||||
if project.sources.insert(id.into()) {
|
||||
std::fs::create_dir_all(file.parent().unwrap()).map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&file, &serde_json::to_vec(&projects).unwrap()).map_err(error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn connect_database(
|
||||
&self,
|
||||
root: &Path,
|
||||
client: &str,
|
||||
id: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let (_, db) = self.database_host(root)?;
|
||||
if self.shared.project_of(client).as_deref() != Some(root) {
|
||||
return Err(HostError::Invalid("requires this project's invoking window".into()));
|
||||
}
|
||||
let source = db.plugin_source(self.pkg.id(), id).map_err(error)?;
|
||||
self.window_request(client, WindowRequest::DatabaseConnect { id: source.id }, cancel, timeout).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn changed(host: &crate::Host, root: &Path) {
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
project.sink.emit(IdeEvent::DbSourcesChanged);
|
||||
}
|
||||
}
|
||||
impl PluginHost {
|
||||
pub(super) fn remove_database_sources(&self, plugin: &str) -> IdeResult<()> {
|
||||
let file = self.shared.data_dir.join(".database-projects").join(format!("{plugin}.json"));
|
||||
let projects: Vec<OwnedProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string()))?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let host = self
|
||||
.shared
|
||||
.workspace()
|
||||
.and_then(|w| w.database_host())
|
||||
.ok_or_else(|| IdeError::unavailable("Database subsystem is unavailable on this host"))?;
|
||||
for project in projects {
|
||||
let root = project.root;
|
||||
let removed = ide_db::plugin::remove_all(&root, plugin)?;
|
||||
let store = ide_db::secrets::SecretStore::new(host.data_dir.join("db-passwords.json"), &root);
|
||||
let ids: std::collections::BTreeSet<_> = project
|
||||
.sources
|
||||
.iter()
|
||||
.map(|id| ide_db::plugin::source_id(plugin, id))
|
||||
.chain(removed.into_iter().map(|source| source.id))
|
||||
.collect();
|
||||
for id in ids {
|
||||
store.delete(&id);
|
||||
store.delete(&ide_db::secrets::ssh_key(&id));
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
host.runtime.block_on(project.db.forget_plugin_source(&id));
|
||||
}
|
||||
}
|
||||
changed(&host, &root);
|
||||
}
|
||||
std::fs::remove_file(file)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn error(e: IdeError) -> HostError {
|
||||
use ide_core::error::ErrorKind;
|
||||
match e.kind {
|
||||
ErrorKind::NotFound => HostError::NotFound(e.message),
|
||||
ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
ErrorKind::PermissionDenied => HostError::Denied(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn source() -> PluginDataSource {
|
||||
PluginDataSource { id: "local".into(), name: "Local".into(), driver: "postgres".into(), ..Default::default() }
|
||||
}
|
||||
#[test]
|
||||
fn rejects_secrets_without_echoing_them_and_documents_every_driver_id() {
|
||||
for driver in ["sqlite", "postgres", "mysql", "sqlServer", "clickhouse", "oracle"] {
|
||||
assert!(settings(PluginDataSource { driver: driver.into(), ..source() }).is_ok(), "{driver}");
|
||||
}
|
||||
for url in [
|
||||
"postgres://user:top-secret@host/db",
|
||||
"postgres://host/db?password=top-secret",
|
||||
"postgres://host/db?PASSWORD=top-secret",
|
||||
"postgres://host/db?to%6ben=top-secret",
|
||||
] {
|
||||
let error = settings(PluginDataSource { url: Some(url.into()), ..source() }).unwrap_err();
|
||||
assert!(!error.to_string().contains("top-secret"));
|
||||
}
|
||||
for key in ["password", "accessToken", "client_secret", "PWD"] {
|
||||
assert!(
|
||||
settings(PluginDataSource { options: vec![(key.into(), "top-secret".into())], ..source() }).is_err()
|
||||
);
|
||||
}
|
||||
let source = settings(PluginDataSource {
|
||||
url: Some("postgres://reader@host:5433/db?sslmode=require".into()),
|
||||
options: vec![("application_name".into(), "Example".into())],
|
||||
..source()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(source.resolved().unwrap().host, "host");
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,9 @@ impl Diagnostics {
|
||||
doc.cancel.cancel();
|
||||
doc.cancel = CancellationToken::new();
|
||||
let Some(host) = editor_plugin_host() else { return };
|
||||
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
|
||||
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty()
|
||||
&& !host.providers.any(ProviderKind::Injections)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Register only once per project incarnation; no project is retained by the host.
|
||||
@@ -113,6 +115,7 @@ impl Diagnostics {
|
||||
snapshot.text = current.text;
|
||||
drop(documents);
|
||||
|
||||
let injections = host.providers.injection_generations(&snapshot.language);
|
||||
let generations = host.providers.diagnostic_generations(&snapshot.language);
|
||||
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
|
||||
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
|
||||
@@ -124,6 +127,11 @@ impl Diagnostics {
|
||||
if state.closed || cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if injections != host.providers.injection_generations(&snapshot.language)
|
||||
|| injections.keys().any(|id| lifecycle_generations.get(id) != state.generations.get(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let matching: HashSet<_> = host
|
||||
.diagnostic_plugins(Some(&root), &snapshot.language)
|
||||
.into_iter()
|
||||
@@ -224,6 +232,13 @@ pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
|
||||
impl PluginHost {
|
||||
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
|
||||
// Clone before taking a project's state lock (edits register in the other order).
|
||||
let injector = self.get(plugin).is_ok_and(|p| {
|
||||
p.manifest().declarations("providers").iter().any(|d| {
|
||||
d.get("kinds")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|v| v.iter().any(|k| k.as_str() == Some("injections")))
|
||||
})
|
||||
});
|
||||
let projects = self.diagnostic_projects.lock().clone();
|
||||
for (state, sink) in projects {
|
||||
if let Some(state) = state.upgrade() {
|
||||
@@ -231,7 +246,15 @@ impl PluginHost {
|
||||
// A completed answer can still be waiting for another provider.
|
||||
*state.generations.entry(plugin.to_owned()).or_default() += 1;
|
||||
for doc in state.documents.values_mut() {
|
||||
if doc.sources.remove(plugin) {
|
||||
if injector {
|
||||
for source in doc.sources.drain() {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: doc.snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{source}"),
|
||||
items: vec![],
|
||||
});
|
||||
}
|
||||
} else if doc.sources.remove(plugin) {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: doc.snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{plugin}"),
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validates_entire_rooted_batch_with_utf16_positions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let file = root.join("routes.rb");
|
||||
std::fs::write(&file, "😀x\n").unwrap();
|
||||
let good = endpoint(file.clone());
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Window-owned plugin operations. Instance reclamation does not end them.
|
||||
use super::api::{PluginApi, PromptEntry, Waiter};
|
||||
use super::{CommandAnswer, EventQueue, PluginEvent, PluginHost, WindowRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_plugin::{ContentTab, ContentTabReply, HostError, WorkbenchEvent};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(super) struct Operation {
|
||||
pub plugin: String,
|
||||
pub client: String,
|
||||
pub kind: OperationKind,
|
||||
pub root: Option<std::path::PathBuf>,
|
||||
pub started: bool,
|
||||
pub closed: bool,
|
||||
pub run: Option<ide_core::RunId>,
|
||||
pub terminal: Option<(std::sync::Weak<crate::Host>, ide_core::TerminalId)>,
|
||||
}
|
||||
|
||||
pub(super) enum OperationKind {
|
||||
Run { config: String, configuration: String, program: String },
|
||||
Terminal { key: String, command: ide_plugin::ProcessCommand },
|
||||
}
|
||||
|
||||
pub(super) fn label(value: &str, max: usize) -> Result<(), HostError> {
|
||||
if value.is_empty() || value.encode_utf16().count() > max || value.chars().any(char::is_control) {
|
||||
return Err(HostError::Invalid(format!("expected 1-{max} characters without controls")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn body(value: &str) -> Result<(), HostError> {
|
||||
if value.encode_utf16().count() > 1 << 20 || value.contains('\0') {
|
||||
return Err(HostError::Invalid("content too large or contains NUL".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_tab(tab: &ContentTab) -> Result<(), HostError> {
|
||||
label(&tab.key, 128)?;
|
||||
label(&tab.title, 256)?;
|
||||
body(&tab.content)?;
|
||||
if let Some(name) = &tab.file_name {
|
||||
label(name, 256)?;
|
||||
if name.contains(['/', '\\']) {
|
||||
return Err(HostError::Invalid("file-name cannot contain separators".into()));
|
||||
}
|
||||
}
|
||||
if let Some(language) = &tab.language {
|
||||
label(language, 64)?;
|
||||
}
|
||||
if tab.actions.len() > 16 || tab.actions.iter().filter(|a| a.save).count() > 1 {
|
||||
return Err(HostError::Invalid("at most 16 actions and one Save action".into()));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for action in &tab.actions {
|
||||
label(&action.id, 64)?;
|
||||
label(&action.label, 128)?;
|
||||
if !ids.insert(&action.id) {
|
||||
return Err(HostError::Invalid("duplicate action id".into()));
|
||||
}
|
||||
if let Some(icon) = &action.icon {
|
||||
label(icon, 128)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
|
||||
if let Some(content) = &reply.content {
|
||||
body(content)?;
|
||||
}
|
||||
if reply.status.as_ref().is_some_and(|s| s.len() > 512) || reply.error.as_ref().is_some_and(|s| s.len() > 1024) {
|
||||
return Err(HostError::Invalid("content status or error too long".into()));
|
||||
}
|
||||
if let Some(diff) = &reply.diff {
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
if let Some(title) = &diff.title {
|
||||
label(title, 256)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
pub(super) fn new_operation(&self, client: &str, kind: OperationKind) -> Result<String, HostError> {
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if closed.contains(client) || blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if operations.values().filter(|o| o.plugin == self.pkg.id()).count() >= 32 {
|
||||
return Err(HostError::Invalid("at most 32 active operations".into()));
|
||||
}
|
||||
let id = format!("plugin-op-{}", self.shared.next_prompt.fetch_add(1, Ordering::Relaxed));
|
||||
operations.insert(
|
||||
id.clone(),
|
||||
Operation {
|
||||
plugin: self.pkg.id().into(),
|
||||
client: client.into(),
|
||||
kind,
|
||||
root: self.shared.project_of(client),
|
||||
started: false,
|
||||
closed: false,
|
||||
terminal: None,
|
||||
run: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
pub(super) fn window_request(
|
||||
&self,
|
||||
client: &str,
|
||||
request: WindowRequest,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if closed.contains(client) || blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared.prompts.lock().insert(
|
||||
id,
|
||||
PromptEntry { plugin: self.pkg.id().into(), client: client.into(), answer: Waiter::Command(tx) },
|
||||
);
|
||||
}
|
||||
self.shared.sink.emit(
|
||||
Some(client),
|
||||
PluginEvent::WindowRequest { plugin_id: self.pkg.id().into(), request_id: id.to_string(), request },
|
||||
);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(20)) {
|
||||
Ok(CommandAnswer::Value(value)) => return Ok(value),
|
||||
Ok(CommandAnswer::NotFound) => return Err(HostError::NotFound("window service unavailable".into())),
|
||||
Ok(CommandAnswer::Failed(error)) => return Err(HostError::Failed(error)),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if !cancel.is_cancelled() && Instant::now() < deadline => {}
|
||||
Err(_) => {
|
||||
self.shared.prompts.lock().remove(&id);
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// The launcher alone may finish its operation; duplicate and foreign reports are ignored.
|
||||
pub fn finish_operation(&self, client: &str, id: &str, status: Option<i32>, stopped: bool) -> bool {
|
||||
let operation = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if !operations.get(id).is_some_and(|op| op.client == client) {
|
||||
return false;
|
||||
}
|
||||
operations.remove(id).unwrap()
|
||||
};
|
||||
let event = match operation.kind {
|
||||
OperationKind::Run { config, .. } => {
|
||||
WorkbenchEvent::RunExited(ide_plugin::RunExited { id: id.into(), config, status, killed: stopped })
|
||||
}
|
||||
OperationKind::Terminal { key, .. } => WorkbenchEvent::TerminalExited(ide_plugin::TerminalExited {
|
||||
id: id.into(),
|
||||
key,
|
||||
status,
|
||||
closed: stopped,
|
||||
}),
|
||||
};
|
||||
self.post_owned_event(&operation.plugin, client, event);
|
||||
true
|
||||
}
|
||||
/// Content actions bypass subscriptions, just like completions of owned operations.
|
||||
pub fn content_tab_action(
|
||||
&self,
|
||||
client: &str,
|
||||
plugin: &str,
|
||||
action: ide_plugin::ContentTabAction,
|
||||
) -> IdeResult<()> {
|
||||
let tabs = self.shared.content_tabs.lock();
|
||||
let tab = tabs
|
||||
.get(&(plugin.into(), client.into(), action.key.clone()))
|
||||
.ok_or_else(|| IdeError::not_found("unknown content tab"))?;
|
||||
if !tab.actions.iter().any(|a| a.id == action.action) {
|
||||
return Err(IdeError::invalid("unknown content action"));
|
||||
}
|
||||
body(&action.content).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
label(&action.request_id, 128).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
drop(tabs);
|
||||
let mut pending = self.shared.content_requests.lock();
|
||||
pending.retain(|_, (_, created)| created.elapsed() < Duration::from_secs(15));
|
||||
if pending.keys().filter(|(p, w, _)| p == plugin && w == client).count() >= 32 {
|
||||
return Err(IdeError::invalid("too many pending content actions"));
|
||||
}
|
||||
pending.insert((plugin.into(), client.into(), action.request_id.clone()), (action.key.clone(), Instant::now()));
|
||||
drop(pending);
|
||||
self.post_owned_event(plugin, client, WorkbenchEvent::ContentTabAction(action));
|
||||
Ok(())
|
||||
}
|
||||
fn post_owned_event(&self, plugin: &str, client: &str, event: WorkbenchEvent) {
|
||||
if self.shared.client_closed(client) {
|
||||
return;
|
||||
}
|
||||
self.queues
|
||||
.lock()
|
||||
.entry((plugin.into(), Some(client.into())))
|
||||
.or_insert_with(|| EventQueue { since: Instant::now(), events: Vec::new(), files_source: None })
|
||||
.events
|
||||
.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic configurations opt in with marker files, just like static detectors.
|
||||
pub(super) fn configurations_declaration(
|
||||
pkg: &ide_extension::Package,
|
||||
value: toml::Value,
|
||||
) -> Result<ide_extension::UiDecl, String> {
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
files: Vec<String>,
|
||||
}
|
||||
pkg.manifest.needs_api("contributes.runConfigurations", 4)?;
|
||||
let decl: Decl = ide_extension::decode("runConfigurations", value)?;
|
||||
if pkg.manifest.wasm.is_none() || decl.files.is_empty() || decl.files.len() > 16 {
|
||||
return Err("runConfigurations needs code and 1-16 marker files".into());
|
||||
}
|
||||
for file in &decl.files {
|
||||
ide_extension::check_relative(std::path::Path::new(file), "files")?;
|
||||
}
|
||||
Ok(ide_extension::UiDecl::new(
|
||||
serde_json::to_string(&decl.files).unwrap(),
|
||||
"Run configurations",
|
||||
serde_json::to_value(decl).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Refreshes configuration exports on the same listing path as static detectors.
|
||||
pub fn refresh_run_configurations(&self, root: &std::path::Path) {
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
|
||||
return;
|
||||
}
|
||||
let owners: std::collections::BTreeSet<_> = self
|
||||
.ui
|
||||
.items("runConfigurations", None)
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
d.decl["files"]
|
||||
.as_array()
|
||||
.is_some_and(|files| files.iter().filter_map(|f| f.as_str()).any(|f| root.join(f).is_file()))
|
||||
})
|
||||
.map(|d| d.plugin_id)
|
||||
.collect();
|
||||
self.shared.run_configurations.write().retain(|(p, r), _| r != root || owners.contains(p));
|
||||
for plugin in owners {
|
||||
let project = root.to_path_buf();
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(project.clone()), ..Default::default() };
|
||||
let result = self.code_call(&plugin, invocation, "run-configurations", |rt, ctx| {
|
||||
rt.run_configurations(
|
||||
ctx.workspace_root.unwrap().to_string_lossy().into_owned(),
|
||||
Duration::from_secs(2),
|
||||
)
|
||||
});
|
||||
let Ok(items) = result else { continue };
|
||||
if let Err(error) = validate_configurations(root, &items) {
|
||||
tracing::warn!(%plugin, %error, "invalid run configurations");
|
||||
continue;
|
||||
}
|
||||
self.shared.run_configurations.write().insert((plugin, project), items);
|
||||
}
|
||||
}
|
||||
/// Resolves only an owned plugin test configuration in the client's trusted project.
|
||||
fn test_configuration(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
|
||||
let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?;
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) {
|
||||
return Err(IdeError::invalid("project is not trusted"));
|
||||
}
|
||||
let Some(rest) = config.strip_prefix(ide_run::EXTERNAL_PREFIX) else { return Ok(None) };
|
||||
let Some((owner, id)) = rest.split_once(':') else { return Ok(None) };
|
||||
let configuration = self
|
||||
.shared
|
||||
.workspace()
|
||||
.ok_or_else(IdeError::cancelled)?
|
||||
.run_configuration(client, owner, id)
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if !configuration.is_test || configuration.extras.params.get("framework").map(String::as_str) != Some("plugin")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some((root, owner.to_owned(), id.to_owned())))
|
||||
}
|
||||
|
||||
/// Derives replacement argv for a user action, under the command budget.
|
||||
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
|
||||
if tests.is_empty()
|
||||
|| tests.len() > 10000
|
||||
|| tests.iter().any(|p| p.is_empty() || p.len() > 128)
|
||||
|| serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
|
||||
let plugin = self.get(&owner)?;
|
||||
let (actor, activated) = self.ensure_active(&plugin, None)?;
|
||||
if activated {
|
||||
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 started = Instant::now();
|
||||
let result = actor.call(Some(timeout), move |rt| rt.select_tests(id, tests, invocation, timeout));
|
||||
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))
|
||||
}
|
||||
|
||||
/// Parses a bounded batch under the editor-provider soft/hard budgets.
|
||||
pub fn parse_test_results(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
chunk: String,
|
||||
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
|
||||
if chunk.len() > 64 << 10 {
|
||||
return Err(IdeError::invalid("test output batch too large"));
|
||||
}
|
||||
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
|
||||
let plugin = self.get(&owner)?;
|
||||
let (actor, activated) = self.ensure_active(&plugin, None)?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(root.clone()), ..Default::default() };
|
||||
let (id, hard) = (id.to_owned(), self.limits.edit_hard);
|
||||
let started = Instant::now();
|
||||
let result =
|
||||
actor.call(Some(self.limits.edit_soft), move |rt| rt.parse_test_results(id, chunk, invocation, hard));
|
||||
let mut events = self.finish_provider_call(&plugin, &actor, started, result);
|
||||
if let Some(events) = &mut events {
|
||||
if serde_json::to_vec(&*events).is_ok_and(|b| b.len() > 4 << 20) {
|
||||
return Ok(None);
|
||||
}
|
||||
for event in events {
|
||||
let location = match event {
|
||||
ide_plugin::PluginTestEvent::SuiteStarted(v) | ide_plugin::PluginTestEvent::TestStarted(v) => {
|
||||
v.location.as_mut()
|
||||
}
|
||||
ide_plugin::PluginTestEvent::Failed(v) => v.location.as_mut(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(location) = location {
|
||||
let Ok(path) = ide_plugin::resolve_new_in_root(&root, &location.path.to_string_lossy()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if location.range.end < location.range.start {
|
||||
return Ok(None);
|
||||
}
|
||||
location.path = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_configurations(
|
||||
root: &std::path::Path,
|
||||
items: &[ide_plugin::PluginRunConfiguration],
|
||||
) -> Result<(), HostError> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
if items.len() > 1000 || serde_json::to_vec(items).is_ok_and(|b| b.len() > 4 << 20) {
|
||||
return Err(HostError::Invalid("too many run configurations".into()));
|
||||
}
|
||||
for c in items {
|
||||
ide_extension::validate_item_id(&c.id, "run configuration id").map_err(HostError::Invalid)?;
|
||||
label(&c.name, 128)?;
|
||||
if !ids.insert(&c.id)
|
||||
|| c.program.is_empty()
|
||||
|| c.program.len() > 256
|
||||
|| c.program.contains(char::is_whitespace)
|
||||
|| c.args.len() > 256
|
||||
|| c.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
|
||||
{
|
||||
return Err(HostError::Invalid("invalid run configuration".into()));
|
||||
}
|
||||
if let Some(cwd) = &c.cwd {
|
||||
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Resolves and rechecks the pin immediately before starting a keyed terminal's PTY.
|
||||
pub fn spawn_terminal_session(
|
||||
self: &std::sync::Arc<Self>,
|
||||
host: &std::sync::Arc<crate::Host>,
|
||||
client: &str,
|
||||
operation: &str,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
let (plugin, command) = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
let op = operations
|
||||
.get_mut(operation)
|
||||
.filter(|op| op.client == client)
|
||||
.ok_or_else(|| IdeError::not_found("unknown terminal operation"))?;
|
||||
if op.started || op.closed || op.root != self.shared.project_of(client) {
|
||||
return Err(IdeError::invalid("terminal operation already started or project changed"));
|
||||
}
|
||||
let OperationKind::Terminal { command, .. } = &op.kind else {
|
||||
return Err(IdeError::invalid("not a terminal operation"));
|
||||
};
|
||||
op.started = true;
|
||||
(op.plugin.clone(), command.clone())
|
||||
};
|
||||
let options = super::web::WebPtyCommand {
|
||||
program: command.program,
|
||||
args: command.args,
|
||||
env: command.env.into_iter().collect(),
|
||||
cols,
|
||||
rows,
|
||||
};
|
||||
let weak = Arc::downgrade(self);
|
||||
let (id, window) = (operation.to_owned(), client.to_owned());
|
||||
let completion = Arc::new(move |status| {
|
||||
if let Some(plugins) = weak.upgrade() {
|
||||
let closed = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
|
||||
plugins.finish_operation(&window, &id, status, closed);
|
||||
}
|
||||
});
|
||||
let result = self.spawn_plugin_pty(host, &plugin, client, output, Some(completion), || {
|
||||
self.pty_options(&plugin, client, options)
|
||||
});
|
||||
if let Ok(info) = &result {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if let Some(op) = operations.get_mut(operation) {
|
||||
op.terminal = Some((Arc::downgrade(host), info.id));
|
||||
if op.closed {
|
||||
let _ = host.terminals.kill(info.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.is_err()
|
||||
&& let Some(op) = self.shared.operations.lock().get_mut(operation)
|
||||
{
|
||||
op.started = false;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Closing a tab marks the operation before killing its PTY. The real exit emits the event.
|
||||
pub fn close_terminal_session(&self, client: &str, id: &str) -> IdeResult<()> {
|
||||
let terminal = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
let Some(op) = operations
|
||||
.get_mut(id)
|
||||
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Terminal { .. }))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
op.closed = true;
|
||||
op.terminal.clone()
|
||||
};
|
||||
if let Some((host, id)) = terminal
|
||||
&& let Some(host) = host.upgrade()
|
||||
{
|
||||
host.terminals.kill(id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Rechecks the owned configuration's program before Debug and every IDE rerun.
|
||||
pub fn prepare_debug_run(&self, client: &str, mut config: ide_run::RunConfig) -> IdeResult<ide_run::RunConfig> {
|
||||
let Some((plugin, id)) = config.id.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':'))
|
||||
else {
|
||||
return Err(IdeError::invalid("not a plugin configuration"));
|
||||
};
|
||||
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
|
||||
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if owned.debug_adapter.is_none() {
|
||||
return Err(IdeError::invalid("this configuration has no debugger"));
|
||||
}
|
||||
if config.extras.target.is_none() {
|
||||
config.extras.target = owned.extras.target.clone();
|
||||
}
|
||||
if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("debugging this target is not supported"));
|
||||
}
|
||||
config.command = self
|
||||
.program_for_project(plugin, &owned.command, self.shared.project_of(client).as_deref())?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
config.debug_adapter = owned.debug_adapter;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal launch metadata; it is ignored after the original operation finishes.
|
||||
pub(super) const RUN_OPERATION: &str = "plugin.operation";
|
||||
impl PluginHost {
|
||||
pub(crate) fn run_operation_sink(
|
||||
self: &Arc<Self>,
|
||||
client: &str,
|
||||
config: &mut ide_run::RunConfig,
|
||||
previous: Arc<dyn ide_core::EventSink>,
|
||||
) -> IdeResult<Arc<dyn ide_core::EventSink>> {
|
||||
let Some(id) = config.extras.params.get(RUN_OPERATION).cloned() else { return Ok(previous) };
|
||||
let origin = self.shared.operations.lock().get(&id).and_then(|op| {
|
||||
if op.client != client {
|
||||
return None;
|
||||
}
|
||||
let OperationKind::Run { configuration, program, .. } = &op.kind else { return None };
|
||||
(configuration == &config.id).then(|| (op.plugin.clone(), op.root.clone(), program.clone()))
|
||||
});
|
||||
let Some((plugin, root, program)) = origin else { return Ok(previous) };
|
||||
let binary = self.program_for_project(&plugin, &program, root.as_deref())?;
|
||||
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
program
|
||||
} else {
|
||||
binary.to_string_lossy().into_owned()
|
||||
};
|
||||
config.detected = false;
|
||||
let (weak, client) = (Arc::downgrade(self), client.to_owned());
|
||||
Ok(Arc::new(move |event: ide_core::IdeEvent| {
|
||||
if let ide_core::IdeEvent::RunExited { code, .. } = &event
|
||||
&& let Some(plugins) = weak.upgrade()
|
||||
{
|
||||
let stopped = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
|
||||
plugins.finish_operation(&client, &id, *code, stopped);
|
||||
}
|
||||
previous.emit(event);
|
||||
}))
|
||||
}
|
||||
pub(crate) fn run_operation_started(&self, client: &str, operation: &str, run: ide_core::RunId) {
|
||||
if let Some(op) = self
|
||||
.shared
|
||||
.operations
|
||||
.lock()
|
||||
.get_mut(operation)
|
||||
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Run { .. }))
|
||||
{
|
||||
op.run = Some(run);
|
||||
}
|
||||
}
|
||||
pub(crate) fn run_operation_stopped(&self, run: ide_core::RunId) {
|
||||
for op in self.shared.operations.lock().values_mut().filter(|op| op.run == Some(run)) {
|
||||
op.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ pub async fn plugin_navigation_index(
|
||||
&project.root,
|
||||
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some(client.clone()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: None,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Embedded documents preserve the host's UTF-16 coordinates.
|
||||
use super::{PluginHost, ProviderKind};
|
||||
use ide_core::{Pos, Range};
|
||||
use ide_plugin::{ProviderDocument, language_features::Injection};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Allowed output ranges for one virtual document; the host document is unrestricted.
|
||||
pub(super) struct Bounds(Option<Vec<Range>>);
|
||||
impl Bounds {
|
||||
pub fn unrestricted() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
pub fn contains(&self, range: Range) -> bool {
|
||||
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start <= range.start && range.end <= r.end))
|
||||
}
|
||||
pub fn lines(&self, start: u32, end: u32) -> bool {
|
||||
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start.line <= start && end <= r.end.line))
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub fn provide_injections(&self, doc: &ProviderDocument) -> Vec<Injection> {
|
||||
if !self.providers.any(ProviderKind::Injections) {
|
||||
return vec![];
|
||||
}
|
||||
if doc.text.len() > super::navigation::MAX_DOCUMENT_BYTES {
|
||||
return Vec::new();
|
||||
}
|
||||
let text = ide_text::Document::new(&doc.text);
|
||||
let d = Arc::new(doc.clone());
|
||||
let mut items: Vec<_> = self
|
||||
.document_calls(ProviderKind::Injections, doc, move |rt, t| rt.provide_injections(&d, t))
|
||||
.into_iter()
|
||||
.flat_map(|(_, v)| v)
|
||||
.filter(|i| {
|
||||
!i.range.is_empty()
|
||||
&& super::language_features::valid_range_in(&text, i.range)
|
||||
&& i.language != doc.language
|
||||
&& !i.language.is_empty()
|
||||
&& i.language.len() <= 64
|
||||
&& i.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
|
||||
})
|
||||
.collect();
|
||||
items.sort_by_key(|i| i.range);
|
||||
let mut end = Pos::default();
|
||||
items.retain(|i| {
|
||||
if i.range.start < end {
|
||||
return false;
|
||||
}
|
||||
end = i.range.end;
|
||||
true
|
||||
});
|
||||
items.truncate(1000);
|
||||
items
|
||||
}
|
||||
|
||||
pub(super) fn feature_documents(&self, doc: &ProviderDocument, at: Option<Pos>) -> Vec<(ProviderDocument, Bounds)> {
|
||||
let injections = self.provide_injections(doc);
|
||||
let mut languages = Vec::new();
|
||||
for i in &injections {
|
||||
if at.is_some_and(|p| p < i.range.start || p >= i.range.end) || languages.contains(&i.language) {
|
||||
continue;
|
||||
}
|
||||
languages.push(i.language.clone());
|
||||
if languages.len() == 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut docs = Vec::new();
|
||||
for language in languages {
|
||||
let ranges = injections.iter().filter(|i| i.language == language).map(|i| i.range).collect();
|
||||
let text = virtual_text(doc, &injections, &language);
|
||||
docs.push((ProviderDocument { language, text, ..doc.clone() }, Bounds(Some(ranges))));
|
||||
}
|
||||
docs.push((doc.clone(), Bounds(None)));
|
||||
docs
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], language: &str) -> String {
|
||||
let ranges: Vec<_> = ranges.iter().filter(|i| i.language == language).map(|i| i.range).collect();
|
||||
let mut index = 0;
|
||||
let mut at = Pos::default();
|
||||
let mut text = String::with_capacity(doc.text.len());
|
||||
for c in doc.text.chars() {
|
||||
while index < ranges.len() && ranges[index].end <= at {
|
||||
index += 1;
|
||||
}
|
||||
let inside = ranges.get(index).is_some_and(|r| r.start <= at && at < r.end);
|
||||
if inside || c == '\n' || c == '\r' {
|
||||
text.push(c);
|
||||
} else {
|
||||
for _ in 0..c.len_utf16() {
|
||||
text.push(' ');
|
||||
}
|
||||
}
|
||||
if c == '\n' {
|
||||
at.line += 1;
|
||||
at.col = 0;
|
||||
} else {
|
||||
at.col += c.len_utf16() as u32;
|
||||
}
|
||||
}
|
||||
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::*;
|
||||
#[test]
|
||||
fn virtual_documents_preserve_utf16_columns_and_newlines() {
|
||||
let d = ProviderDocument {
|
||||
path: "/p/a".into(),
|
||||
language: "host".into(),
|
||||
version: 7,
|
||||
text: "😀 sql\n outside\n sql".into(),
|
||||
};
|
||||
let ranges = vec![
|
||||
Injection { range: Range::new(Pos::new(0, 3), Pos::new(0, 6)), language: "sql".into() },
|
||||
Injection { range: Range::new(Pos::new(2, 1), Pos::new(2, 4)), language: "sql".into() },
|
||||
];
|
||||
let text = virtual_text(&d, &ranges, "sql");
|
||||
assert_eq!(text, " sql\n \n sql");
|
||||
assert_eq!(text.encode_utf16().count(), d.text.encode_utf16().count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Plugin-owned local byte streams, closed on resource drop and plugin shutdown.
|
||||
use super::api::net_runtime;
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::HostError;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
|
||||
struct Connection {
|
||||
stream: tokio::sync::Mutex<Box<dyn Stream>>,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
impl Drop for Connection {
|
||||
fn drop(&mut self) {
|
||||
self.closed.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Connections {
|
||||
next: AtomicU64,
|
||||
entries: Mutex<HashMap<(String, u64), Arc<Connection>>>,
|
||||
}
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
impl Connections {
|
||||
pub fn connect(
|
||||
&self,
|
||||
plugin: &str,
|
||||
name: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<u64, HostError> {
|
||||
let check = |entries: &HashMap<(String, u64), Arc<Connection>>| {
|
||||
if entries.keys().filter(|(p, _)| p == plugin).count() >= 8 {
|
||||
Err(HostError::Invalid("at most eight IPC connections per plugin".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
check(&self.entries.lock())?;
|
||||
let stream: Box<dyn Stream> = net_runtime().block_on(async {
|
||||
let connect = async {
|
||||
#[cfg(unix)]
|
||||
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::UnixStream::connect(name).await.map_err(failed)?)) }
|
||||
#[cfg(windows)]
|
||||
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(name).map_err(failed)?)) }
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{ let _ = name; Err(HostError::Failed("local IPC unavailable".into())) }
|
||||
};
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(timeout, connect) => r.map_err(|_| HostError::Failed("IPC connection timed out".into()))?,
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
}
|
||||
})?;
|
||||
let mut entries = self.entries.lock();
|
||||
check(&entries)?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
(plugin.into(), id),
|
||||
Arc::new(Connection { stream: tokio::sync::Mutex::new(stream), closed: CancellationToken::new() }),
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
fn get(&self, plugin: &str, id: u64) -> Result<Arc<Connection>, HostError> {
|
||||
self.entries
|
||||
.lock()
|
||||
.get(&(plugin.into(), id))
|
||||
.cloned()
|
||||
.ok_or_else(|| HostError::NotFound("IPC connection".into()))
|
||||
}
|
||||
pub fn write(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
bytes: Vec<u8>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let entry = self.get(plugin, id)?;
|
||||
net_runtime().block_on(async {
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(timeout, async { entry.stream.lock().await.write_all(&bytes).await.map_err(failed) }) => r.map_err(|_| HostError::Failed("IPC write timed out".into()))?,
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
() = entry.closed.cancelled() => Err(HostError::Cancelled),
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn read(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
max: usize,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Vec<u8>, HostError> {
|
||||
let entry = self.get(plugin, id)?;
|
||||
net_runtime().block_on(async {
|
||||
let read = async {
|
||||
let mut bytes = vec![0; max.min(1 << 20)];
|
||||
let count = entry.stream.lock().await.read(&mut bytes).await.map_err(failed)?;
|
||||
bytes.truncate(count);
|
||||
Ok(bytes)
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
() = entry.closed.cancelled() => Err(HostError::Cancelled),
|
||||
r = read => r,
|
||||
() = tokio::time::sleep(timeout) => Ok(Vec::new()),
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn close(&self, plugin: &str, id: u64) {
|
||||
if let Some(entry) = self.entries.lock().remove(&(plugin.into(), id)) {
|
||||
entry.closed.cancel();
|
||||
}
|
||||
}
|
||||
pub fn clear(&self, plugin: &str) {
|
||||
self.entries.lock().retain(|(p, _), entry| {
|
||||
if p != plugin {
|
||||
return true;
|
||||
}
|
||||
entry.closed.cancel();
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
#[test]
|
||||
fn echo_limit_and_shutdown() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("echo.sock");
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut bytes = [0; 4];
|
||||
stream.read_exact(&mut bytes).unwrap();
|
||||
stream.write_all(&bytes).unwrap();
|
||||
// Accept the remaining seven connections before shutdown.
|
||||
for _ in 1..8 {
|
||||
listener.accept().unwrap();
|
||||
}
|
||||
});
|
||||
let pool = Connections::default();
|
||||
let token = CancellationToken::new();
|
||||
let timeout = Duration::from_secs(2);
|
||||
let id = pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
|
||||
pool.write("a", id, b"echo".to_vec(), &token, timeout).unwrap();
|
||||
assert_eq!(pool.read("a", id, 4, timeout, &token).unwrap(), b"echo");
|
||||
for _ in 1..8 {
|
||||
pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
|
||||
}
|
||||
assert!(matches!(pool.connect("a", path.to_str().unwrap(), &token, timeout), Err(HostError::Invalid(_))));
|
||||
assert!(pool.read("b", id, 4, timeout, &token).is_err());
|
||||
pool.clear("a");
|
||||
assert!(pool.read("a", id, 4, timeout, &token).is_err());
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! API 1.4 language features. Results are bounded and checked against the snapshot.
|
||||
use super::{PluginHost, ProviderKind};
|
||||
use ide_core::{IdeResult, Pos, Range};
|
||||
use ide_plugin::{ProviderDocument, language_features::*};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn valid_range(doc: &ProviderDocument, range: Range) -> bool {
|
||||
valid_range_in(&ide_text::Document::new(&doc.text), range)
|
||||
}
|
||||
pub(super) fn valid_range_in(text: &ide_text::Document, range: Range) -> bool {
|
||||
if range.start > range.end {
|
||||
return false;
|
||||
}
|
||||
[range.start, range.end].iter().all(|p| text.offset_to_pos(text.pos_to_offset(*p)) == *p)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub fn language_format(
|
||||
&self,
|
||||
id: &str,
|
||||
doc: &ProviderDocument,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<ide_core::TextEdit>> {
|
||||
let candidates = if let Some(r) = range {
|
||||
self.feature_documents(doc, Some(r.start))
|
||||
} else {
|
||||
vec![(doc.clone(), super::injections::Bounds::unrestricted())]
|
||||
};
|
||||
let (target, bounds) = candidates
|
||||
.into_iter()
|
||||
.find(|(d, b)| {
|
||||
range.is_none_or(|r| b.contains(r))
|
||||
&& self
|
||||
.provider_plugins(ProviderKind::Formatting, &d.language)
|
||||
.iter()
|
||||
.any(|p| p.id() == id && p.dev_root.as_ref().is_none_or(|r| d.path.starts_with(r)))
|
||||
})
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("The selected plugin formatter is unavailable"))?;
|
||||
let text = ide_text::Document::new(&target.text);
|
||||
let d = Arc::new(target);
|
||||
let edits = self.code_call(id, ide_plugin::Invocation::default(), "formatting", move |rt, _| {
|
||||
rt.language_format(&d, range, options.clone(), std::time::Duration::from_secs(5))
|
||||
})?;
|
||||
if edits.iter().any(|e| !valid_range_in(&text, e.range) || !bounds.contains(e.range)) {
|
||||
return Err(ide_core::IdeError::invalid("Plugin formatter returned an invalid range"));
|
||||
}
|
||||
let mut ranges: Vec<_> = edits.iter().map(|e| e.range).collect();
|
||||
ranges.sort();
|
||||
if ranges.windows(2).any(|p| p[0].end > p[1].start) {
|
||||
return Err(ide_core::IdeError::invalid("Plugin formatter returned overlapping edits"));
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
pub fn language_signature_help(&self, doc: &ProviderDocument, at: Pos) -> Option<SignatureHelp> {
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::SignatureHelp, &snapshot, move |rt, t| {
|
||||
rt.language_signature_help(&d, at, t)
|
||||
});
|
||||
for (_, v) in results {
|
||||
if let Some(v) = v.filter(|h| !h.signatures.is_empty()) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn language_document_highlights(&self, doc: &ProviderDocument, at: Pos) -> Vec<Highlight> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::DocumentHighlights, &snapshot, move |rt, t| {
|
||||
rt.language_document_highlights(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_document_links(&self, doc: &ProviderDocument) -> Vec<DocumentLink> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self
|
||||
.document_calls(ProviderKind::DocumentLinks, &snapshot, move |rt, t| rt.language_document_links(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_code_lenses(&self, doc: &ProviderDocument) -> Vec<(String, CodeLens)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results =
|
||||
self.document_calls(ProviderKind::CodeLenses, &snapshot, move |rt, t| rt.language_code_lenses(&d, t));
|
||||
for (p, items) in results {
|
||||
let commands: std::collections::HashSet<_> =
|
||||
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
|
||||
for i in items {
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) && commands.contains(&i.command) {
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_selection_ranges(&self, doc: &ProviderDocument, at: Pos) -> Vec<Range> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::SelectionRanges, &snapshot, move |rt, t| {
|
||||
rt.language_selection_ranges(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i) && i.start <= at && at <= i.end && bounds.contains(i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end)));
|
||||
out.dedup();
|
||||
let mut end = None;
|
||||
out.retain(|r| {
|
||||
let ok = end.is_none_or(|prev: Range| r.start <= prev.start && prev.end <= r.end);
|
||||
if ok {
|
||||
end = Some(*r);
|
||||
}
|
||||
ok
|
||||
});
|
||||
out
|
||||
}
|
||||
pub fn language_folding(&self, doc: &ProviderDocument) -> Vec<FoldRange> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let lines = d.text.split('\n').count() as u32;
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results =
|
||||
self.document_calls(ProviderKind::Folding, &snapshot, move |rt, t| rt.language_folding(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if i.start_line < i.end_line && i.end_line < lines && bounds.lines(i.start_line, i.end_line) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_colors(&self, doc: &ProviderDocument) -> Vec<(String, ColorInfo)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::Colors, &snapshot, move |rt, t| rt.language_colors(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
if valid_range_in(&text, i.range)
|
||||
&& bounds.contains(i.range)
|
||||
&& [i.red, i.green, i.blue, i.alpha].iter().all(|c| c.is_finite() && (0.0..=1.0).contains(c))
|
||||
{
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_color_presentations(&self, id: &str, doc: &ProviderDocument, color: ColorInfo) -> Vec<String> {
|
||||
for (d, bounds) in self.feature_documents(doc, Some(color.range.start)) {
|
||||
if !bounds.contains(color.range)
|
||||
|| !self.provider_plugins(ProviderKind::Colors, &d.language).iter().any(|p| p.id() == id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let color = color.clone();
|
||||
let result = self.document_calls_for(ProviderKind::Colors, &snapshot, Some(id), move |rt, t| {
|
||||
rt.language_color_presentations(&d, color.clone(), t)
|
||||
});
|
||||
if let Some((_, items)) = result.into_iter().find(|(p, _)| p.id() == id) {
|
||||
return items.into_iter().take(100).collect();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
pub fn language_document_symbols(&self, doc: &ProviderDocument) -> Vec<DocumentSymbol> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::DocumentSymbols, &snapshot, move |rt, t| {
|
||||
rt.language_document_symbols(&d, t)
|
||||
});
|
||||
for (_, items) in results {
|
||||
let mut remap = std::collections::HashMap::new();
|
||||
for (index, mut i) in items.into_iter().enumerate() {
|
||||
if !valid_range_in(&text, i.range)
|
||||
|| !valid_range_in(&text, i.selection_range)
|
||||
|| i.selection_range.start < i.range.start
|
||||
|| i.selection_range.end > i.range.end
|
||||
|| !bounds.contains(i.range)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
i.parent = i.parent.and_then(|p| remap.get(&(p as usize)).copied());
|
||||
remap.insert(index, out.len() as u32);
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_implementation(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
|
||||
let mut out = Vec::new();
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::Implementation, &snapshot, move |rt, t| {
|
||||
rt.language_implementation(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_type_definition(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
|
||||
let mut out = Vec::new();
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::TypeDefinition, &snapshot, move |rt, t| {
|
||||
rt.language_type_definition(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_gutter_actions(&self, doc: &ProviderDocument) -> Vec<(String, GutterAction)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let lines = d.text.split('\n').count() as u32;
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self
|
||||
.document_calls(ProviderKind::GutterActions, &snapshot, move |rt, t| rt.language_gutter_actions(&d, t));
|
||||
for (p, items) in results {
|
||||
let commands: std::collections::HashSet<_> =
|
||||
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
|
||||
for i in items {
|
||||
if i.line < lines && bounds.lines(i.line, i.line) && commands.contains(&i.command) {
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(crate) fn run_language_command(
|
||||
&self,
|
||||
plugin: &str,
|
||||
command: &str,
|
||||
argument: Option<&str>,
|
||||
invocation: ide_plugin::Invocation,
|
||||
) -> IdeResult<()> {
|
||||
if !self.ui.items(super::COMMANDS, Some(plugin)).iter().any(|c| c.key == command) {
|
||||
return Err(ide_core::IdeError::not_found("The plugin did not declare this command"));
|
||||
}
|
||||
self.run_command(plugin, command, argument, invocation).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn signature_help(h: SignatureHelp) -> ide_lsp::types::SignatureHelp {
|
||||
let signatures: Vec<_> = h
|
||||
.signatures
|
||||
.into_iter()
|
||||
.take(100)
|
||||
.map(|s| {
|
||||
let len = s.label.encode_utf16().count() as u32;
|
||||
let parameters = s
|
||||
.parameters
|
||||
.into_iter()
|
||||
.take(100)
|
||||
.map(|p| match p.label {
|
||||
ParameterLabel::Offsets((a, b)) => (a.min(len), b.max(a).min(len)),
|
||||
ParameterLabel::Text(t) => s
|
||||
.label
|
||||
.find(&t)
|
||||
.map(|a| {
|
||||
let start = s.label[..a].encode_utf16().count() as u32;
|
||||
(start, start + t.encode_utf16().count() as u32)
|
||||
})
|
||||
.unwrap_or((0, 0)),
|
||||
})
|
||||
.collect();
|
||||
ide_lsp::types::Signature {
|
||||
label: s.label,
|
||||
documentation: s.documentation.map(|value| ide_lsp::types::Markup { kind: "markdown".into(), value }),
|
||||
parameters,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let active_signature = h.active_signature.min(signatures.len().saturating_sub(1) as u32);
|
||||
let active_parameter =
|
||||
h.active_parameter
|
||||
.min(signatures.get(active_signature as usize).map(|s| s.parameters.len().saturating_sub(1)).unwrap_or(0)
|
||||
as u32);
|
||||
ide_lsp::types::SignatureHelp { signatures, active_signature, active_parameter }
|
||||
}
|
||||
|
||||
pub(crate) fn outline(items: Vec<DocumentSymbol>) -> Vec<ide_core::OutlineItem> {
|
||||
// A parent must precede its child, ruling out cycles and unbounded recursion.
|
||||
let mut depths = vec![0usize; items.len()];
|
||||
let mut parents = vec![None; items.len()];
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
if let Some(parent) = item.parent.map(|p| p as usize).filter(|p| {
|
||||
*p < idx
|
||||
&& depths[*p] < 63
|
||||
&& items[*p].range.start <= item.range.start
|
||||
&& item.range.end <= items[*p].range.end
|
||||
}) {
|
||||
parents[idx] = Some(parent);
|
||||
depths[idx] = depths[parent] + 1;
|
||||
}
|
||||
}
|
||||
let mut nodes: Vec<_> = items
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
Some(ide_core::OutlineItem {
|
||||
name: i.name,
|
||||
detail: Some(i.detail).filter(|s| !s.is_empty()),
|
||||
kind: serde_json::from_value(serde_json::Value::String(symbol_kind_name(&i.kind)))
|
||||
.unwrap_or(ide_core::SymbolKind::Unknown),
|
||||
range: i.range,
|
||||
selection_range: i.selection_range,
|
||||
children: vec![],
|
||||
visibility: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut roots = Vec::new();
|
||||
for idx in (0..nodes.len()).rev() {
|
||||
let mut node = nodes[idx].take().expect("once");
|
||||
node.children.reverse();
|
||||
if let Some(parent) = parents[idx] {
|
||||
nodes[parent].as_mut().expect("parent precedes child").children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
roots.reverse();
|
||||
roots
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FormatterChoice {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub languages: Vec<String>,
|
||||
}
|
||||
impl PluginHost {
|
||||
pub fn formatter_choices(&self) -> Vec<FormatterChoice> {
|
||||
let mut out = std::collections::BTreeMap::<String, FormatterChoice>::new();
|
||||
for (id, languages) in self.providers.formatter_declarations() {
|
||||
let Ok(p) = self.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(p.meta.lock().status, super::PluginStatus::Active | super::PluginStatus::Inactive) {
|
||||
continue;
|
||||
}
|
||||
out.entry(id.clone())
|
||||
.or_insert_with(|| FormatterChoice { id, name: p.pkg.manifest.name.clone(), languages: vec![] })
|
||||
.languages
|
||||
.extend(languages);
|
||||
}
|
||||
out.into_values()
|
||||
.map(|mut c| {
|
||||
c.languages.sort();
|
||||
c.languages.dedup();
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol_kind_name(kind: &str) -> String {
|
||||
let mut parts = kind.split('-');
|
||||
let mut out = parts.next().unwrap_or_default().to_owned();
|
||||
for part in parts {
|
||||
let mut chars = part.chars();
|
||||
if let Some(c) = chars.next() {
|
||||
out.extend(c.to_uppercase());
|
||||
out.extend(chars);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn outline_uses_parent_order_containment_and_string_kinds() {
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 10));
|
||||
let item = |name: &str, parent| DocumentSymbol {
|
||||
name: name.into(),
|
||||
detail: String::new(),
|
||||
kind: "enum-member".into(),
|
||||
range,
|
||||
selection_range: range,
|
||||
parent,
|
||||
};
|
||||
let out =
|
||||
outline(vec![item("root", None), item("child", Some(0)), item("future", Some(3)), item("other", Some(2))]);
|
||||
assert_eq!(out[0].children[0].kind, ide_core::SymbolKind::EnumMember);
|
||||
assert_eq!(out[1].name, "future");
|
||||
assert_eq!(out[1].children[0].name, "other");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Validated MCP declarations and dispatch through the ordinary plugin actor.
|
||||
use super::{PluginHost, PluginStatus};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::{Package, UiDecl, decode};
|
||||
use ide_plugin::Invocation;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(super) const POINT: &str = "mcpTools";
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Declaration {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: std::path::PathBuf,
|
||||
read_only: bool,
|
||||
destructive: bool,
|
||||
}
|
||||
fn tool_name(plugin: &str, name: &str) -> String {
|
||||
let plugin: String = plugin
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
format!("{plugin}__{name}")
|
||||
}
|
||||
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
|
||||
pkg.manifest.needs_api("contributes.mcpTools", 4)?;
|
||||
if pkg.manifest.wasm.is_none() {
|
||||
return Err("mcpTools requires wasm".into());
|
||||
}
|
||||
let d: Declaration = decode(POINT, value)?;
|
||||
if d.name.is_empty()
|
||||
|| d.name.len() > 40
|
||||
|| !d.name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
|
||||
|| tool_name(pkg.id(), &d.name).len() > 64
|
||||
|| d.description.trim().is_empty()
|
||||
|| d.description.len() > 4096
|
||||
{
|
||||
return Err(
|
||||
"mcpTools: name must match [a-z0-9_]{1,40}, namespaced name at most 64 bytes, description 1–4096 bytes"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let text = pkg.read_text(&d.input_schema)?;
|
||||
if text.len() > 256 << 10 {
|
||||
return Err("mcpTools: schema exceeds 256 KiB".into());
|
||||
}
|
||||
let schema: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("mcpTools: {e}"))?;
|
||||
if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
|
||||
return Err("mcpTools: inputSchema must describe an object".into());
|
||||
}
|
||||
// No HTTP or filesystem resolver features: schemas must be self-contained.
|
||||
jsonschema::validator_for(&schema).map_err(|e| format!("mcpTools: invalid JSON Schema: {e}"))?;
|
||||
let mut out = UiDecl::new(
|
||||
d.name.clone(),
|
||||
d.description.clone(),
|
||||
serde_json::json!({
|
||||
"name": d.name, "description": d.description, "inputSchema": schema, "readOnly": d.read_only, "destructive": d.destructive,
|
||||
}),
|
||||
);
|
||||
out.files.push(d.input_schema);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Live declarations only, refreshed on every list/call. Ambiguous normalized names fail closed.
|
||||
pub fn mcp_tools(&self, root: &std::path::Path) -> Vec<(String, String, ide_mcp::Tool)> {
|
||||
let mut tools = vec![];
|
||||
for item in self.ui.items(POINT, None) {
|
||||
let Ok(plugin) = self.get(&item.plugin_id) else { continue };
|
||||
if !matches!(plugin.status(), PluginStatus::Active | PluginStatus::Inactive)
|
||||
|| plugin.dev_root.as_deref().is_some_and(|r| r != root)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let d = &item.decl;
|
||||
let Some(name) = d["name"].as_str() else { continue };
|
||||
tools.push((
|
||||
plugin.id().to_owned(),
|
||||
name.to_owned(),
|
||||
ide_mcp::Tool {
|
||||
name: tool_name(plugin.id(), name),
|
||||
description: format!(
|
||||
"{} (plugin: {} [{}])",
|
||||
d["description"].as_str().unwrap_or_default(),
|
||||
plugin.manifest().name,
|
||||
plugin.id()
|
||||
),
|
||||
input_schema: d["inputSchema"].clone(),
|
||||
output_schema: None,
|
||||
read_only: d["readOnly"].as_bool().unwrap_or(false),
|
||||
destructive: d["destructive"].as_bool().unwrap_or(true),
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for (_, _, tool) in &tools {
|
||||
*counts.entry(tool.name.clone()).or_insert(0) += 1;
|
||||
}
|
||||
tools.retain(|(_, _, tool)| counts[&tool.name] == 1);
|
||||
tools
|
||||
}
|
||||
pub fn call_mcp_tool(
|
||||
&self,
|
||||
plugin: &str,
|
||||
tool: &str,
|
||||
arguments: &str,
|
||||
invocation: Invocation,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
if arguments.len() > 1 << 20 {
|
||||
return Err(IdeError::invalid("MCP arguments exceed 1 MiB"));
|
||||
}
|
||||
let root =
|
||||
invocation.workspace_root.as_ref().ok_or_else(|| IdeError::invalid("MCP requires a project window"))?;
|
||||
let (_, _, declared) = self
|
||||
.mcp_tools(std::path::Path::new(root))
|
||||
.into_iter()
|
||||
.find(|(p, n, _)| p == plugin && n == tool)
|
||||
.ok_or_else(|| IdeError::not_found("plugin MCP tool is no longer available"))?;
|
||||
let args: serde_json::Value = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let validator =
|
||||
jsonschema::validator_for(&declared.input_schema).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if !validator.is_valid(&args) {
|
||||
return Err(IdeError::invalid("MCP arguments do not match inputSchema"));
|
||||
}
|
||||
let (tool, arguments) = (tool.to_owned(), arguments.to_owned());
|
||||
self.code_call_cancellable(plugin, invocation, "calling the MCP tool", Some(cancel), move |rt, inv| {
|
||||
rt.mcp_call(&tool, &arguments, inv)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_names_schema_files_versions_and_schema_keywords_at_install() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = |api: &str| {
|
||||
Package::new(
|
||||
ide_extension::Manifest::parse(&format!(
|
||||
"id='test.tools'\nname='Tools'\nversion='1.0.0'\nengines.openidea='{api}'\nwasm='plugin.wasm'"
|
||||
))
|
||||
.unwrap(),
|
||||
tmp.path().into(),
|
||||
)
|
||||
};
|
||||
let decl = |name: &str, path: &str| {
|
||||
toml::from_str::<toml::Value>(&format!(
|
||||
"name='{name}'\ndescription='Tool'\ninputSchema='{path}'\nreadOnly=true\ndestructive=false"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
std::fs::write(
|
||||
tmp.path().join("args.json"),
|
||||
r##"{"type":"object","$defs":{"text":{"type":"string"}},"properties":{"text":{"$ref":"#/$defs/text"}}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(declaration(&pkg("^1.4"), decl("echo", "args.json")).unwrap().files.len(), 1);
|
||||
assert!(declaration(&pkg("^1.3"), decl("echo", "args.json")).is_err());
|
||||
for name in ["", "Bad", "bad-name", &"x".repeat(41)] {
|
||||
assert!(declaration(&pkg("^1.4"), decl(name, "args.json")).is_err());
|
||||
}
|
||||
assert!(declaration(&pkg("^1.4"), decl("echo", "../args.json")).is_err());
|
||||
for schema in [
|
||||
r#"{"type":"nonsense"}"#,
|
||||
r#"{"type":"object","required":42}"#,
|
||||
r#"{"type":"object","properties":{"x":{"$ref":"file:///private/secret"}}}"#,
|
||||
r#"{"type":"object","properties":{"x":{"$ref":"https://example.invalid/schema"}}}"#,
|
||||
] {
|
||||
std::fs::write(tmp.path().join("args.json"), schema).unwrap();
|
||||
assert!(declaration(&pkg("^1.4"), decl("echo", "args.json")).is_err(), "{schema}");
|
||||
}
|
||||
assert_eq!(tool_name("a.b-c", "echo"), "a_b_c__echo");
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,13 @@
|
||||
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
|
||||
|
||||
mod api;
|
||||
mod auth;
|
||||
pub mod commit;
|
||||
mod database;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
mod execution;
|
||||
mod files_changed;
|
||||
mod hover;
|
||||
mod icon_extensions;
|
||||
@@ -27,17 +30,23 @@ mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
pub mod index_ops;
|
||||
pub(crate) mod injections;
|
||||
mod inlay_hints;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod ipc;
|
||||
pub mod language_features;
|
||||
mod market;
|
||||
mod mcp;
|
||||
pub mod navigation;
|
||||
mod points;
|
||||
mod providers;
|
||||
pub mod remote;
|
||||
pub mod remote_host;
|
||||
mod run_detectors;
|
||||
pub mod run_targets;
|
||||
mod semantic_tokens;
|
||||
mod snippets;
|
||||
mod theme;
|
||||
mod types;
|
||||
pub mod ui_points;
|
||||
@@ -410,6 +419,11 @@ struct PendingUpdate {
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Current decoration snapshots for windows opening after a plugin published them.
|
||||
pub fn decoration_sets(&self) -> std::collections::BTreeMap<String, Vec<DecorationSet>> {
|
||||
self.shared.decoration_sets()
|
||||
}
|
||||
|
||||
/// Creates the host; call [`load_all`](Self::load_all) to load installed plugins.
|
||||
pub fn new(plugins_dir: PathBuf, sink: Arc<dyn PluginEventSink>, limits: Limits) -> IdeResult<Self> {
|
||||
std::fs::create_dir_all(&plugins_dir)?;
|
||||
@@ -653,6 +667,7 @@ impl PluginHost {
|
||||
|
||||
/// Unloads the project plugins of `root` (the project became untrusted or closed).
|
||||
pub fn restrict_project(&self, root: &Path) {
|
||||
self.shared.withdraw_run_targets(None, Some(root));
|
||||
let _guard = self.lifecycle.lock();
|
||||
let ids: Vec<_> = self
|
||||
.plugins
|
||||
@@ -1080,7 +1095,9 @@ impl PluginHost {
|
||||
ledger.set(&subject, &cap, granted);
|
||||
if granted
|
||||
&& let Capability::Process(program) = &cap
|
||||
&& let Some(bin) = self.resolve_binary(program)
|
||||
&& let Some(bin) = ledger
|
||||
.program_choice(&subject, &cap)
|
||||
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
|
||||
{
|
||||
ledger.set_binary(&subject, &cap, bin);
|
||||
}
|
||||
@@ -1097,14 +1114,74 @@ impl PluginHost {
|
||||
/// The program a `process:` grant allows, checked against the file
|
||||
/// approved for it. Errors ask the user to confirm again.
|
||||
pub fn program_for(&self, id: &str, program: &str) -> IdeResult<PathBuf> {
|
||||
self.program_for_project(id, program, None)
|
||||
}
|
||||
|
||||
/// Resolves the approved executable for the invoking project, verifying its pinned content.
|
||||
pub fn program_for_project(&self, id: &str, program: &str, root: Option<&Path>) -> IdeResult<PathBuf> {
|
||||
let plugin = self.get(id)?;
|
||||
let cap = Capability::Process(program.to_owned());
|
||||
self.ledger
|
||||
.lock()
|
||||
.check_binary(&plugin.subject(), &cap)
|
||||
.check_binary_in_project(&plugin.subject(), &Capability::Process(program.into()), root)
|
||||
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))
|
||||
}
|
||||
|
||||
/// Records a user-approved executable choice; removing it revalidates the fallback or revokes the grant.
|
||||
pub fn choose_program(
|
||||
&self,
|
||||
id: &str,
|
||||
capability: &str,
|
||||
root: Option<&Path>,
|
||||
path: Option<&Path>,
|
||||
) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
|
||||
let Capability::Process(program) = &cap else {
|
||||
return Err(IdeError::invalid("expected a process capability"));
|
||||
};
|
||||
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
|
||||
return Err(IdeError::invalid("undeclared process capability"));
|
||||
}
|
||||
let binary = if let Some(path) = path {
|
||||
Some(ledger::approve_program(path).map_err(IdeError::invalid)?)
|
||||
} else if root.is_none() {
|
||||
self.resolve_binary(program)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
{
|
||||
let mut ledger = self.ledger.lock();
|
||||
let subject = plugin.subject();
|
||||
if ledger.get(&subject).is_none() {
|
||||
return Err(IdeError::invalid("confirm this plugin's permissions first"));
|
||||
}
|
||||
if let Some(root) = root {
|
||||
ledger.set_project_binary(&subject, &cap, root, binary);
|
||||
if path.is_some() {
|
||||
ledger.set(&subject, &cap, true);
|
||||
} else if ledger.check_binary(&subject, &cap).is_err() {
|
||||
ledger.set(&subject, &cap, false);
|
||||
}
|
||||
} else if let Some(binary) = binary {
|
||||
ledger.set_program_choice(&subject, &cap, path);
|
||||
ledger.set(&subject, &cap, true);
|
||||
ledger.set_binary(&subject, &cap, binary);
|
||||
} else {
|
||||
ledger.set_program_choice(&subject, &cap, None);
|
||||
ledger.set(&subject, &cap, false);
|
||||
}
|
||||
self.save_ledger(&ledger);
|
||||
}
|
||||
self.stop(&plugin);
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
|
||||
fn check_uninstall_pending(&self, id: &str) -> IdeResult<()> {
|
||||
if install::uninstall_pending(&self.plugins_dir, id)? {
|
||||
return Err(IdeError::unavailable(std::io::Error::from(std::io::ErrorKind::ResourceBusy).to_string()));
|
||||
@@ -1151,6 +1228,7 @@ impl PluginHost {
|
||||
}
|
||||
self.stop(&plugin);
|
||||
let cleanup = || -> std::io::Result<()> {
|
||||
self.remove_database_sources(id).map_err(|e| std::io::Error::other(e.message))?;
|
||||
install::check_cleanup_parent(&self.plugins_dir, &self.shared.data_dir.join(id))?;
|
||||
install::check_cleanup_parent(&self.plugins_dir, plugin.dir())?;
|
||||
install::check_not_symlink(&self.shared.data_dir.join(id))?;
|
||||
@@ -1342,6 +1420,17 @@ impl PluginHost {
|
||||
invocation: Invocation,
|
||||
what: &str,
|
||||
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> IdeResult<R> {
|
||||
self.code_call_cancellable(id, invocation, what, None, f)
|
||||
}
|
||||
|
||||
fn code_call_cancellable<R: Send + 'static>(
|
||||
&self,
|
||||
id: &str,
|
||||
invocation: Invocation,
|
||||
what: &str,
|
||||
cancel: Option<&ide_core::CancellationToken>,
|
||||
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> IdeResult<R> {
|
||||
let plugin = self.get(id)?;
|
||||
let client = invocation.client.clone();
|
||||
@@ -1383,7 +1472,14 @@ impl PluginHost {
|
||||
}
|
||||
let started = Instant::now();
|
||||
let (call, inv) = (f.clone(), invocation.clone());
|
||||
let result = 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 matches!(result, Err(ActorError::Cancelled)) {
|
||||
self.reap(&plugin, &actor, client.as_deref());
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) {
|
||||
let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_))));
|
||||
// Cancelling or a missing grant is not the plugin failing.
|
||||
@@ -1457,7 +1553,12 @@ impl PluginHost {
|
||||
|
||||
/// Plugins' `runDetectors`, for [`ide_run::set_external_detection`].
|
||||
pub fn run_detection(&self) -> Arc<dyn ide_run::ExternalDetection> {
|
||||
Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() })
|
||||
Arc::new(run_detectors::PluginRunDetection {
|
||||
store: self.run_detectors.clone(),
|
||||
dynamic: self.shared.run_configurations.clone(),
|
||||
pins: self.pins.clone(),
|
||||
project_pins: self.shared.project_pins.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The syntax of plugin languages, for [`ide_language::set_external_syntax`].
|
||||
@@ -1513,6 +1614,10 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
/// Plugin context keys in effect for window `client`: global keys overlaid with its own.
|
||||
pub fn status_snapshot(&self, client: &str) -> Vec<PluginStatusItem> {
|
||||
self.shared.status_snapshot(client)
|
||||
}
|
||||
|
||||
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
|
||||
self.shared.context_keys(client)
|
||||
}
|
||||
@@ -1543,6 +1648,10 @@ impl PluginHost {
|
||||
|
||||
/// A window answered a plugin's `CommitRequested`; false when the call no
|
||||
/// longer waits.
|
||||
pub fn answer_command(&self, client: &str, request: &str, answer: CommandAnswer) -> bool {
|
||||
request.parse().is_ok_and(|id| self.shared.answer_command(client, id, answer))
|
||||
}
|
||||
|
||||
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
|
||||
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
|
||||
}
|
||||
@@ -1632,6 +1741,30 @@ impl PluginHost {
|
||||
|
||||
/// Queues workbench events from window `client` for the plugins that want
|
||||
/// them; [`flush_events`](Self::flush_events) delivers them in batches.
|
||||
pub fn post_window_events(&self, client: &str, events: Vec<WorkbenchEvent>, editor: Option<PluginEditor>) {
|
||||
let mut editors = self.shared.event_editors.write();
|
||||
if let Some(editor) = editor {
|
||||
editors.insert(client.into(), editor.context());
|
||||
} else {
|
||||
editors.remove(client);
|
||||
}
|
||||
drop(editors);
|
||||
self.post_events(
|
||||
Some(client),
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
WorkbenchEvent::RunExited(_)
|
||||
| WorkbenchEvent::TerminalExited(_)
|
||||
| WorkbenchEvent::ContentTabAction(_)
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn post_events(&self, client: Option<&str>, events: Vec<WorkbenchEvent>) {
|
||||
self.post_events_from(client, events, None);
|
||||
}
|
||||
@@ -1661,7 +1794,13 @@ impl PluginHost {
|
||||
.as_ref()
|
||||
.is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root))
|
||||
})
|
||||
.cloned()
|
||||
.filter_map(|e| match e {
|
||||
WorkbenchEvent::SettingsChanged(_) => {
|
||||
let keys = self.shared.changed_settings(&p.pkg, client);
|
||||
(!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys))
|
||||
}
|
||||
_ => Some(e.clone()),
|
||||
})
|
||||
.collect();
|
||||
let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs);
|
||||
if wanted.is_empty() {
|
||||
@@ -1682,7 +1821,15 @@ impl PluginHost {
|
||||
let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
|
||||
let mut remaining = excess;
|
||||
queue.events.retain(|event| {
|
||||
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
|
||||
if remaining > 0
|
||||
&& !matches!(
|
||||
event,
|
||||
WorkbenchEvent::FilesChanged(_)
|
||||
| WorkbenchEvent::RunExited(_)
|
||||
| WorkbenchEvent::TerminalExited(_)
|
||||
| WorkbenchEvent::ContentTabAction(_)
|
||||
)
|
||||
{
|
||||
remaining -= 1;
|
||||
false
|
||||
} else {
|
||||
@@ -1809,9 +1956,10 @@ impl PluginHost {
|
||||
let invocation = Invocation {
|
||||
workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)),
|
||||
client: client.clone(),
|
||||
editor: None,
|
||||
editor: client.as_deref().and_then(|c| self.shared.event_editors.read().get(c).cloned()),
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
command_chain: Vec::new(),
|
||||
};
|
||||
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
|
||||
let filesystem = kinds.contains(&EventKind::FilesChanged);
|
||||
@@ -1823,6 +1971,9 @@ impl PluginHost {
|
||||
if batches.running.as_ref() == Some(&kinds)
|
||||
&& !kinds.contains(&EventKind::Scheduled)
|
||||
&& !kinds.contains(&EventKind::FilesChanged)
|
||||
&& !kinds.iter().any(|k| {
|
||||
matches!(k, EventKind::RunExited | EventKind::TerminalExited | EventKind::ContentTabAction)
|
||||
})
|
||||
{
|
||||
actor.cancel_soft();
|
||||
}
|
||||
@@ -2599,8 +2750,21 @@ impl PluginHost {
|
||||
/// pick up a change on their next lookup.
|
||||
fn refresh_pins(&self, ledger: &Ledger) {
|
||||
let mut pins = HashMap::new();
|
||||
let mut project_pins = HashMap::new();
|
||||
for plugin in self.plugins.read().values() {
|
||||
let subject = plugin.subject();
|
||||
if let Some(grant) = ledger.get(&subject) {
|
||||
for (root, binaries) in &grant.project_binaries {
|
||||
for (cap, bin) in binaries {
|
||||
if grant.granted.contains(cap) {
|
||||
project_pins.insert(
|
||||
(plugin.id().to_owned(), cap.clone(), root.clone()),
|
||||
ProgramPin { path: bin.path.clone(), hash: bin.hash.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let declared =
|
||||
plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone());
|
||||
for cap in declared.filter(|c| matches!(c, Capability::Process(_))) {
|
||||
@@ -2612,6 +2776,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
}
|
||||
*self.shared.project_pins.write() = project_pins;
|
||||
let mut current = self.pins.write();
|
||||
if *current != pins {
|
||||
*current = pins;
|
||||
@@ -2665,12 +2830,26 @@ impl PluginHost {
|
||||
|
||||
fn record_consent(&self, request: &Request<'_>, consent: &Consent) {
|
||||
let accepted: Vec<Capability> = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect();
|
||||
let choices: BTreeMap<_, _> = {
|
||||
let ledger = self.ledger.lock();
|
||||
request
|
||||
.required
|
||||
.iter()
|
||||
.chain(&accepted)
|
||||
.filter_map(|cap| {
|
||||
ledger.program_choice(&request.subject, cap).map(|p| (cap.to_string(), p.to_path_buf()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let binaries: BTreeMap<String, BinaryGrant> = request
|
||||
.required
|
||||
.iter()
|
||||
.chain(&accepted)
|
||||
.filter_map(|c| match c {
|
||||
Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)),
|
||||
Capability::Process(program) => choices
|
||||
.get(&c.to_string())
|
||||
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
|
||||
.map(|b| (c.to_string(), b)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -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 = 2 << 20;
|
||||
pub const MAX_DOCUMENT_BYTES: usize = ide_lsp::MAX_INJECTED_DOCUMENT_BYTES;
|
||||
const MAX_FILES: usize = 20_000;
|
||||
const MAX_EDGES: usize = 4096;
|
||||
|
||||
@@ -197,6 +197,7 @@ impl PluginHost {
|
||||
/// navigation_at. Call off the UI thread, in small cancellable batches.
|
||||
pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> {
|
||||
let Snapshot { path, version, text, symbols, scope } = snapshot;
|
||||
let path = dunce::simplified(path);
|
||||
if !(self.project_policy.read())(root) {
|
||||
return Err(IdeError::unavailable("project is not trusted"));
|
||||
}
|
||||
|
||||
@@ -20,15 +20,23 @@ pub struct CommandDecl {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
/// Enablement, evaluated by the client against context keys (`editorLang == rust`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub when: Option<String>,
|
||||
}
|
||||
|
||||
fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
fn command(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let c: CommandDecl = decode(COMMANDS, decl)?;
|
||||
validate_item_id(&c.id, "command id")?;
|
||||
if let Some(icon) = &c.icon {
|
||||
pkg.manifest.needs_api("contributes.commands.icon", 4)?;
|
||||
if icon.is_empty() || icon.len() > 256 {
|
||||
return Err("command icon: expected 1–256 bytes".into());
|
||||
}
|
||||
}
|
||||
if c.title.trim().is_empty() {
|
||||
return Err(format!("command {:?} has an empty title", c.id));
|
||||
}
|
||||
@@ -40,7 +48,14 @@ fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
}
|
||||
let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c };
|
||||
let json = serde_json::to_value(&c).map_err(|e| e.to_string())?;
|
||||
Ok(UiDecl::new(c.id.clone(), c.title, json))
|
||||
let mut out = UiDecl::new(c.id.clone(), c.title, json);
|
||||
if let Some(icon) = c.icon.filter(|s| s.ends_with(".svg")) {
|
||||
let path = std::path::PathBuf::from(icon);
|
||||
let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?;
|
||||
out.files.push(path);
|
||||
out.assets.push((format!("commandIcon/{}", c.id), svg));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Registers every first-party extension point.
|
||||
@@ -60,6 +75,13 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?;
|
||||
registry.register(Arc::new(UiPoint::new(
|
||||
"runConfigurations",
|
||||
ui.clone(),
|
||||
super::execution::configurations_declaration,
|
||||
)))?;
|
||||
registry.register(Arc::new(UiPoint::new("snippets", ui.clone(), super::snippets::declaration)))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::mcp::POINT, ui.clone(), super::mcp::declaration)))?;
|
||||
use super::ui_points as p;
|
||||
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
|
||||
|
||||
@@ -42,6 +42,19 @@ pub enum ProviderKind {
|
||||
Hover,
|
||||
InlayHints,
|
||||
Diagnostics,
|
||||
Formatting,
|
||||
SignatureHelp,
|
||||
DocumentHighlights,
|
||||
DocumentLinks,
|
||||
CodeLenses,
|
||||
SelectionRanges,
|
||||
Folding,
|
||||
Colors,
|
||||
DocumentSymbols,
|
||||
Implementation,
|
||||
TypeDefinition,
|
||||
GutterActions,
|
||||
Injections,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -63,16 +76,29 @@ 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()
|
||||
@@ -94,9 +120,30 @@ impl Providers {
|
||||
}
|
||||
|
||||
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
|
||||
self.matching(ProviderKind::Diagnostics, language)
|
||||
if self.any(ProviderKind::Injections) {
|
||||
let mut out = HashMap::new();
|
||||
for (seq, plugin, kinds, _) in self.items.read().iter() {
|
||||
if kinds.contains(&ProviderKind::Diagnostics) {
|
||||
out.entry(plugin.clone()).or_insert(*seq);
|
||||
}
|
||||
}
|
||||
out
|
||||
} else {
|
||||
self.matching(ProviderKind::Diagnostics, language)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn injection_generations(&self, language: &str) -> HashMap<String, u64> {
|
||||
self.matching(ProviderKind::Injections, language)
|
||||
}
|
||||
pub(super) fn formatter_declarations(&self) -> Vec<(String, Vec<String>)> {
|
||||
self.items
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, _, k, _)| k.contains(&ProviderKind::Formatting))
|
||||
.map(|(_, p, _, l)| (p.clone(), l.clone()))
|
||||
.collect()
|
||||
}
|
||||
/// Whether any plugin provides `kind` (for any language).
|
||||
pub fn any(&self, kind: ProviderKind) -> bool {
|
||||
self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind))
|
||||
@@ -142,6 +189,26 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
if d.kinds.contains(&ProviderKind::InlayHints) {
|
||||
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
|
||||
}
|
||||
if d.kinds.iter().any(|k| {
|
||||
matches!(
|
||||
k,
|
||||
ProviderKind::Formatting
|
||||
| ProviderKind::SignatureHelp
|
||||
| ProviderKind::DocumentHighlights
|
||||
| ProviderKind::DocumentLinks
|
||||
| ProviderKind::CodeLenses
|
||||
| ProviderKind::SelectionRanges
|
||||
| ProviderKind::Folding
|
||||
| ProviderKind::Colors
|
||||
| ProviderKind::DocumentSymbols
|
||||
| ProviderKind::Implementation
|
||||
| ProviderKind::TypeDefinition
|
||||
| ProviderKind::GutterActions
|
||||
| ProviderKind::Injections
|
||||
)
|
||||
}) {
|
||||
pkg.manifest.needs_api("contributes.providers: language features and injections", 4)?;
|
||||
}
|
||||
for l in &d.languages {
|
||||
if l != "*"
|
||||
&& (l.is_empty()
|
||||
@@ -206,12 +273,13 @@ 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`.
|
||||
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
|
||||
let host = editor_plugin_host()?;
|
||||
host.providers.any(kind).then_some(host)
|
||||
(host.providers.any(kind) || host.providers.any(ProviderKind::Injections)).then_some(host)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
|
||||
@@ -236,15 +304,46 @@ impl PluginHost {
|
||||
/// declares `kind` for `language`, in plugin order, leaving out those that
|
||||
/// failed or were late. Each plugin runs on its own thread, so the calls
|
||||
/// run at the same time: the wait is the slowest plugin's, not the sum.
|
||||
fn edit_calls<R: Send + 'static>(
|
||||
pub(super) fn edit_calls<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
language: &str,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
self.edit_calls_plugins(self.provider_plugins(kind, language), f)
|
||||
}
|
||||
|
||||
pub(super) fn document_calls<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
doc: &ProviderDocument,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
self.document_calls_for(kind, doc, None, f)
|
||||
}
|
||||
pub(super) fn document_calls_for<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
doc: &ProviderDocument,
|
||||
owner: Option<&str>,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
let plugins = self
|
||||
.provider_plugins(kind, &doc.language)
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)) && owner.is_none_or(|id| p.id() == id)
|
||||
})
|
||||
.collect();
|
||||
self.edit_calls_plugins(plugins, f)
|
||||
}
|
||||
fn edit_calls_plugins<R: Send + 'static>(
|
||||
&self,
|
||||
plugins: Vec<Arc<LoadedPlugin>>,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard);
|
||||
let queued: Vec<_> = self
|
||||
.provider_plugins(kind, language)
|
||||
let queued: Vec<_> = plugins
|
||||
.into_iter()
|
||||
.filter_map(|plugin| {
|
||||
let (actor, activated) = self.ensure_active(&plugin, None).ok()?;
|
||||
@@ -303,13 +402,24 @@ impl PluginHost {
|
||||
at: ide_core::Pos,
|
||||
trigger: Option<&str>,
|
||||
) -> Vec<(String, CompletionItem)> {
|
||||
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
|
||||
.collect()
|
||||
self.feature_documents(doc, Some(at))
|
||||
.into_iter()
|
||||
.flat_map(|(doc, bounds)| {
|
||||
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
|
||||
.filter_map(move |(p, mut i)| {
|
||||
if i.replace.is_some_and(|r| !bounds.contains(r)) {
|
||||
return None;
|
||||
}
|
||||
i.additional_edits.retain(|e| bounds.contains(e.range));
|
||||
Some((p, i))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_code_actions(
|
||||
@@ -391,10 +501,20 @@ impl PluginHost {
|
||||
project: Option<&std::path::Path>,
|
||||
language: &str,
|
||||
) -> Vec<Arc<LoadedPlugin>> {
|
||||
self.provider_plugins(ProviderKind::Diagnostics, language)
|
||||
.into_iter()
|
||||
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
|
||||
.collect()
|
||||
let plugins = if self.providers.any(ProviderKind::Injections) {
|
||||
self.providers
|
||||
.diagnostic_generations(language)
|
||||
.keys()
|
||||
.filter_map(|id| self.get(id).ok())
|
||||
.filter(|p| {
|
||||
let m = p.meta.lock();
|
||||
matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty()
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
self.provider_plugins(ProviderKind::Diagnostics, language)
|
||||
};
|
||||
plugins.into_iter().filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project).collect()
|
||||
}
|
||||
|
||||
pub(super) fn provide_project_diagnostics(
|
||||
@@ -402,11 +522,35 @@ impl PluginHost {
|
||||
project: Option<&std::path::Path>,
|
||||
doc: &ProviderDocument,
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
|
||||
let mut out = std::collections::BTreeMap::<String, Vec<ide_core::Diagnostic>>::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
for (id, items) in self.provide_document_diagnostics(project, &d, cancel) {
|
||||
out.entry(id).or_default().extend(items.into_iter().filter(|i| bounds.contains(i.range)));
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.map(|(id, mut items)| {
|
||||
items.truncate(5000);
|
||||
(id, items)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provide_document_diagnostics(
|
||||
&self,
|
||||
project: Option<&std::path::Path>,
|
||||
doc: &ProviderDocument,
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
|
||||
std::thread::scope(|scope| {
|
||||
let calls: Vec<_> = self
|
||||
.diagnostic_plugins(project, &doc.language)
|
||||
.provider_plugins(ProviderKind::Diagnostics, &doc.language)
|
||||
.into_iter()
|
||||
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
|
||||
.map(|p| {
|
||||
scope.spawn(move || {
|
||||
if cancel.is_cancelled() {
|
||||
@@ -432,10 +576,15 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
|
||||
self.feature_documents(doc, Some(at))
|
||||
.into_iter()
|
||||
.filter_map(|(_, hover)| hover)
|
||||
.flat_map(|(doc, bounds)| {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.document_calls(ProviderKind::Hover, &doc, move |rt, timeout| rt.provide_hover(&d, at, timeout))
|
||||
.into_iter()
|
||||
.filter_map(|(_, h)| h)
|
||||
.filter(move |h| h.range.is_none_or(|r| bounds.contains(r)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
//! keychain and are asked for with a `plugin_secret` request
|
||||
//! ([`SecretRequest`]) when a request header needs one (rule 4).
|
||||
|
||||
use super::{
|
||||
Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore,
|
||||
};
|
||||
use super::{Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, SecretStore};
|
||||
use crate::rpc::Dispatcher;
|
||||
use base64::Engine as _;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
@@ -45,6 +43,32 @@ pub const SYNC_PACKAGES: &str = "plugin_sync_packages";
|
||||
pub const EVENT_NOTIFICATION: &str = "plugin";
|
||||
/// Server → client request ([`SecretRequest`] → `Option<String>`).
|
||||
pub const SECRET_REQUEST: &str = "plugin_secret";
|
||||
/// Mutations of declared secrets, kept on the desktop.
|
||||
pub const SECRET_WRITE: &str = "plugin_secret_write";
|
||||
/// OAuth listeners bound on the desktop, never the project server.
|
||||
pub const AUTH_REQUEST: &str = "plugin_auth";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SecretWrite {
|
||||
pub plugin: String,
|
||||
pub name: String,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
/// A remote plugin's operation on a desktop OAuth listener.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
pub plugin: String,
|
||||
pub operation: AuthOperation,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub enum AuthOperation {
|
||||
Begin { path: String },
|
||||
Wait { id: u64, timeout_ms: u32 },
|
||||
Close { id: u64 },
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// Under the server's data directory.
|
||||
pub const PLUGINS_DIR: &str = "remote-plugins";
|
||||
const INCOMING_DIR: &str = "remote-plugins.incoming";
|
||||
@@ -61,6 +85,13 @@ pub struct RemotePackage {
|
||||
pub digest: String,
|
||||
/// Capabilities the user granted on the desktop.
|
||||
pub granted: Vec<String>,
|
||||
/// Disabled packages remain installed so their persistent project data survives.
|
||||
#[serde(default = "enabled_by_default")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn enabled_by_default() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// One file of a package (`path` relative to the package, `data` base64).
|
||||
@@ -107,8 +138,18 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
|
||||
pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
PluginEvent::Notification { .. }
|
||||
PluginEvent::RunTargetsChanged
|
||||
| PluginEvent::WindowRequest { .. }
|
||||
| PluginEvent::ContentTabReply { .. }
|
||||
| PluginEvent::DecorationsChanged { .. }
|
||||
| PluginEvent::EditorOpen { .. }
|
||||
| PluginEvent::EditorRanges { .. }
|
||||
| PluginEvent::Notification { .. }
|
||||
| PluginEvent::StatusItemChanged { .. }
|
||||
| PluginEvent::WindowStatusItemChanged { .. }
|
||||
| PluginEvent::ExecuteCommand { .. }
|
||||
| PluginEvent::OutputChanged { .. }
|
||||
| PluginEvent::RevealOutput { .. }
|
||||
| PluginEvent::ContextChanged { .. }
|
||||
| PluginEvent::TaskStarted { .. }
|
||||
| PluginEvent::TaskProgress { .. }
|
||||
@@ -144,12 +185,16 @@ impl PluginHost {
|
||||
plugins
|
||||
.iter()
|
||||
.filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some())
|
||||
.filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive))
|
||||
.filter_map(|p| {
|
||||
let files = self.package_files(p).ok()?;
|
||||
let digest = package_digest(p.dir(), &files).ok()?;
|
||||
let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect();
|
||||
Some(RemotePackage { id: p.id().to_owned(), digest, granted })
|
||||
Some(RemotePackage {
|
||||
id: p.id().to_owned(),
|
||||
digest,
|
||||
granted,
|
||||
enabled: matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -218,8 +263,19 @@ impl SecretStore for ClientSecrets {
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
|
||||
Err("secrets are stored on the desktop".into())
|
||||
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
|
||||
let peer = self.peer.get().ok_or("desktop disconnected")?.clone();
|
||||
let request = SecretWrite { plugin: plugin.into(), name: name.into(), value: value.map(str::to_owned) };
|
||||
let ask = async {
|
||||
tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Result<(), String>>(SECRET_WRITE, request, None))
|
||||
.await
|
||||
};
|
||||
let answer = if tokio::runtime::Handle::try_current().is_ok() {
|
||||
tokio::task::block_in_place(|| self.runtime.block_on(ask))
|
||||
} else {
|
||||
self.runtime.block_on(ask)
|
||||
};
|
||||
answer.map_err(|_| "desktop timed out".to_owned())?.map_err(|e| e.to_string())?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +292,10 @@ impl RemotePlugins {
|
||||
}
|
||||
};
|
||||
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
|
||||
projects.set_plugin_host(&host);
|
||||
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
|
||||
host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() }));
|
||||
host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() }));
|
||||
*host.shared.loopbacks.write() = Arc::new(ClientLoopbacks { peer, runtime: projects.runtime.clone() });
|
||||
let trust = Arc::downgrade(projects);
|
||||
host.set_project_policy(Arc::new(move |root| {
|
||||
trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false))
|
||||
@@ -285,6 +343,14 @@ impl RemotePlugins {
|
||||
report.failed.push(format!("{}: invalid plugin id", pkg.id));
|
||||
continue;
|
||||
}
|
||||
if !pkg.enabled {
|
||||
if self.host.info_of(&pkg.id).is_ok()
|
||||
&& let Err(e) = self.host.set_enabled(&pkg.id, false)
|
||||
{
|
||||
report.failed.push(format!("{}: {e}", pkg.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The desktop asked the user, change of signer included.
|
||||
let consent = Consent { optional: pkg.granted.clone(), signer_change: true };
|
||||
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
|
||||
@@ -320,6 +386,9 @@ impl RemotePlugins {
|
||||
if have != want || info.status == super::PluginStatus::NeedsConsent {
|
||||
self.host.grant(&pkg.id, consent)?;
|
||||
}
|
||||
if info.status == super::PluginStatus::Disabled {
|
||||
self.host.set_enabled(&pkg.id, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -380,6 +449,7 @@ impl RemotePlugins {
|
||||
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
|
||||
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
|
||||
let invocation = Invocation {
|
||||
command_chain: c.arg::<Option<Vec<String>>>("commandChain")?.unwrap_or_default(),
|
||||
client: Some(c.window.clone()),
|
||||
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
|
||||
editor: editor.map(PluginEditor::context),
|
||||
@@ -398,10 +468,69 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_select_tests", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let config = c.arg::<String>("config")?;
|
||||
let tests = c.arg("tests")?;
|
||||
blocking(move || host.select_tests(&c.window, &config, tests)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_parse_test_results", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let config = c.arg::<String>("config")?;
|
||||
let chunk = c.arg("chunk")?;
|
||||
blocking(move || host.parse_test_results(&c.window, &config, chunk)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_terminal_session_close", move |c| {
|
||||
let h = h.clone();
|
||||
async move { alive(&h)?.close_terminal_session(&c.window, &c.arg::<String>("operation")?) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_terminal_session_spawn", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let operation: String = c.arg("operation")?;
|
||||
let cols = c.arg("cols")?;
|
||||
let rows = c.arg("rows")?;
|
||||
let output = c.channel("onOutput")?;
|
||||
blocking(move || plugins.spawn_terminal_session(&c.host, &c.window, &operation, cols, rows, output))
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_prepare_debug_run", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let config = c.arg("config")?;
|
||||
blocking(move || plugins.prepare_debug_run(&c.window, config)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_finish_operation", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
Ok(alive(&h)?.finish_operation(&c.window, &c.arg::<String>("id")?, c.arg("status")?, c.arg("stopped")?))
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_content_tab_action", move |c| {
|
||||
let h = h.clone();
|
||||
async move { alive(&h)?.content_tab_action(&c.window, &c.arg::<String>("pluginId")?, c.arg("action")?) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_post_events", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
alive(&h)?.post_events(Some(&c.window), c.arg("events")?);
|
||||
alive(&h)?.post_window_events(&c.window, c.arg("events")?, c.arg("editor")?);
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
@@ -411,15 +540,9 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.event_kinds().into_iter().collect::<Vec<_>>()) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_status_items", move |_| {
|
||||
d.add("plugin_status_items", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
Ok(alive(&h)?
|
||||
.status_items()
|
||||
.into_iter()
|
||||
.map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item })
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
async move { Ok(alive(&h)?.status_snapshot(&c.window)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_context_keys", move |c| {
|
||||
@@ -437,6 +560,43 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_choose_program", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let id: String = c.arg("pluginId")?;
|
||||
let cap: String = c.arg("capability")?;
|
||||
let scope: SettingsScope = c.arg("scope")?;
|
||||
let path: Option<PathBuf> = c.arg("path")?;
|
||||
let root = match scope {
|
||||
SettingsScope::Global => None,
|
||||
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
|
||||
};
|
||||
blocking(move || host.choose_program(&id, &cap, root.as_deref(), path.as_deref())).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_program", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let id: String = c.arg("pluginId")?;
|
||||
let program: String = c.arg("program")?;
|
||||
let scope: SettingsScope = c.arg("scope")?;
|
||||
let root = match scope {
|
||||
SettingsScope::Global => None,
|
||||
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
|
||||
};
|
||||
blocking(move || host.program_for_project(&id, &program, root.as_deref()).map(ide_core::IpcPath::from))
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_command", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_command(&c.window, &c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_commit", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
@@ -456,6 +616,27 @@ impl RemotePlugins {
|
||||
.await
|
||||
}
|
||||
});
|
||||
d.add("mcp_set_open_editors", |c| async move {
|
||||
let paths: Vec<ide_core::IpcPath> = c.arg("paths")?;
|
||||
c.host.project(&c.window)?;
|
||||
c.host.mcp_reports.set_open_editors(&c.window, paths.into_iter().map(PathBuf::from).collect());
|
||||
Ok(())
|
||||
});
|
||||
d.add("mcp_set_focused_editor", |c| async move {
|
||||
c.host.project(&c.window)?;
|
||||
c.host.mcp_reports.set_focused_editor(&c.window, c.arg("editor")?, c.arg("focused")?);
|
||||
Ok(())
|
||||
});
|
||||
d.add("mcp_report_problems", |c| async move {
|
||||
c.host.project(&c.window)?;
|
||||
c.host.mcp_reports.report_problems(&c.window, c.arg("reset")?, c.arg("files")?);
|
||||
Ok(())
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_decoration_sets", move |_c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.decoration_sets()) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_active_file", move |c| {
|
||||
let h = h.clone();
|
||||
@@ -577,6 +758,21 @@ impl RemotePlugins {
|
||||
let h = h.clone();
|
||||
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_run_targets", move |c| {
|
||||
let h = h.clone();
|
||||
async move { super::run_targets::list(&c.host, alive(&h)?.as_ref(), &c.window) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_run_target", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let id: String = c.arg("id")?;
|
||||
let use_target = c.arg("useTarget")?;
|
||||
blocking(move || super::run_targets::answer(&c.host, &plugins, &c.window, &id, use_target)).await
|
||||
}
|
||||
});
|
||||
d.add("plugin_line_markers", |c| async move {
|
||||
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
@@ -621,6 +817,123 @@ fn safe_relative(path: &str) -> IdeResult<PathBuf> {
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
|
||||
struct ClientLoopbacks {
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
}
|
||||
impl ClientLoopbacks {
|
||||
fn request(
|
||||
&self,
|
||||
plugin: &str,
|
||||
operation: AuthOperation,
|
||||
cancel: Option<ide_core::CancellationToken>,
|
||||
) -> Result<serde_json::Value, ide_plugin::HostError> {
|
||||
let peer = self.peer.get().ok_or(ide_plugin::HostError::Cancelled)?.clone();
|
||||
let timeout = match &operation {
|
||||
AuthOperation::Wait { timeout_ms, .. } => Duration::from_millis(u64::from(*timeout_ms)) + SECRET_TIMEOUT,
|
||||
_ => SECRET_TIMEOUT,
|
||||
};
|
||||
let request = AuthRequest { plugin: plugin.into(), operation };
|
||||
let ask = async {
|
||||
tokio::time::timeout(
|
||||
timeout,
|
||||
peer.request::<_, Result<serde_json::Value, ide_plugin::HostError>>(AUTH_REQUEST, request, cancel),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ide_plugin::HostError::Failed("desktop OAuth request timed out".into()))?
|
||||
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))?
|
||||
};
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
tokio::task::block_in_place(|| self.runtime.block_on(ask))
|
||||
} else {
|
||||
self.runtime.block_on(ask)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl super::auth::Service for ClientLoopbacks {
|
||||
fn begin(&self, plugin: &str, _window: &str, path: &str) -> Result<(u64, String), ide_plugin::HostError> {
|
||||
serde_json::from_value(self.request(plugin, AuthOperation::Begin { path: path.into() }, None)?)
|
||||
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
|
||||
}
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> Result<Option<String>, ide_plugin::HostError> {
|
||||
let result = self.request(
|
||||
plugin,
|
||||
AuthOperation::Wait { id, timeout_ms: timeout.as_millis().min(u128::from(u32::MAX)) as u32 },
|
||||
Some(cancel.clone()),
|
||||
);
|
||||
if cancel.is_cancelled() {
|
||||
self.close(plugin, id);
|
||||
return Err(ide_plugin::HostError::Cancelled);
|
||||
}
|
||||
serde_json::from_value(result?).map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
|
||||
}
|
||||
fn close(&self, plugin: &str, id: u64) {
|
||||
let _ = self.request(plugin, AuthOperation::Close { id }, None);
|
||||
}
|
||||
fn clear(&self, plugin: &str) {
|
||||
let _ = self.request(plugin, AuthOperation::Clear, None);
|
||||
}
|
||||
fn forget_window(&self, _: &str) {}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// A remote instance may mutate only declared secrets with a current grant.
|
||||
pub fn remote_secret_write(&self, request: SecretWrite) -> Result<(), String> {
|
||||
let plugin = self.get(&request.plugin).map_err(|e| e.message)?;
|
||||
if !plugin.meta.lock().granted.iter().any(|g| g.covers(&ide_extension::Capability::Secrets)) {
|
||||
return Err("secrets capability not granted".into());
|
||||
}
|
||||
if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == request.name) {
|
||||
return Err("undeclared secret".into());
|
||||
}
|
||||
if request.value.as_ref().is_some_and(|v| v.len() > 64 << 10) {
|
||||
return Err("secret value too long".into());
|
||||
}
|
||||
self.shared.secrets.read().set(plugin.id(), &request.name, request.value.as_deref())
|
||||
}
|
||||
/// Serves a connected remote instance using this connection's actual window.
|
||||
pub fn remote_auth(&self, window: &str, request: AuthRequest) -> Result<serde_json::Value, ide_plugin::HostError> {
|
||||
if matches!(&request.operation, AuthOperation::Begin { .. } | AuthOperation::Wait { .. }) {
|
||||
self.get(&request.plugin).map_err(|e| ide_plugin::HostError::NotFound(e.message))?;
|
||||
}
|
||||
let owner = format!("remote:{window}:{}", request.plugin);
|
||||
let service = self.shared.loopbacks.read().clone();
|
||||
let result = match request.operation {
|
||||
AuthOperation::Begin { path } => {
|
||||
if self.shared.client_closed(window) {
|
||||
return Err(ide_plugin::HostError::Cancelled);
|
||||
}
|
||||
serde_json::to_value(service.begin(&owner, window, &path)?)
|
||||
}
|
||||
AuthOperation::Wait { id, timeout_ms } => serde_json::to_value(service.wait(
|
||||
&owner,
|
||||
id,
|
||||
Duration::from_millis(u64::from(timeout_ms)),
|
||||
&ide_core::CancellationToken::new(),
|
||||
)?),
|
||||
AuthOperation::Close { id } => {
|
||||
service.close(&owner, id);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
AuthOperation::Clear => {
|
||||
service.clear(&owner);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
};
|
||||
result.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
|
||||
}
|
||||
/// A remote transport closed; abandon its browser listeners.
|
||||
pub fn forget_remote_loopbacks(&self, window: &str) {
|
||||
self.shared.loopbacks.read().forget_window(window);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct Detector {
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub test: bool,
|
||||
/// Optional output parser, including the API 1.4 plugin export.
|
||||
#[serde(default)]
|
||||
pub framework: Option<String>,
|
||||
}
|
||||
|
||||
/// Detectors of enabled plugins.
|
||||
@@ -85,6 +88,12 @@ impl ExtensionPoint for RunDetectorsPoint {
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let d: Detector = decode(POINT_ID, decl)?;
|
||||
if d.framework.is_some() {
|
||||
pkg.manifest.needs_api("runDetectors.framework", 4)?;
|
||||
}
|
||||
if d.framework.as_ref().is_some_and(|v| v.is_empty() || v.len() > 64) {
|
||||
return Err("invalid test framework".into());
|
||||
}
|
||||
validate_item_id(&d.id, "run detector id")?;
|
||||
if d.name.trim().is_empty() || d.name.len() > 128 {
|
||||
return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id));
|
||||
@@ -110,48 +119,106 @@ impl ExtensionPoint for RunDetectorsPoint {
|
||||
}
|
||||
|
||||
type Pins = Arc<RwLock<HashMap<(String, String), ProgramPin>>>;
|
||||
pub(super) type DynamicConfigurations =
|
||||
Arc<RwLock<std::collections::BTreeMap<(String, PathBuf), Vec<ide_plugin::PluginRunConfiguration>>>>;
|
||||
|
||||
/// [`ExternalDetection`] over the detectors and the approved programs.
|
||||
pub struct PluginRunDetection {
|
||||
pub(crate) store: Arc<RunDetectors>,
|
||||
pub(crate) pins: Pins,
|
||||
pub(crate) dynamic: DynamicConfigurations,
|
||||
pub(crate) project_pins: super::api::ProjectPins,
|
||||
}
|
||||
|
||||
fn config_id(plugin: &str, detector: &str) -> String {
|
||||
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
|
||||
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
|
||||
}
|
||||
|
||||
impl PluginRunDetection {
|
||||
fn pin(&self, plugin: &str, d: &Detector) -> Option<ProgramPin> {
|
||||
fn pin(&self, plugin: &str, d: &Detector, root: Option<&Path>) -> Option<ProgramPin> {
|
||||
let cap = Capability::Process(d.command.trim().to_owned());
|
||||
self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
root.and_then(|root| self.project_pins.read().get(&(plugin.into(), cap.to_string(), root.into())).cloned())
|
||||
.or_else(|| self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned())
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalDetection for PluginRunDetection {
|
||||
fn detect(&self, root: &Path) -> Vec<RunConfig> {
|
||||
let items = self.store.items.read().clone();
|
||||
items
|
||||
let mut configs: Vec<_> = items
|
||||
.into_iter()
|
||||
.filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists()))
|
||||
.filter(|(_, plugin, d)| {
|
||||
self.pin(plugin, d, Some(root)).is_some() && d.files.iter().any(|f| root.join(f).exists())
|
||||
})
|
||||
.map(|(_, plugin, d)| RunConfig {
|
||||
id: config_id(&plugin, &d.id),
|
||||
name: d.name.clone(),
|
||||
kind: "plugin".into(),
|
||||
command: d.command.trim().to_owned(),
|
||||
args: d.args.clone(),
|
||||
cwd: None,
|
||||
cwd: Some(root.to_path_buf().into()),
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: d.test,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
extras: ide_run::RunExtras {
|
||||
params: d.framework.clone().map(|f| [("framework".into(), f)].into()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
for ((plugin, project), entries) in self.dynamic.read().iter().filter(|((_, project), _)| project == root) {
|
||||
for c in entries {
|
||||
let cap = Capability::Process(c.program.clone());
|
||||
if !self.project_pins.read().contains_key(&(plugin.clone(), cap.to_string(), project.clone()))
|
||||
&& !self.pins.read().contains_key(&(plugin.clone(), cap.to_string()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut config = RunConfig {
|
||||
id: String::new(),
|
||||
name: c.name.clone(),
|
||||
kind: "plugin".into(),
|
||||
command: c.program.clone(),
|
||||
args: c.args.clone(),
|
||||
cwd: Some(root.to_path_buf().into()),
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: c.test,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
config.id = config_id(plugin, &c.id);
|
||||
config.cwd = Some(c.cwd.as_ref().map(|p| root.join(p)).unwrap_or_else(|| root.to_path_buf()).into());
|
||||
if let Some(framework) = &c.framework {
|
||||
config.extras.params.insert("framework".into(), framework.clone());
|
||||
}
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
|
||||
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String> {
|
||||
let dynamic = self.dynamic.read();
|
||||
if let Some(((plugin, root), c)) =
|
||||
dynamic.iter().flat_map(|(k, entries)| entries.iter().map(move |c| (k, c))).find(|((plugin, root), c)| {
|
||||
config_id(plugin, &c.id) == config.id && config.cwd.as_ref().is_some_and(|cwd| cwd.starts_with(root))
|
||||
})
|
||||
{
|
||||
let cap = Capability::Process(c.program.clone()).to_string();
|
||||
let pin = self
|
||||
.project_pins
|
||||
.read()
|
||||
.get(&(plugin.clone(), cap.clone(), root.clone()))
|
||||
.cloned()
|
||||
.or_else(|| self.pins.read().get(&(plugin.clone(), cap)).cloned())
|
||||
.ok_or_else(|| "run program is no longer granted".to_owned())?;
|
||||
return pin.verify().map(Path::to_path_buf);
|
||||
}
|
||||
drop(dynamic);
|
||||
let items = self.store.items.read();
|
||||
let (plugin, d) = items
|
||||
.iter()
|
||||
@@ -159,7 +226,7 @@ impl ExternalDetection for PluginRunDetection {
|
||||
.map(|(_, p, d)| (p.clone(), d.clone()))
|
||||
.ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?;
|
||||
drop(items);
|
||||
let pin = self.pin(&plugin, &d).ok_or_else(|| {
|
||||
let pin = self.pin(&plugin, &d, config.cwd.as_ref().map(|p| p.as_ref())).ok_or_else(|| {
|
||||
format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command)
|
||||
})?;
|
||||
pin.verify().map(Path::to_path_buf)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Project-owned registrations, retained across idle instance reclamation.
|
||||
use super::api::{PluginApi, Shared};
|
||||
use super::{PluginEvent, PluginHost};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_plugin::{HostError, PluginRunTarget};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisteredRunTarget {
|
||||
/// Opaque selector id, namespaced by the host.
|
||||
pub id: String,
|
||||
pub plugin_id: String,
|
||||
pub plugin_name: String,
|
||||
pub target: PluginRunTarget,
|
||||
pub prompt: bool,
|
||||
}
|
||||
|
||||
pub(super) type Targets = std::collections::BTreeMap<(PathBuf, String), RegisteredRunTarget>;
|
||||
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())
|
||||
}
|
||||
pub(super) fn withdraw_run_targets(&self, plugin: Option<&str>, root: Option<&Path>) {
|
||||
let mut targets = self.run_targets.lock();
|
||||
let before = targets.len();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PluginApi {
|
||||
pub(super) fn register_target(&self, root: &Path, target: PluginRunTarget) -> Result<(), HostError> {
|
||||
ide_remote::plugin_target::validate(&target).map_err(|e| HostError::Invalid(e.to_string()))?;
|
||||
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
|
||||
if !workspace.projects().iter().any(|r| r == root) {
|
||||
return Err(HostError::NotFound("project is not open".into()));
|
||||
}
|
||||
if !workspace.trusted(root) {
|
||||
return Err(HostError::Denied("project is not trusted".into()));
|
||||
}
|
||||
self.approved_program_in_project(&target.program, Some(root))?;
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let id = selector(self.pkg.id(), &target.id);
|
||||
let mut targets = self.shared.run_targets.lock();
|
||||
if !targets.contains_key(&(root.into(), id.clone()))
|
||||
&& targets.iter().filter(|((r, _), t)| r == root && t.plugin_id == self.pkg.id()).count() >= 32
|
||||
{
|
||||
return Err(HostError::Invalid("at most 32 targets per plugin and project".into()));
|
||||
}
|
||||
let prompt = !self.shared.target_answer_file(root, &id).exists();
|
||||
targets.insert(
|
||||
(root.into(), id.clone()),
|
||||
RegisteredRunTarget {
|
||||
id,
|
||||
plugin_id: self.pkg.id().into(),
|
||||
plugin_name: self.pkg.manifest.name.clone(),
|
||||
target,
|
||||
prompt,
|
||||
},
|
||||
);
|
||||
drop(targets);
|
||||
drop(blocked);
|
||||
self.shared.targets_changed();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PluginHost {
|
||||
/// Drops registrations when their project closes or loses trust.
|
||||
pub fn withdraw_project_run_targets(&self, root: &Path) {
|
||||
self.shared.withdraw_run_targets(None, Some(root));
|
||||
}
|
||||
pub fn run_targets(&self, root: &Path) -> Vec<RegisteredRunTarget> {
|
||||
self.shared.run_targets.lock().iter().filter(|((r, _), _)| r == root).map(|(_, t)| t.clone()).collect()
|
||||
}
|
||||
pub fn prepare_run_target(
|
||||
&self,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
) -> 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
|
||||
.lock()
|
||||
.get(&(root.into(), id.into()))
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
|
||||
ide_remote::plugin_target::prepare(root, &target.target, &program, config)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(host: &crate::Host, plugins: &PluginHost, window: &str) -> IdeResult<Vec<RegisteredRunTarget>> {
|
||||
Ok(plugins.run_targets(&host.project(window)?.root))
|
||||
}
|
||||
|
||||
/// The first answer wins across the project's windows. Only Use changes settings.
|
||||
pub fn answer(
|
||||
host: &crate::Host,
|
||||
plugins: &PluginHost,
|
||||
window: &str,
|
||||
id: &str,
|
||||
use_target: bool,
|
||||
) -> IdeResult<ide_core::settings::Settings> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let mut targets = plugins.shared.run_targets.lock();
|
||||
let target = targets
|
||||
.get_mut(&(project.root.clone(), id.into()))
|
||||
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let file = plugins.shared.target_answer_file(&project.root, id);
|
||||
if file.exists() {
|
||||
target.prompt = false;
|
||||
return Ok(project.settings.effective());
|
||||
}
|
||||
if use_target {
|
||||
plugins.program_for_project(&target.plugin_id, &target.target.program, Some(&project.root))?;
|
||||
let scope = ide_workspace::SettingsScope::Project;
|
||||
for key in ["run.defaultTarget", "languages.php.runTarget", "languages.node.runTarget"] {
|
||||
project.settings.set(scope, key, serde_json::json!(id))?;
|
||||
for window in host.windows_for_root(&project.root) {
|
||||
if let Ok(open) = host.project(&window) {
|
||||
open.settings.reload();
|
||||
open.sink.emit(ide_core::IdeEvent::SettingsChanged { key: key.into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(file.parent().unwrap())?;
|
||||
std::fs::write(file, if use_target { "use" } else { "not-now" })?;
|
||||
target.prompt = false;
|
||||
drop(targets);
|
||||
plugins.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
Ok(project.settings.effective())
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Named JSON snippets, retained as read-only UI declarations.
|
||||
use ide_extension::{Package, UiDecl, decode};
|
||||
use serde::Deserialize;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
language: String,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Lines {
|
||||
One(String),
|
||||
Many(Vec<String>),
|
||||
}
|
||||
impl Lines {
|
||||
fn values(self) -> Vec<String> {
|
||||
match self {
|
||||
Self::One(s) => vec![s],
|
||||
Self::Many(v) => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Snippet {
|
||||
prefix: Lines,
|
||||
body: Lines,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn declaration(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
pkg.manifest.needs_api("contributes.snippets", 4)?;
|
||||
let d: Decl = decode("snippets", decl)?;
|
||||
if d.language.is_empty()
|
||||
|| d.language.len() > 64
|
||||
|| !d.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
|
||||
{
|
||||
return Err("snippets: invalid language id".into());
|
||||
}
|
||||
let text = pkg.read_text(&d.path)?;
|
||||
if text.len() > 1024 * 1024 {
|
||||
return Err("snippets: file exceeds 1 MiB".into());
|
||||
}
|
||||
let snippets: std::collections::BTreeMap<String, Snippet> =
|
||||
serde_json::from_str(&text).map_err(|e| format!("snippets: {e}"))?;
|
||||
if snippets.len() > 1000 {
|
||||
return Err("snippets: at most 1000 entries per file".into());
|
||||
}
|
||||
let mut entries = vec![];
|
||||
for (name, s) in snippets {
|
||||
let prefixes = s.prefix.values();
|
||||
let body = s.body.values().join("\n");
|
||||
if name.is_empty()
|
||||
|| name.len() > 256
|
||||
|| prefixes.is_empty()
|
||||
|| prefixes.len() > 32
|
||||
|| prefixes.iter().any(|p| p.is_empty() || p.len() > 256)
|
||||
|| body.len() > 65536
|
||||
|| s.description.as_ref().is_some_and(|s| s.len() > 4096)
|
||||
{
|
||||
return Err(format!("snippets: invalid entry {name:?}"));
|
||||
}
|
||||
entries.push(serde_json::json!({"name":name,"prefixes":prefixes,"body":body,"description":s.description.unwrap_or_default()}));
|
||||
}
|
||||
let key = d.path.to_string_lossy().into_owned();
|
||||
let mut out = UiDecl::new(
|
||||
key.clone(),
|
||||
d.language.clone(),
|
||||
serde_json::json!({"source":key,"language":d.language,"entries":entries}),
|
||||
);
|
||||
out.files.push(d.path);
|
||||
Ok(out)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_every_snippet_at_install_and_requires_api4() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest = |api| {
|
||||
ide_extension::Manifest::parse(&format!(
|
||||
"id='test'\nname='Test'\nversion='1.0.0'\nengines.openidea='{api}'"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let decl: toml::Value = toml::from_str("language='sql'\npath='snippets.json'").unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("snippets.json"),
|
||||
r#"{"Select":{"prefix":["sel","select"],"body":["SELECT ${1:column}","FROM $1$0"],"description":"Query"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pkg = Package::new(manifest("^1.4"), dir.path().into());
|
||||
let out = declaration(&pkg, decl.clone()).unwrap();
|
||||
assert_eq!(out.json["entries"][0]["body"], "SELECT ${1:column}\nFROM $1$0");
|
||||
assert!(declaration(&Package::new(manifest("^1.3"), dir.path().into()), decl.clone()).is_err());
|
||||
for text in [
|
||||
r#"{"x":{"prefix":[],"body":"x"}}"#,
|
||||
r#"{"x":{"prefix":"x","body":42}}"#,
|
||||
r#"{"x":{"prefix":"x","body":"x","extra":1}}"#,
|
||||
] {
|
||||
std::fs::write(dir.path().join("snippets.json"), text).unwrap();
|
||||
assert!(declaration(&pkg, decl.clone()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub use ide_plugin::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A line a view's `process` body printed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -164,8 +165,33 @@ pub struct PluginUpdate {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PluginEvent {
|
||||
/// Project target registrations or remembered prompt answers changed.
|
||||
RunTargetsChanged,
|
||||
/// A window-owned operation; an empty request id queues an asynchronous run.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
WindowRequest { plugin_id: String, request_id: String, request: WindowRequest },
|
||||
/// Reply to a content action in this window.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ContentTabReply { plugin_id: String, request_id: String, reply: ide_plugin::ContentTabReply },
|
||||
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
/// Complete replacement of one plugin's project-scoped decorations.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DecorationsChanged { plugin_id: String, sets: Vec<DecorationSet> },
|
||||
/// Open a file in the invoking window.
|
||||
EditorOpen { path: ide_core::IpcPath, selection: Option<ide_core::Range> },
|
||||
/// Change selection or scrolling only in this window's already open editor.
|
||||
EditorRanges { path: ide_core::IpcPath, ranges: Vec<ide_core::Range>, reveal: bool },
|
||||
/// A bounded output channel snapshot, or withdrawal when the plugin stops.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OutputChanged { plugin_id: String, channel: String, text: Option<String> },
|
||||
/// Reveal a read-only Output tab in the receiving window.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RevealOutput { plugin_id: String, channel: String, text: String },
|
||||
/// Execute an action through the receiving window and answer over IPC.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ExecuteCommand { request_id: String, id: String, arg: Option<String>, chain: Vec<String> },
|
||||
/// Cached document associations were disposed (reload, disable or failure).
|
||||
DocumentIndexesChanged,
|
||||
/// The routes `plugin_id` reports for the project `root` may have changed:
|
||||
@@ -187,6 +213,9 @@ pub enum PluginEvent {
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
|
||||
/// An override in the receiving window; removal exposes the global item.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
WindowStatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
|
||||
/// A plugin's context key was set (`value`: JSON text) or removed, in the
|
||||
/// emitted window (every window when the client is `None`).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -340,6 +369,8 @@ pub struct PackageFile {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStatusItem {
|
||||
/// Whether this item overrides the global item in the requesting window.
|
||||
pub window: bool,
|
||||
pub plugin_id: String,
|
||||
pub id: String,
|
||||
pub item: StatusItem,
|
||||
@@ -388,3 +419,46 @@ pub struct PluginCommitCheckResult {
|
||||
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// The invoking window's response to a command request.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
|
||||
pub enum CommandAnswer {
|
||||
Value(Option<String>),
|
||||
NotFound,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// A plugin-owned editor or project-tree decoration set for one file.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecorationSet {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub path: ide_core::IpcPath,
|
||||
pub set_id: String,
|
||||
pub tree: bool,
|
||||
pub items: Vec<ide_plugin::Decoration>,
|
||||
pub file: Option<ide_plugin::FileDecoration>,
|
||||
}
|
||||
|
||||
/// A request that the invoking window acknowledges through plugin_answer_command.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum WindowRequest {
|
||||
/// Connect and introspect using the normal Database action in this window.
|
||||
DatabaseConnect { id: String },
|
||||
/// Open and acknowledge validation by the shared content editor.
|
||||
ContentTab { tab: ide_plugin::ContentTab },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Run { operation: String, config: Box<ide_run::RunConfig>, debug: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Terminal {
|
||||
operation: String,
|
||||
key: String,
|
||||
title: String,
|
||||
program: ide_core::IpcPath,
|
||||
args: Vec<String>,
|
||||
env: BTreeMap<String, String>,
|
||||
cwd: ide_core::IpcPath,
|
||||
},
|
||||
}
|
||||
@@ -76,8 +76,18 @@ pub const COMMIT_CHECKS: &str = "commitChecks";
|
||||
const VIEW_ITEM_MENU: &str = "viewItem.context";
|
||||
/// The toolbar above the commit message (Commit tool window and dialog).
|
||||
const COMMIT_MESSAGE_MENU: &str = "commit.message";
|
||||
const MENU_IDS: [&str; 6] =
|
||||
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
|
||||
const MENU_IDS: [&str; 10] = [
|
||||
"editor.context",
|
||||
"projectView.context",
|
||||
"editorTab.context",
|
||||
"tools",
|
||||
VIEW_ITEM_MENU,
|
||||
COMMIT_MESSAGE_MENU,
|
||||
"editor.title",
|
||||
"mainToolbar",
|
||||
"problems.context",
|
||||
"searchResults.context",
|
||||
];
|
||||
const MAX_TITLE: usize = 256;
|
||||
const MAX_SETTINGS: usize = 200;
|
||||
const MAX_PATTERNS: usize = 16;
|
||||
@@ -135,6 +145,9 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
|
||||
}
|
||||
declared_command(pkg, MENUS, &d.command)?;
|
||||
if ["editor.title", "mainToolbar", "problems.context", "searchResults.context"].contains(&d.menu.as_str()) {
|
||||
pkg.manifest.needs_api("contributes.menus: language workbench menus", 4)?;
|
||||
}
|
||||
if d.menu == COMMIT_MESSAGE_MENU {
|
||||
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
|
||||
}
|
||||
@@ -965,6 +978,20 @@ pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result<UiDecl, Strin
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api4_menu_locations_require_api4_and_declared_commands() {
|
||||
for location in ["editor.title", "mainToolbar", "problems.context", "searchResults.context"] {
|
||||
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
|
||||
let manifest=ide_extension::Manifest::parse(&format!("id='test'\nname='Test'\nversion='1.0.0'\nengines.openidea='{api}'\n[[contributes.commands]]\nid='check'\ntitle='Check'")).unwrap();
|
||||
let pkg = Package::new(manifest, std::path::PathBuf::from("/unused"));
|
||||
let decl =
|
||||
toml::from_str(&format!("menu='{location}'\ncommand='check'\nwhen='editorLang == sql'")).unwrap();
|
||||
assert_eq!(menu(&pkg, decl).is_ok(), valid, "{api}:{location}");
|
||||
assert!(menu(&pkg, toml::from_str(&format!("menu='{location}'\ncommand='foreign'")).unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindings_need_a_modifier() {
|
||||
for ok in ["Ctrl+Alt+C", "Meta+Shift+K", "F5", "Shift+F6", "Meta+K Meta+C", "Alt+Enter"] {
|
||||
|
||||
@@ -16,6 +16,7 @@ use ide_extension::{Package, check_relative};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Most files under one web root.
|
||||
const MAX_FILES: usize = 4096;
|
||||
@@ -135,7 +136,7 @@ impl PluginHost {
|
||||
command: WebPtyCommand,
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, || {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, None, || {
|
||||
self.web_pty_options(plugin_id, view, window, command)
|
||||
})
|
||||
}
|
||||
@@ -154,19 +155,21 @@ impl PluginHost {
|
||||
(cols, rows): (u16, u16),
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, || {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, None, || {
|
||||
self.log_tab_pty_options(plugin_id, key, window, on, (cols, rows))
|
||||
})
|
||||
}
|
||||
|
||||
/// The PTY of a plugin, counted against its sessions and ended with its
|
||||
/// window, project or permission; `options` authorizes it.
|
||||
fn spawn_plugin_pty(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn spawn_plugin_pty(
|
||||
&self,
|
||||
host: &std::sync::Arc<crate::Host>,
|
||||
plugin_id: &str,
|
||||
window: &str,
|
||||
output: crate::ops::Emit<String>,
|
||||
completion: Option<Arc<dyn Fn(Option<i32>) + Send + Sync>>,
|
||||
options: impl FnOnce() -> IdeResult<ide_terminal::TerminalOptions>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
@@ -181,10 +184,19 @@ impl PluginHost {
|
||||
return Err(IdeError::invalid("a plugin may own at most 8 PTYs"));
|
||||
}
|
||||
let cwd = options.cwd.0.clone();
|
||||
let previous = host.window_sink(window);
|
||||
let sink: Arc<dyn ide_core::EventSink> = Arc::new(move |event: ide_core::IdeEvent| {
|
||||
if let ide_core::IdeEvent::TerminalExited { code, .. } = &event
|
||||
&& let Some(completion) = &completion
|
||||
{
|
||||
completion(*code);
|
||||
}
|
||||
previous.emit(event);
|
||||
});
|
||||
let info = host.terminals.spawn_program(
|
||||
options,
|
||||
host.owner_window(window).to_owned(),
|
||||
host.window_sink(window),
|
||||
sink,
|
||||
Box::new(move |data| output(data)),
|
||||
)?;
|
||||
if !self
|
||||
@@ -258,7 +270,7 @@ impl PluginHost {
|
||||
|
||||
/// Checks `command` against the plugin's current grant, the limits and
|
||||
/// the window's trusted project.
|
||||
fn pty_options(
|
||||
pub(super) fn pty_options(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
window: &str,
|
||||
@@ -309,7 +321,7 @@ impl PluginHost {
|
||||
if !workspace.trusted(&cwd) {
|
||||
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
|
||||
}
|
||||
let program = self.program_for(plugin_id, &command.program)?;
|
||||
let program = self.program_for_project(plugin_id, &command.program, Some(&cwd))?;
|
||||
Ok(ide_terminal::TerminalOptions {
|
||||
cwd: cwd.into(),
|
||||
shell: Some(program.to_string_lossy().into_owned()),
|
||||
|
||||
@@ -133,9 +133,45 @@ 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();
|
||||
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();
|
||||
@@ -272,7 +308,23 @@ impl Host {
|
||||
{
|
||||
let settings = settings.clone();
|
||||
lsp.set_servers_hook(Box::new(move |language, defined| {
|
||||
ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective())
|
||||
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
|
||||
}));
|
||||
}
|
||||
{
|
||||
@@ -1017,12 +1069,13 @@ impl Project {
|
||||
let Some(root) = root else {
|
||||
return ide_lsp::CandidateFiles::default();
|
||||
};
|
||||
let query = ide_search::WordQuery {
|
||||
word: q.word.clone(),
|
||||
dir: q.root.clone(),
|
||||
exclude: self.search_excludes(),
|
||||
limit: q.limit,
|
||||
};
|
||||
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 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 },
|
||||
|
||||
@@ -248,7 +248,7 @@ impl Dispatcher {
|
||||
.add("term_is_busy", |c| async move { ops::term::is_busy(&c.host, c.arg("id")?) })
|
||||
.add("term_default_shell", |_| async move { Ok(ide_terminal::default_shell_path()) })
|
||||
.add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) })
|
||||
.add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) })
|
||||
.add("run_configs", |c| async move { ops::doc::blocking(move || ops::run::configs(&c.host, &c.window)).await })
|
||||
.add("run_save_configs", |c| async move {
|
||||
ops::run::save_configs(
|
||||
&c.host,
|
||||
@@ -286,6 +286,10 @@ where
|
||||
let p = Peer::connect(reader, writer, handler);
|
||||
let _ = peer.set(p.clone());
|
||||
p.closed().await;
|
||||
// Reports belong to the disconnected desktop, even while the host shuts down.
|
||||
for window in host.projects.read().keys() {
|
||||
host.mcp_reports.forget_window(window);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a window's events to the client as `event` notifications.
|
||||
|
||||
@@ -146,6 +146,9 @@ impl crate::Host {
|
||||
phpstan.interrupt();
|
||||
}
|
||||
if !trusted {
|
||||
if let Some(plugins) = self.plugin_host() {
|
||||
plugins.withdraw_project_run_targets(&project.root);
|
||||
}
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
project.repos();
|
||||
@@ -396,6 +399,8 @@ pub fn projectless_command_allowed(cmd: &str) -> bool {
|
||||
| "plugin_set_enabled"
|
||||
| "plugin_grant"
|
||||
| "plugin_set_capability"
|
||||
| "plugin_choose_program"
|
||||
| "plugin_program"
|
||||
| "plugin_roll_back"
|
||||
| "plugin_reload"
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ fn fixture() -> Fixture {
|
||||
|
||||
fn inv() -> Invocation {
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some("main".into()),
|
||||
workspace_root: None,
|
||||
editor: None,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Database and MCP calls through a real sandboxed API 1.4 component.
|
||||
use ide_core::{CancellationToken, IdeEvent, event::McpUiRequest};
|
||||
use ide_host::{
|
||||
Host, HostConfig,
|
||||
mcp::Registry,
|
||||
ops,
|
||||
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost, WindowRequest},
|
||||
};
|
||||
use ide_mcp::{ToolCallError, ToolRegistry};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
const ID: &str = "test.data-tools";
|
||||
fn fixture() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/data-tools")
|
||||
}
|
||||
struct Harness {
|
||||
host: Arc<Host>,
|
||||
plugins: Arc<PluginHost>,
|
||||
root: PathBuf,
|
||||
events: Arc<Mutex<Vec<(String, IdeEvent)>>>,
|
||||
requests: Arc<Mutex<Vec<(String, PluginEvent)>>>,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
let out = events.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| {
|
||||
let (w, out) = (w.to_owned(), out.clone());
|
||||
Arc::new(move |e| out.lock().unwrap().push((w.clone(), e)))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
std::fs::write(root.join("db.sqlite"), "").unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.open_project("second", &root).unwrap();
|
||||
let requests = Arc::new(Mutex::new(vec![]));
|
||||
let out = requests.clone();
|
||||
let plugins = Arc::new(
|
||||
PluginHost::new(
|
||||
tmp.path().join("plugins"),
|
||||
Arc::new(move |w: Option<&str>, e| out.lock().unwrap().push((w.unwrap_or_default().to_owned(), e))),
|
||||
Limits { timeout: Duration::from_millis(250), ..Default::default() },
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
|
||||
host.set_plugin_host(&plugins);
|
||||
plugins.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
||||
Self { host, plugins, root, events, requests, _tmp: tmp }
|
||||
}
|
||||
fn invocation(&self) -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(self.root.clone()), ..Default::default() }
|
||||
}
|
||||
fn run(&self, id: &str, arg: Option<&str>) -> ide_core::IdeResult<Option<String>> {
|
||||
self.plugins.run_command(ID, id, arg, self.invocation())
|
||||
}
|
||||
fn source(&self) -> String {
|
||||
serde_json::json!({"id":"local","name":"Example","driver":"sqlite","database":self.root.join("db.sqlite").to_string_lossy(),"options":[]}).to_string()
|
||||
}
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn database_calls_enforce_grants_trust_ownership_and_uninstall_lifetime() {
|
||||
let f = Harness::new();
|
||||
assert!(f.run("sources", None).is_err());
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let source = f.source();
|
||||
f.run("upsert", Some(&source)).unwrap();
|
||||
assert_eq!(f.run("sources", None).unwrap().as_deref(), Some("[\"local\"]"));
|
||||
f.run("credential", Some("never-on-disk")).unwrap();
|
||||
let source = f.host.project("main").unwrap().db.plugin_source(ID, "local").unwrap();
|
||||
let store = ide_db::secrets::SecretStore::new(f._tmp.path().join("data/db-passwords.json"), &f.root);
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("never-on-disk"));
|
||||
let file = f.root.join(".openidea/data-sources.toml");
|
||||
assert!(!std::fs::read_to_string(&file).unwrap().contains("never-on-disk"));
|
||||
for command in ["remove", "connect"] {
|
||||
assert!(f.run(command, Some("foreign")).is_err());
|
||||
}
|
||||
assert!(f.events.lock().unwrap().iter().any(|(w, e)| w == "second" && matches!(e, IdeEvent::DbSourcesChanged)));
|
||||
f.plugins.set_enabled(ID, false).unwrap();
|
||||
assert!(file.exists());
|
||||
f.plugins.set_enabled(ID, true).unwrap();
|
||||
assert!(f.host.project("main").unwrap().db.plugin_source(ID, "local").is_ok());
|
||||
f.host.set_mcp_executable(Some(PathBuf::from("/app/openidea")));
|
||||
let command: serde_json::Value = serde_json::from_str(&f.run("stdio", None).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(command[1], "--mcp-stdio");
|
||||
f.host.set_mcp_executable(None);
|
||||
assert_eq!(f.run("stdio", None).unwrap().as_deref(), Some("null"));
|
||||
// Closed project cleanup uses the host-owned journal, not only current windows.
|
||||
f.host.close_project("main");
|
||||
f.host.close_project("second");
|
||||
f.plugins.uninstall(ID).unwrap();
|
||||
assert!(ide_db::source::load(&f.root).unwrap().is_empty());
|
||||
assert!(store.get(&source.id).is_none());
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn database_connect_asks_only_the_invoking_window_and_uses_normal_connection_flow() {
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
f.run("upsert", Some(&f.source())).unwrap();
|
||||
let (plugins, invocation) = (f.plugins.clone(), f.invocation());
|
||||
let run = tokio::task::spawn_blocking(move || plugins.run_command(ID, "connect", None, invocation));
|
||||
let (window, request, source) = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let found = f.requests.lock().unwrap().iter().find_map(|(w, e)| match e {
|
||||
PluginEvent::WindowRequest { request_id, request: WindowRequest::DatabaseConnect { id }, .. } => {
|
||||
Some((w.clone(), request_id.clone(), id.clone()))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(v) = found {
|
||||
break v;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(window, "main");
|
||||
assert!(!f.plugins.answer_command("second", &request, ide_host::plugins::CommandAnswer::Value(None)));
|
||||
tokio::time::sleep(Duration::from_millis(350)).await; // Password entry can outlive the 250 ms command budget.
|
||||
ops::db::connect(&f.host, "main", &source).await.unwrap();
|
||||
f.plugins.answer_command("main", &request, ide_host::plugins::CommandAnswer::Value(None));
|
||||
run.await.unwrap().unwrap();
|
||||
assert!(!f.host.project("main").unwrap().db.connected().is_empty());
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_listing_dispatch_confirmation_and_lifecycle_use_plugin_authority() {
|
||||
let f = Harness::new();
|
||||
let registry = Arc::new(Registry::new(f.host.clone()));
|
||||
assert!(!registry.tools(&f.root).unwrap().iter().any(|t| t.name == "test_data_tools__write"));
|
||||
assert!(registry.call(&f.root, "test_data_tools__write", "{}", CancellationToken::new()).is_err());
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let tools = registry.tools(&f.root).unwrap();
|
||||
let name = "test_data_tools__echo";
|
||||
assert!(tools.iter().any(|t| t.name == name && t.description.contains(ID)));
|
||||
assert!(matches!(
|
||||
registry.call(&f.root, name, r#"{"text":12}"#, CancellationToken::new()),
|
||||
Err(ToolCallError::Failed(_))
|
||||
));
|
||||
let result = registry.call(&f.root, name, r#"{"text":"hello"}"#, CancellationToken::new()).unwrap();
|
||||
assert!(result.contains("hello"));
|
||||
let denied = registry.call(&f.root, "test_data_tools__denied", "{}", CancellationToken::new()).unwrap_err();
|
||||
assert!(format!("{denied:?}").contains("process:sh"));
|
||||
f.host.mcp_requests.set_ready("main", true);
|
||||
let (r, root) = (registry.clone(), f.root.clone());
|
||||
let call =
|
||||
tokio::task::spawn_blocking(move || r.call(&root, "test_data_tools__erase", "{}", CancellationToken::new()));
|
||||
let request = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(id) = f.events.lock().unwrap().iter().find_map(|(_, e)| match e {
|
||||
IdeEvent::McpRequest { id, request: McpUiRequest::Confirm { .. } } => Some(*id),
|
||||
_ => None,
|
||||
}) {
|
||||
break id;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(f.host.mcp_requests.answer("second", request, true, None).is_err());
|
||||
f.host.mcp_requests.answer("main", request, false, None).unwrap();
|
||||
assert!(call.await.unwrap().is_err());
|
||||
f.plugins.set_enabled(ID, false).unwrap();
|
||||
assert!(!registry.tools(&f.root).unwrap().iter().any(|t| t.name == name));
|
||||
assert!(registry.call(&f.root, name, "{}", CancellationToken::new()).is_err());
|
||||
f.plugins.set_enabled(ID, true).unwrap();
|
||||
assert!(registry.tools(&f.root).unwrap().iter().any(|t| t.name == name));
|
||||
let (r, root) = (registry.clone(), f.root.clone());
|
||||
let spin =
|
||||
tokio::task::spawn_blocking(move || r.call(&root, "test_data_tools__spin", "{}", CancellationToken::new()));
|
||||
assert!(spin.await.unwrap().is_err(), "command budget interrupts wasm");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn database_imports_are_denied_without_the_optional_grant() {
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
f.plugins.set_capability(ID, "database.sources", false).unwrap();
|
||||
for command in ["upsert", "remove", "sources", "credential", "connect"] {
|
||||
let err = f.run(command, Some(&f.source())).unwrap_err();
|
||||
assert!(err.message.contains("database.sources"), "{command}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn official_api_examples_install_and_render_real_component_contributions() {
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let official = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official");
|
||||
for name in ["blame-annotations", "todo-finder"] {
|
||||
f.plugins.install_from_dir(&official.join(name), &Consent::all()).unwrap();
|
||||
}
|
||||
std::fs::write(f.root.join("notes.txt"), "TODO example\n").unwrap();
|
||||
let doc = ide_plugin::ProviderDocument {
|
||||
path: f.root.join("notes.txt"),
|
||||
language: "text".into(),
|
||||
version: 1,
|
||||
text: "TODO example\n".into(),
|
||||
};
|
||||
assert!(eventually(|| {
|
||||
f.plugins
|
||||
.language_code_lenses(&doc)
|
||||
.iter()
|
||||
.any(|(owner, lens)| owner == "openidea.blame-annotations" && lens.command == "annotate")
|
||||
}));
|
||||
f.plugins.run_command("openidea.todo-finder", "find", None, f.invocation()).unwrap();
|
||||
let view = f.plugins.render_view("openidea.todo-finder", "todos", None, f.invocation()).unwrap();
|
||||
let ide_plugin::ViewBody::List(items) = view.body else { panic!("expected TODO list") };
|
||||
assert_eq!(items.len(), 1);
|
||||
assert!(items[0].label.contains("TODO example"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = std::env::join_paths([official.join("container-runner"), official.join("test-runner")]).unwrap();
|
||||
f.plugins.set_program_path(Arc::new(move || Some(path.clone())));
|
||||
for name in ["container-runner", "test-runner"] {
|
||||
f.plugins.install_from_dir(&official.join(name), &Consent::all()).unwrap();
|
||||
}
|
||||
std::fs::write(f.root.join("example.tests"), "test adds\n").unwrap();
|
||||
let doc =
|
||||
ide_plugin::ProviderDocument { path: f.root.join("example.tests"), text: "test adds\n".into(), ..doc };
|
||||
assert!(eventually(|| {
|
||||
f.plugins
|
||||
.language_gutter_actions(&doc)
|
||||
.iter()
|
||||
.any(|(owner, action)| owner == "openidea.test-runner" && action.argument.as_deref() == Some("adds"))
|
||||
}));
|
||||
f.plugins.refresh_run_configurations(&f.root);
|
||||
let configs = f.plugins.run_detection().detect(&f.root);
|
||||
assert!(configs.iter().any(|c| c.id == "plugin:openidea.test-runner:tests"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor-path calls drop a result that misses their budget, as the first call into a cold
|
||||
/// component can on a slow machine: ask again until it arrives.
|
||||
fn eventually(mut ready: impl FnMut() -> bool) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if ready() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -20,7 +20,7 @@ impl Fixture {
|
||||
}
|
||||
fn with_manifest(edit: impl FnOnce(&Path)) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
host.set_project_policy(Arc::new(|_| true));
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
|
||||
@@ -147,6 +147,48 @@ fn permissions_and_paths_are_checked_before_indexing() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn verbatim_snapshots_use_the_hosts_plain_document_identity() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "app");
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app");
|
||||
let verbatim = std::fs::canonicalize(&source).unwrap();
|
||||
assert_ne!(source, verbatim);
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &verbatim, version: Some(2), text: SOURCE, symbols: &[method()], scope: "app" },
|
||||
Invocation { workspace_root: Some(f.root.clone()), ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets.len(), 1, "a different prefix must replace the same document snapshot");
|
||||
assert_eq!(result.targets[0].path.0, source);
|
||||
assert!(f.host.indexed_symbol_at(&f.root, &source, Pos::new(0, 4), None).is_some());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn snapshot_paths_still_reject_symlink_aliases_and_escapes() {
|
||||
let f = Fixture::new();
|
||||
let path = f.index("source.idx", SOURCE, &[method()], Some(1), "app");
|
||||
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||
for (target, name) in [(path.as_path(), "alias.idx"), (outside.path(), "escape.idx")] {
|
||||
let alias = f.root.join(name);
|
||||
std::os::unix::fs::symlink(target, &alias).unwrap();
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &alias, version: None, text: SOURCE, symbols: &[], scope: "app" },
|
||||
Invocation::default(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_refactorings_drop_graphs_after_permission_revocation_or_lost_trust() {
|
||||
let f = Fixture::new();
|
||||
@@ -194,6 +236,7 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
|
||||
&f.root,
|
||||
Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" },
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some(client.into()),
|
||||
workspace_root: Some(f.root.clone()),
|
||||
editor: None,
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! The API 1.4 fixture exercises every kind through the real component runtime and editor ops.
|
||||
use ide_core::{CancellationToken, Pos, Range, RequestId};
|
||||
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, set_editor_providers};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_plugin::{Invocation, Limits, ProviderDocument};
|
||||
use std::{path::Path, sync::Arc, time::Duration};
|
||||
const ID: &str = "test.language-features";
|
||||
fn fixture() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/language-features")
|
||||
}
|
||||
fn plugins(base: &Path) -> Arc<PluginHost> {
|
||||
let p = Arc::new(
|
||||
PluginHost::new(
|
||||
base.join("plugins"),
|
||||
Arc::new(|_: Option<&str>, _| {}),
|
||||
Limits { edit_soft: Duration::from_secs(3), edit_hard: Duration::from_secs(5), ..Default::default() },
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
p.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
||||
p
|
||||
}
|
||||
#[test]
|
||||
fn injections_route_only_declared_embedded_providers_and_filter_diagnostics() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = plugins(tmp.path());
|
||||
let doc = ProviderDocument {
|
||||
path: tmp.path().join("a.txt"),
|
||||
language: "text".into(),
|
||||
version: 9,
|
||||
text: "xx[[sql]]zz".into(),
|
||||
};
|
||||
assert_eq!(p.provide_injections(&doc).len(), 1);
|
||||
let items = p.provide_completion(&doc, Pos::new(0, 5), None);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].1.label, "sql: sql ");
|
||||
assert!(p.provide_completion(&doc, Pos::new(0, 1), None).is_empty());
|
||||
assert_eq!(p.provide_hover(&doc, Pos::new(0, 5))[0].markdown, "sql: sql ");
|
||||
let diagnostics = p.provide_diagnostics(&doc, &CancellationToken::new());
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].1.len(), 1);
|
||||
assert_eq!(diagnostics[0].1[0].range, Range::new(Pos::new(0, 4), Pos::new(0, 7)));
|
||||
assert!(p.language_signature_help(&ProviderDocument { language: "rust".into(), ..doc }, Pos::default()).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn every_new_kind_requires_api4_at_install() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = plugins(tmp.path());
|
||||
let package = tmp.path().join("old");
|
||||
std::fs::create_dir_all(package.join("dist")).unwrap();
|
||||
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
|
||||
let header = "id='old'\nname='Old'\nversion='1.0.0'\nengines.openidea='^1.3'\nwasm='dist/plugin.wasm'\n";
|
||||
for kind in [
|
||||
"formatting",
|
||||
"signature-help",
|
||||
"document-highlights",
|
||||
"document-links",
|
||||
"code-lenses",
|
||||
"selection-ranges",
|
||||
"folding",
|
||||
"colors",
|
||||
"document-symbols",
|
||||
"implementation",
|
||||
"type-definition",
|
||||
"gutter-actions",
|
||||
"injections",
|
||||
] {
|
||||
std::fs::write(
|
||||
package.join("plugin.toml"),
|
||||
format!("{header}\n[[contributes.providers]]\nkinds=['{kind}']\nlanguages=['text']"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(p.install_from_dir(&package, &Consent::all()).is_err(), "{kind}");
|
||||
}
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.open_project("main", &root).unwrap();
|
||||
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();
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
|
||||
.parameters,
|
||||
vec![(6, 11)]
|
||||
);
|
||||
assert_eq!(ops::lsp::lsp_document_highlights(&host, "main", doc.id, Pos::default()).await.unwrap().len(), 1);
|
||||
let links = ops::lsp::lsp_document_links(&host, "main", doc.id).await.unwrap();
|
||||
assert_eq!(links.len(), 2);
|
||||
assert_eq!(links[0].path.as_ref().unwrap().0, root.join("next.txt"));
|
||||
let lenses = ops::lsp::lsp_code_lens(&host, "main", doc.id).await.unwrap();
|
||||
assert_eq!(lenses.len(), 1, "undeclared command was dropped");
|
||||
ops::lsp::lsp_execute_command(&host, "main", doc.id, lenses[0].command.clone().unwrap()).await.unwrap();
|
||||
assert_eq!(p.run_command(ID, "state", None, Invocation::default()).unwrap().as_deref(), Some("lens"));
|
||||
let outline = ops::doc::doc_outline(&host, "main", doc.id).await.unwrap();
|
||||
assert_eq!(outline[0].name, "Outer");
|
||||
assert_eq!(outline[0].children[0].name, "Inner");
|
||||
assert_eq!(ops::doc::doc_folding(&host, "main", doc.id).await.unwrap()[0].end_line, 2);
|
||||
assert_eq!(ops::doc::doc_selection_ranges(&host, "main", doc.id, 0).await.unwrap().len(), 2);
|
||||
for kind in [ide_lsp::types::GotoKind::Implementation, ide_lsp::types::GotoKind::TypeDefinition] {
|
||||
assert_eq!(ops::lsp::lsp_goto(&host, "main", doc.id, Pos::default(), kind).await.unwrap().len(), 1);
|
||||
}
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_goto_implementation(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(1),
|
||||
doc.id,
|
||||
Pos::default(),
|
||||
ide_host::ops::Channel::new(|_| {})
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let visuals = ops::lsp::plugin_language_visuals(&host, "main", doc.id).await.unwrap();
|
||||
assert_eq!(visuals.colors.len(), 1);
|
||||
assert_eq!(visuals.gutter_actions.len(), 1);
|
||||
assert_eq!(
|
||||
ops::lsp::plugin_color_presentations(&host, "main", doc.id, ID.into(), visuals.colors[0].1.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
["red-plaintext", "#ff0000"]
|
||||
);
|
||||
let project = host.project("main").unwrap();
|
||||
project
|
||||
.settings
|
||||
.set(
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"languages.pluginFormatters",
|
||||
serde_json::json!({doc.language.as_str():ID}),
|
||||
)
|
||||
.unwrap();
|
||||
let options = ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None };
|
||||
let formatted =
|
||||
ops::lsp::lsp_format(&host, "main", doc.id, Some(Range::new(Pos::default(), Pos::new(0, 5))), options)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(formatted[0].new_text, "formatted");
|
||||
// The same editor ops are exposed by the server dispatcher, with no desktop provider call.
|
||||
let dispatcher = ide_host::rpc::Dispatcher::standard();
|
||||
assert!(dispatcher.commands().contains(&"plugin_language_visuals"));
|
||||
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(std::sync::OnceLock::new());
|
||||
let remote = ide_host::plugins::remote_host::RemotePlugins::start(&base, &host, peer_slot.clone()).unwrap();
|
||||
let mut dispatcher = ide_host::rpc::Dispatcher::standard();
|
||||
remote.register(&mut dispatcher);
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(ide_host::rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
|
||||
struct Client;
|
||||
impl ide_jsonrpc::Handler for Client {
|
||||
fn on_notification(&self, _: &str, _: serde_json::Value) {}
|
||||
fn on_request(&self, method: &str, _: serde_json::Value) -> Result<serde_json::Value, (i64, String)> {
|
||||
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
|
||||
}
|
||||
}
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = ide_jsonrpc::Peer::connect(cr, cw, Arc::new(Client));
|
||||
let _: serde_json::Value =
|
||||
peer.request("initialize", serde_json::json!({"version":ide_host::rpc::PROTOCOL_VERSION}), None).await.unwrap();
|
||||
async fn invoke(peer: &ide_jsonrpc::Peer, cmd: &str, args: serde_json::Value) -> serde_json::Value {
|
||||
peer.request(
|
||||
"invoke",
|
||||
serde_json::to_value(ide_host::rpc::Invoke { window: "main".into(), cmd: cmd.into(), args }).unwrap(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
invoke(&peer, "project_trust_set", serde_json::json!({"path":root,"trusted":true,"folder":false})).await;
|
||||
let files = serde_json::json!({ID:p.remote_package_files(ID).unwrap()});
|
||||
let synced = invoke(
|
||||
&peer,
|
||||
ide_host::plugins::remote_host::SYNC_PACKAGES,
|
||||
serde_json::json!({"packages":p.remote_packages(),"files":files}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(synced["failed"], serde_json::json!([]), "{synced}");
|
||||
let listed = invoke(&peer, "plugin_formatters", serde_json::json!({})).await;
|
||||
assert_eq!(listed[0]["id"], ID);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_uses_the_command_budget_while_editor_calls_can_expire() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = PluginHost::new(
|
||||
tmp.path().join("plugins"),
|
||||
Arc::new(|_: Option<&str>, _| {}),
|
||||
Limits { edit_soft: Duration::ZERO, ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
p.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
||||
let doc =
|
||||
ProviderDocument { path: tmp.path().join("a.txt"), language: "text".into(), version: 1, text: "value".into() };
|
||||
assert_eq!(
|
||||
p.language_format(
|
||||
ID,
|
||||
&doc,
|
||||
None,
|
||||
ide_plugin::language_features::FormatOptions { tab_size: 4, insert_spaces: true }
|
||||
)
|
||||
.unwrap()[0]
|
||||
.new_text,
|
||||
"formatted"
|
||||
);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use std::{path::Path, sync::Arc};
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let first = root.join("definition.idx");
|
||||
let second = root.join("usage.idx");
|
||||
let source = "def 😀route\nref 😀route\n";
|
||||
@@ -35,9 +35,7 @@ async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_light("main", &first).unwrap();
|
||||
// Editors retain ordinary Windows paths; index snapshots use canonical
|
||||
// (verbatim on Windows) paths and the host's own project-root identity.
|
||||
let doc = project.documents.open(dunce::simplified(&first)).unwrap();
|
||||
let doc = project.documents.open(&first).unwrap();
|
||||
let plugins =
|
||||
Arc::new(PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap());
|
||||
plugins.set_project_policy(Arc::new(|_| true));
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
use ide_core::{CancellationToken, Diagnostic, Pos, Range, Severity};
|
||||
use ide_host::plugins::{HostWorkspace, PluginWorkspace};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_plugin::TextQuery;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Harness {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.open_project("second", &root).unwrap();
|
||||
Self { host, root, _tmp: tmp }
|
||||
}
|
||||
fn workspace(&self) -> HostWorkspace {
|
||||
HostWorkspace(Arc::downgrade(&self.host))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn window_editor_reports_use_document_language_dirty_state_and_focus() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("main.rs");
|
||||
std::fs::write(&path, "fn main() {}\n").unwrap();
|
||||
let p = f.host.project("main").unwrap();
|
||||
let doc = p.documents.open(&path).unwrap();
|
||||
p.documents
|
||||
.apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "// unsaved\n".into() }])
|
||||
.unwrap();
|
||||
f.host.mcp_reports.set_open_editors("main", vec![path.clone()]);
|
||||
f.host.mcp_reports.set_focused_editor(
|
||||
"main",
|
||||
Some(ide_host::mcp::FocusedEditor {
|
||||
path: path.clone().into(),
|
||||
selection: Range::default(),
|
||||
selected_text: String::new(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
let workspace = f.workspace();
|
||||
let editors = workspace.open_editors("main").unwrap();
|
||||
assert_eq!(editors.len(), 1);
|
||||
assert!(editors[0].dirty && editors[0].active);
|
||||
assert_eq!(editors[0].language, "rust");
|
||||
assert!(workspace.open_editors("second").unwrap().is_empty());
|
||||
let second = f.host.project("second").unwrap();
|
||||
second.documents.open(&path).unwrap();
|
||||
f.host.mcp_reports.set_open_editors("second", vec![path.clone()]);
|
||||
assert!(!workspace.open_editors("second").unwrap()[0].dirty);
|
||||
assert!(workspace.open_editors("main").unwrap()[0].dirty);
|
||||
assert!(workspace.buffer_text_in(Some("main"), &path).unwrap().contains("unsaved"));
|
||||
assert!(!workspace.buffer_text_in(Some("second"), &path).unwrap().contains("unsaved"));
|
||||
assert_ne!(workspace.document_in("main", &path), workspace.document_in("second", &path));
|
||||
f.host.mcp_reports.forget_window("main");
|
||||
assert!(workspace.open_editors("main").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn text_search_uses_overlay_excludes_utf16_and_limits() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("a.txt");
|
||||
std::fs::write(&path, "disk\n").unwrap();
|
||||
std::fs::create_dir(f.root.join("target")).unwrap();
|
||||
std::fs::write(f.root.join("target/ignored.txt"), "needle").unwrap();
|
||||
let p = f.host.project("main").unwrap();
|
||||
p.settings
|
||||
.set(ide_workspace::settings::SettingsScope::Project, "files.ignoredNames", serde_json::json!(["target"]))
|
||||
.unwrap();
|
||||
let doc = p.documents.open(&path).unwrap();
|
||||
p.documents
|
||||
.apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "😀needle\nneedle\n".into() }])
|
||||
.unwrap();
|
||||
f.host.project("second").unwrap().documents.open(&path).unwrap();
|
||||
let workspace = f.workspace();
|
||||
let query = TextQuery { pattern: "needle".into(), case_sensitive: true, ..Default::default() };
|
||||
let found = workspace.search_text(Some("main"), &f.root, query.clone(), 10, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(found.matches.len(), 2, "{found:?}; text={:?}", workspace.buffer_text(&path));
|
||||
assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8)));
|
||||
assert!(found.matches.iter().all(|m| m.location.path == path));
|
||||
let only_file = TextQuery { include: vec!["a.txt".into()], ..query.clone() };
|
||||
assert!(
|
||||
workspace
|
||||
.search_text(Some("second"), &f.root, only_file, 10, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.matches
|
||||
.is_empty()
|
||||
);
|
||||
let capped = workspace.search_text(Some("main"), &f.root, query.clone(), 1, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(capped.matches.len(), 1);
|
||||
assert!(capped.limit_hit);
|
||||
let excluded = TextQuery { exclude: vec!["*.txt".into()], ..query.clone() };
|
||||
assert!(
|
||||
workspace
|
||||
.search_text(Some("main"), &f.root, excluded, 10, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.matches
|
||||
.is_empty()
|
||||
);
|
||||
for query in [
|
||||
TextQuery { pattern: "NEEDLE".into(), case_sensitive: true, ..Default::default() },
|
||||
TextQuery { pattern: "need".into(), whole_word: true, ..Default::default() },
|
||||
TextQuery { include: vec!["*.rs".into()], ..query.clone() },
|
||||
] {
|
||||
assert!(
|
||||
workspace
|
||||
.search_text(Some("main"), &f.root, query, 10, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.matches
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
let multiline = TextQuery { pattern: "needle\nneedle".into(), regex: true, ..Default::default() };
|
||||
let found = workspace.search_text(Some("main"), &f.root, multiline, 10, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(1, 6)));
|
||||
let cancelled = CancellationToken::new();
|
||||
cancelled.cancel();
|
||||
assert!(matches!(
|
||||
workspace.search_text(Some("main"), &f.root, query, 10, &cancelled),
|
||||
Err(ide_plugin::HostError::Cancelled)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn problems_share_project_reports_and_keep_sources_separate() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("a.txt");
|
||||
std::fs::write(&path, "text").unwrap();
|
||||
let diagnostic = |source: &str| Diagnostic {
|
||||
range: Range::default(),
|
||||
severity: Severity::Warning,
|
||||
message: source.into(),
|
||||
source: Some(source.into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: None,
|
||||
};
|
||||
f.host.mcp_reports.report_problems(
|
||||
"second",
|
||||
true,
|
||||
vec![ide_host::mcp::FileProblems {
|
||||
path: path.clone().into(),
|
||||
items: vec![diagnostic("lsp"), diagnostic("plugin")],
|
||||
}],
|
||||
);
|
||||
let workspace = f.workspace();
|
||||
let files = workspace.problems("main", Some(&path)).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files.iter().all(|f| f.diagnostics.len() == 1));
|
||||
assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty());
|
||||
f.host.mcp_reports.forget_window("second");
|
||||
assert!(workspace.problems("main", None).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn search_decodes_closed_documents_the_same_way_as_the_editor() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("utf16.txt");
|
||||
let mut bytes = vec![0xff, 0xfe];
|
||||
for unit in "😀needle\r\n".encode_utf16() {
|
||||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let query = TextQuery { pattern: "needle".into(), ..Default::default() };
|
||||
let result = f.workspace().search_text(Some("main"), &f.root, query, 10, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert_eq!(result.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8)));
|
||||
assert_eq!(result.matches[0].line, "😀needle");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn plugin_debug_uses_the_source_language_and_revalidates_its_owned_program() {
|
||||
use ide_host::plugins::{Consent, PluginEvent, PluginHost, WindowRequest};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
std::fs::write(f.root.join("execution.marker"), "").unwrap();
|
||||
std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap();
|
||||
let events = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
let captured = events.clone();
|
||||
let plugins = Arc::new(
|
||||
PluginHost::new(
|
||||
f._tmp.path().join("plugins"),
|
||||
Arc::new(move |client: Option<&str>, event: PluginEvent| {
|
||||
captured.lock().push((client.map(str::to_owned), event))
|
||||
}),
|
||||
Limits::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
||||
f.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(&f.root), Some(std::path::Path::new("/bin/cat")))
|
||||
.unwrap();
|
||||
let mut config =
|
||||
plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap();
|
||||
config.args = vec!["entry.py".into()];
|
||||
ide_run::save_user_configs(&f.root, &[config]).unwrap();
|
||||
let config = f.workspace().run_configuration("main", "execution", "detected").unwrap();
|
||||
assert_eq!(config.debug_adapter.as_deref(), Some("debugpy"));
|
||||
let pinned = plugins.program_for_project("execution", "execution-tool", Some(&f.root)).unwrap();
|
||||
assert_eq!(plugins.prepare_debug_run("main", config.clone()).unwrap().command, pinned.to_string_lossy());
|
||||
let invocation =
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
|
||||
assert!(plugins.run_command("execution", "debug", None, invocation).unwrap().is_some());
|
||||
assert!(events.lock().iter().any(|(client, event)| client.as_deref() == Some("main") && matches!(event, PluginEvent::WindowRequest { request: WindowRequest::Run { config, debug: true, .. }, .. } if config.command == pinned.to_string_lossy())));
|
||||
plugins.set_capability("execution", "process:execution-tool", false).unwrap();
|
||||
assert!(plugins.prepare_debug_run("main", config).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
|
||||
use ide_host::plugins::{Consent, PluginEvent, PluginHost, run_targets};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
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>, _: PluginEvent| {}),
|
||||
Limits { idle_timeout: Some(std::time::Duration::from_millis(10)), ..Default::default() },
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
||||
f.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();
|
||||
let tool = f._tmp.path().join("wrapper");
|
||||
std::fs::write(&tool, "#!/bin/sh\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf '<%s>\\n' \"$@\"\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() };
|
||||
assert!(plugins.run_command("execution", "target", None, invocation()).is_err());
|
||||
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
plugins.tick_at(std::time::Instant::now());
|
||||
assert_eq!(plugins.info_of("execution").unwrap().status, ide_host::plugins::PluginStatus::Inactive);
|
||||
let targets = run_targets::list(&f.host, &plugins, "main").unwrap();
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert!(targets[0].prompt);
|
||||
assert!(f.host.project("main").unwrap().settings.effective().run.default_target.is_empty());
|
||||
let id = &targets[0].id;
|
||||
let settings = run_targets::answer(&f.host, &plugins, "main", id, false).unwrap();
|
||||
assert!(settings.run.default_target.is_empty());
|
||||
// A stale prompt in a second window cannot change an answered selection.
|
||||
assert!(run_targets::answer(&f.host, &plugins, "second", id, true).unwrap().run.default_target.is_empty());
|
||||
plugins.run_command("execution", "untarget", None, invocation()).unwrap();
|
||||
assert!(plugins.run_targets(&f.root).is_empty());
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
assert!(!plugins.run_targets(&f.root)[0].prompt);
|
||||
plugins.run_command("execution", "target", Some("preferred"), invocation()).unwrap();
|
||||
let preferred = plugins.run_targets(&f.root).into_iter().find(|t| t.target.id == "preferred").unwrap();
|
||||
let settings = run_targets::answer(&f.host, &plugins, "main", &preferred.id, true).unwrap();
|
||||
assert_eq!(settings.run.default_target, preferred.id);
|
||||
assert_eq!(settings.languages.php.run_target, preferred.id);
|
||||
assert_eq!(settings.languages.node.run_target, preferred.id);
|
||||
assert_eq!(f.host.project("second").unwrap().settings.effective().run.default_target, preferred.id);
|
||||
std::fs::write(f.root.join("execution.marker"), "").unwrap();
|
||||
std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap();
|
||||
let mut detected =
|
||||
plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap();
|
||||
detected.args = vec!["entry.py".into()];
|
||||
ide_run::save_user_configs(&f.root, &[detected]).unwrap();
|
||||
let mut debug = f.workspace().run_configuration("main", "execution", "detected").unwrap();
|
||||
debug.extras.target = None;
|
||||
let error = plugins.prepare_debug_run("main", debug).unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert!(error.to_string().contains("target"));
|
||||
assert!(plugins.run_command("execution", "debug", None, invocation()).unwrap_err().to_string().contains("target"));
|
||||
let output = Arc::new(parking_lot::Mutex::new(String::new()));
|
||||
let captured = output.clone();
|
||||
let config = ide_run::RunConfig {
|
||||
id: "php-target".into(),
|
||||
name: "PHP target".into(),
|
||||
kind: "php".into(),
|
||||
command: "php".into(),
|
||||
args: vec!["x.php".into(), "a b; $(touch forbidden)".into()],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let run = ide_host::ops::run::start(
|
||||
&f.host,
|
||||
"main",
|
||||
config.clone(),
|
||||
Arc::new(move |chunk| {
|
||||
if let ide_run::OutputChunk::Stdout { text } = chunk {
|
||||
captured.lock().push_str(&text);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..100 {
|
||||
if f.host.processes.list().iter().any(|r| r.id == run.id && !r.running) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
let text = output.lock().clone();
|
||||
assert!(text.contains("<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<a b; $(touch forbidden)>"), "{text}");
|
||||
assert!(text.contains(&format!("cwd={}", f.root.display())), "{text}");
|
||||
assert!(!f.root.join("forbidden").exists());
|
||||
// Pin checks are repeated at launch, not only at registration.
|
||||
std::fs::write(&tool, "#!/bin/sh\nexit 99\n").unwrap();
|
||||
assert!(ide_host::ops::run::start(&f.host, "main", config.clone(), Arc::new(|_| {})).await.is_err());
|
||||
plugins.set_capability("execution", "process:execution-tool", false).unwrap();
|
||||
assert!(plugins.run_targets(&f.root).is_empty());
|
||||
assert!(ide_host::ops::run::start(&f.host, "main", config, Arc::new(|_| {})).await.is_err());
|
||||
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
f.host.set_project_trust(&f.root, false, false).await.unwrap();
|
||||
assert!(plugins.run_targets(&f.root).is_empty());
|
||||
assert!(
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap_err().to_string().contains("trusted")
|
||||
);
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
f.host.close_project_detached("main");
|
||||
assert_eq!(plugins.run_targets(&f.root).len(), 1);
|
||||
f.host.close_project_detached("second");
|
||||
assert!(plugins.run_targets(&f.root).is_empty());
|
||||
plugins.shutdown();
|
||||
}
|
||||
@@ -75,6 +75,26 @@ impl TestWorkspace {
|
||||
}
|
||||
|
||||
impl PluginWorkspace for TestWorkspace {
|
||||
fn run_configuration(&self, _client: &str, plugin: &str, id: &str) -> Result<ide_run::RunConfig, HostError> {
|
||||
if !(plugin == "execution" && matches!(id, "detected" | "dynamic") || plugin == "api14" && id == "tests") {
|
||||
return Err(HostError::NotFound(id.into()));
|
||||
}
|
||||
Ok(ide_run::RunConfig {
|
||||
id: format!("plugin:{plugin}:{id}"),
|
||||
name: "Tests".into(),
|
||||
kind: "plugin".into(),
|
||||
command: "execution-tool".into(),
|
||||
args: vec!["test".into()],
|
||||
cwd: Some(self.root.clone().into()),
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: true,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras { params: [("framework".into(), "plugin".into())].into(), ..Default::default() },
|
||||
})
|
||||
}
|
||||
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
|
||||
}
|
||||
@@ -285,6 +305,7 @@ fn fixture(limits: Limits) -> Fixture {
|
||||
impl Fixture {
|
||||
fn inv(&self) -> Invocation {
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some("main".into()),
|
||||
workspace_root: Some(self.project.clone()),
|
||||
editor: None,
|
||||
@@ -5642,3 +5663,410 @@ fn view_secondary_requires_api_1_3_and_a_boolean_tool_window_placement() {
|
||||
assert!(err.message.contains(message), "{api}, {location}, {value}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_denies_new_privileges_and_requires_an_invoking_window() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&sample("api14"), &Consent::default()).unwrap();
|
||||
for (command, arg, capability) in [
|
||||
("execute", "test.action", "ide.commands"),
|
||||
("execute", "plugin.other.run", "ide.commands"),
|
||||
("execute", "plugin.api14.other.run", "ide.commands"),
|
||||
("open-editors", "", "editor.read"),
|
||||
("open-editor", "a.txt", "project.read"),
|
||||
("search-text", "needle", "project.read"),
|
||||
("problems", "", "project.read"),
|
||||
("decorations", "a.txt", "project.read"),
|
||||
("clear-decorations", "", "project.read"),
|
||||
("file-decoration", "a.txt", "project.read"),
|
||||
("clear-file-decorations", "", "project.read"),
|
||||
("vcs-log", "", "vcs.read"),
|
||||
("vcs-blame", "a.txt", "vcs.read"),
|
||||
("vcs-diff", "", "vcs.read"),
|
||||
("ipc", "/tmp/api14-no-grant", "ipc:"),
|
||||
("fetch", "http://example.com/", "net.http:"),
|
||||
("secret", "value", "secrets"),
|
||||
("delete-secret", "", "secrets"),
|
||||
] {
|
||||
let error = f.run("api14", command, Some(arg)).unwrap_err();
|
||||
assert!(error.contains(capability) && error.contains("not granted"), "{error}");
|
||||
}
|
||||
for command in ["window-status", "remove-window-status", "execute", "oauth", "output"] {
|
||||
let error = f.host.run_command("api14", command, Some("value"), Invocation::default()).unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput, "{command}: {error:?}");
|
||||
}
|
||||
let own = f.run("api14", "execute", Some("plugin.api14.execute")).unwrap_err();
|
||||
assert!(own.contains("recursive") && !own.contains("not granted"), "{own}");
|
||||
f.host.set_capability("api14", "secrets", true).unwrap();
|
||||
f.run("api14", "secret", Some(" raw value ")).unwrap();
|
||||
assert_eq!(f.host.declared_secret("api14", "token"), Some(" raw value ".into()));
|
||||
assert!(f.run("api14", "undeclared-secret", Some("value")).unwrap_err().contains("undeclared"));
|
||||
f.run("api14", "delete-secret", None).unwrap();
|
||||
assert_eq!(f.host.declared_secret("api14", "token"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_window_status_overrides_fall_back_and_are_withdrawn() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("api14"));
|
||||
f.run("api14", "global-status", Some("global")).unwrap();
|
||||
f.run("api14", "window-status", Some("main")).unwrap();
|
||||
let snapshot = f.host.status_snapshot("main");
|
||||
assert!(snapshot.iter().any(|i| i.window && i.item.text == "main"));
|
||||
assert!(!f.host.status_snapshot("other").iter().any(|i| i.window));
|
||||
f.run("api14", "global-status", Some("updated")).unwrap();
|
||||
f.run("api14", "remove-window-status", None).unwrap();
|
||||
assert_eq!(f.host.status_snapshot("main").len(), 1);
|
||||
assert_eq!(f.host.status_snapshot("main")[0].item.text, "updated");
|
||||
f.run("api14", "window-status", Some("override")).unwrap();
|
||||
f.host.forget_client("main");
|
||||
assert!(!f.host.status_snapshot("main").iter().any(|i| i.window));
|
||||
f.host.reopen_client("main");
|
||||
f.run("api14", "window-status", Some("again")).unwrap();
|
||||
f.host.set_enabled("api14", false).unwrap();
|
||||
assert!(f.host.status_snapshot("main").is_empty());
|
||||
assert!(f.take_events().iter().any(|(window, event)| window.as_deref() == Some("main")
|
||||
&& matches!(event, PluginEvent::WindowStatusItemChanged { item: None, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_command_requests_preserve_window_result_and_reject_cycles() {
|
||||
let f = Arc::new(fixture(Limits::default()));
|
||||
f.install(&sample("api14"));
|
||||
assert!(f.run("api14", "execute", Some("plugin.api14.execute")).unwrap_err().contains("recursive"));
|
||||
for answer in [CommandAnswer::Value(Some("result".into())), CommandAnswer::Value(None), CommandAnswer::NotFound] {
|
||||
f.take_events();
|
||||
let runner = f.clone();
|
||||
let worker = std::thread::spawn(move || runner.run("api14", "execute", Some("test.action")));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let request = loop {
|
||||
if let Some((window, PluginEvent::ExecuteCommand { request_id, arg, chain, .. })) =
|
||||
f.events.lock().iter().find(|(_, e)| matches!(e, PluginEvent::ExecuteCommand { .. })).cloned()
|
||||
{
|
||||
assert_eq!(window.as_deref(), Some("main"));
|
||||
assert_eq!(arg.as_deref(), Some("argument"));
|
||||
assert_eq!(chain, ["api14"]);
|
||||
break request_id;
|
||||
}
|
||||
assert!(Instant::now() < deadline);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert!(!f.host.answer_command("other", &request, answer.clone()));
|
||||
assert!(f.host.answer_command("main", &request, answer.clone()));
|
||||
let result = worker.join().unwrap();
|
||||
match answer {
|
||||
CommandAnswer::Value(value) => assert_eq!(result.unwrap(), value),
|
||||
CommandAnswer::NotFound => assert!(result.unwrap_err().contains("test.action")),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_settings_and_input_events_use_other_and_capture_the_editor() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("api14"));
|
||||
use ide_plugin::WorkbenchEvent as E;
|
||||
f.host.post_events(Some("main"), vec![E::SettingsChanged(Vec::new())]);
|
||||
f.workspace
|
||||
.settings
|
||||
.lock()
|
||||
.insert("api14".into(), serde_json::from_value(serde_json::json!({"enabled": true})).unwrap());
|
||||
let path = to_ipc(&f.project.join("src/notes.txt"));
|
||||
f.host.post_window_events(
|
||||
"main",
|
||||
vec![
|
||||
E::SettingsChanged(vec!["another-plugin-key".into()]),
|
||||
E::FileClosed(path.clone()),
|
||||
E::InputModeChanged(ide_plugin::InputMode {
|
||||
path: path.clone(),
|
||||
mode: "visual".into(),
|
||||
sub: Some("linewise".into()),
|
||||
}),
|
||||
],
|
||||
Some(PluginEditor {
|
||||
path: path.clone(),
|
||||
language: "text".into(),
|
||||
selections: vec![ide_core::Range::default()],
|
||||
}),
|
||||
);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(2));
|
||||
assert!(f.host.wait_events(Duration::from_secs(5)));
|
||||
let received: serde_json::Value = serde_json::from_str(&f.run("api14", "events", None).unwrap().unwrap()).unwrap();
|
||||
let events = received.as_array().unwrap();
|
||||
assert_eq!(events.len(), 3, "{received}");
|
||||
assert_eq!(events[0]["kind"], "settings-changed");
|
||||
assert_eq!(events[0]["data"], "[\"enabled\"]");
|
||||
assert_eq!(events[2]["kind"], "input-mode-changed");
|
||||
assert_eq!(events[2]["editor"][0], path);
|
||||
assert_eq!(events[2]["editor"][1][0], serde_json::json!([0, 0, 0, 0]));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_ipc_echo_checks_the_grant_in_the_wasm_binding() {
|
||||
use std::io::{Read, Write};
|
||||
let f = fixture(Limits::default());
|
||||
let pkg = f._tmp.path().join("api14-package");
|
||||
copy_dir(&sample("api14"), &pkg);
|
||||
let path = f._tmp.path().join("echo.sock");
|
||||
let manifest = pkg.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest)
|
||||
.unwrap()
|
||||
.replace("ipc:$TMPDIR/api14-*", &format!("ipc:{}", f._tmp.path().join("*.sock").display()));
|
||||
std::fs::write(manifest, text).unwrap();
|
||||
f.install(&pkg);
|
||||
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut bytes = [0; 4];
|
||||
stream.read_exact(&mut bytes).unwrap();
|
||||
stream.write_all(&bytes).unwrap();
|
||||
});
|
||||
assert_eq!(f.run("api14", "ipc", path.to_str()).unwrap().as_deref(), Some("echo"));
|
||||
worker.join().unwrap();
|
||||
f.host.set_capability("api14", &format!("ipc:{}", f._tmp.path().join("*.sock").display()), false).unwrap();
|
||||
assert!(f.run("api14", "ipc", path.to_str()).unwrap_err().contains("not granted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_oauth_wait_excludes_the_call_budget_and_closes_after_redirect() {
|
||||
use std::io::{Read, Write};
|
||||
let f = Arc::new(fixture(Limits { timeout: Duration::from_millis(200), ..Limits::default() }));
|
||||
f.install(&sample("api14"));
|
||||
let runner = f.clone();
|
||||
let worker = std::thread::spawn(move || runner.run("api14", "oauth", None));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let uri = loop {
|
||||
if let Some((_, PluginEvent::OutputChanged { text: Some(text), .. })) = f
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|(_, e)| matches!(e, PluginEvent::OutputChanged { channel, .. } if channel == "oauth"))
|
||||
.cloned()
|
||||
{
|
||||
break text;
|
||||
}
|
||||
assert!(Instant::now() < deadline);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert!(uri.starts_with("http://127.0.0.1:"));
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let authority = uri.strip_prefix("http://").unwrap().split('/').next().unwrap();
|
||||
let mut stream = std::net::TcpStream::connect(authority).unwrap();
|
||||
stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=test HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response).unwrap();
|
||||
assert!(response.contains("You can close this tab"));
|
||||
assert_eq!(worker.join().unwrap().unwrap().as_deref(), Some("code=a%2Bb&state=test"));
|
||||
assert!(std::net::TcpStream::connect(authority).is_err());
|
||||
f.run("api14", "global-status", Some("still running")).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_executable_choices_are_pinned_per_project_and_reset_revalidates() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = fixture(Limits::default());
|
||||
let package = f._tmp.path().join("api14-process");
|
||||
copy_dir(&sample("api14"), &package);
|
||||
let manifest = package.join("plugin.toml");
|
||||
let text =
|
||||
std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", ");
|
||||
std::fs::write(manifest, text).unwrap();
|
||||
f.install(&package);
|
||||
let executable = |name: &str| {
|
||||
let path = f._tmp.path().join(format!("{name}-bin"));
|
||||
std::fs::write(&path, format!("#!/bin/sh\nprintf '{name}'\n")).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
path.canonicalize().unwrap()
|
||||
};
|
||||
let global = executable("global");
|
||||
let project = executable("project");
|
||||
f.host.choose_program("api14", "process:api14-tool", None, Some(&global)).unwrap();
|
||||
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&project)).unwrap();
|
||||
assert_eq!(f.host.program_for("api14", "api14-tool").unwrap(), global);
|
||||
assert_eq!(f.host.program_for_project("api14", "api14-tool", Some(&f.project)).unwrap(), project);
|
||||
for command in ["process-run", "process-run-command", "process-start"] {
|
||||
assert_eq!(f.run("api14", command, None).unwrap().as_deref(), Some("project"));
|
||||
let invocation = Invocation { client: Some("global".into()), ..Invocation::default() };
|
||||
assert_eq!(f.host.run_command("api14", command, None, invocation).unwrap().as_deref(), Some("global"));
|
||||
}
|
||||
f.run("api14", "terminal", None).unwrap();
|
||||
assert!(
|
||||
f.take_events().iter().any(
|
||||
|(_, e)| matches!(e, PluginEvent::OpenTerminal { program, .. } if program.as_ref() == project.as_path())
|
||||
)
|
||||
);
|
||||
f.run("api14", "log", None).unwrap();
|
||||
assert!(
|
||||
f.take_events()
|
||||
.iter()
|
||||
.any(|(_, e)| matches!(e, PluginEvent::OpenLogTab { log, .. } if log.program == "api14-tool"))
|
||||
);
|
||||
let unrelated = f._tmp.path().join("unrelated");
|
||||
assert_eq!(f.host.program_for_project("api14", "api14-tool", Some(&unrelated)).unwrap(), global);
|
||||
std::fs::write(&project, "changed").unwrap();
|
||||
assert!(f.host.program_for_project("api14", "api14-tool", Some(&f.project)).is_err());
|
||||
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), None).unwrap();
|
||||
assert_eq!(f.host.program_for_project("api14", "api14-tool", Some(&f.project)).unwrap(), global);
|
||||
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&project)).unwrap();
|
||||
std::fs::write(&global, "changed").unwrap();
|
||||
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), None).unwrap();
|
||||
assert!(
|
||||
!f.host.info_of("api14").unwrap().capabilities.iter().find(|c| c.id == "process:api14-tool").unwrap().granted
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn execution_fixture() -> Arc<Fixture> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Arc::new(fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() }));
|
||||
f.install(&sample("execution"));
|
||||
std::fs::write(f.project.join("execution.marker"), "").unwrap();
|
||||
let executable = f._tmp.path().join("execution-bin");
|
||||
std::fs::write(&executable, "#!/bin/sh\nprintf '%s\n' \"$@\"\n").unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
f.host.choose_program("execution", "process:execution-tool", Some(&f.project), Some(&executable)).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn execution_request(f: &Arc<Fixture>, command: &str) -> (String, WindowRequest) {
|
||||
let command = command.to_owned();
|
||||
let runner = f.clone();
|
||||
let worker = std::thread::spawn(move || runner.run("execution", &command, None));
|
||||
let mut request = None;
|
||||
assert!(eventually(|| {
|
||||
for (client, event) in f.take_events() {
|
||||
if let PluginEvent::WindowRequest { request_id, request: r, .. } = event {
|
||||
assert_eq!(client.as_deref(), Some("main"));
|
||||
request = Some((request_id, r));
|
||||
}
|
||||
}
|
||||
request.is_some()
|
||||
}));
|
||||
let (request_id, request) = request.unwrap();
|
||||
assert!(!f.host.answer_command("second", &request_id, CommandAnswer::Value(None)));
|
||||
if !request_id.is_empty() {
|
||||
assert!(f.host.answer_command("main", &request_id, CommandAnswer::Value(None)));
|
||||
}
|
||||
let id = worker.join().unwrap().unwrap().unwrap_or_default();
|
||||
(id, request)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_stage3_runs_and_terminals_deliver_exactly_one_exit_after_idle_reclamation() {
|
||||
let f = execution_fixture();
|
||||
let (run, request) = execution_request(&f, "run");
|
||||
assert!(matches!(request, WindowRequest::Run { config, .. } if config.args == ["test", "appended"]));
|
||||
assert!(f.run("execution", "debug", None).unwrap_err().contains("debugger"));
|
||||
let (terminal, _) = execution_request(&f, "terminal");
|
||||
assert_eq!(execution_request(&f, "terminal").0, terminal);
|
||||
assert!(f.run("execution", "write", None).unwrap_err().contains("running terminal"));
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
f.host.tick();
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
assert!(!f.host.finish_operation("second", &run, Some(2), false));
|
||||
assert!(f.host.finish_operation("main", &run, Some(2), true));
|
||||
assert!(!f.host.finish_operation("main", &run, Some(2), true));
|
||||
assert!(f.host.finish_operation("main", &terminal, None, true));
|
||||
assert!(!f.host.finish_operation("main", &terminal, Some(0), false));
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(eventually(|| f.host.wait_events(Duration::from_secs(1))));
|
||||
let events: serde_json::Value =
|
||||
serde_json::from_str(&f.run("execution", "events", None).unwrap().unwrap()).unwrap();
|
||||
let events = events.as_array().unwrap();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[0]["kind"], "run-exited");
|
||||
let data: serde_json::Value = serde_json::from_str(events[0]["data"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(data["id"], run);
|
||||
assert_eq!(data["killed"], true);
|
||||
assert_eq!(events[1]["kind"], "terminal-exited");
|
||||
assert!(f.run("execution", "write", None).unwrap_err().contains("running terminal"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports() {
|
||||
let f = execution_fixture();
|
||||
f.host.refresh_run_configurations(&f.project);
|
||||
let configs = f.host.run_detection().detect(&f.project);
|
||||
assert!(configs.iter().any(|c| c.id == "plugin:execution:dynamic"
|
||||
&& c.is_test
|
||||
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")));
|
||||
assert_eq!(
|
||||
f.host.parse_test_results("main", "plugin:execution:dynamic", "batch".into()).unwrap().unwrap().len(),
|
||||
3
|
||||
);
|
||||
let (_, request) = execution_request(&f, "content");
|
||||
assert!(matches!(request, WindowRequest::ContentTab { tab } if tab.key == "config"));
|
||||
f.take_events();
|
||||
let action = ide_plugin::ContentTabAction {
|
||||
request_id: "ct-1".into(),
|
||||
key: "config".into(),
|
||||
action: "save".into(),
|
||||
content: "edited".into(),
|
||||
};
|
||||
assert!(f.host.content_tab_action("second", "execution", action.clone()).is_err());
|
||||
f.host.content_tab_action("main", "execution", action).unwrap();
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(eventually(|| {
|
||||
f.events.lock().iter().any(|(client, event)| client.as_deref() == Some("main") && matches!(event, PluginEvent::ContentTabReply { request_id, reply, .. } if request_id == "ct-1" && reply.saved == Some(true)))
|
||||
}));
|
||||
assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_fixture_exports_test_selection() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("api14"));
|
||||
assert_eq!(
|
||||
f.host.select_tests("main", "plugin:api14:tests", vec![vec!["Suite".into(), "Test".into()]]).unwrap(),
|
||||
Some(vec!["Suite::Test".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_test_selection_uses_owned_config_ids_and_name_paths() {
|
||||
let f = execution_fixture();
|
||||
f.host.refresh_run_configurations(&f.project);
|
||||
for id in ["dynamic", "detected"] {
|
||||
assert_eq!(
|
||||
f.host
|
||||
.select_tests(
|
||||
"main",
|
||||
&format!("plugin:execution:{id}"),
|
||||
vec![vec!["Suite".into(), "Test".into()], vec!["Other".into()]]
|
||||
)
|
||||
.unwrap(),
|
||||
Some(vec![id.into(), "Suite::Test".into(), "Other".into()])
|
||||
);
|
||||
}
|
||||
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec!["none".into()]]).unwrap(), None);
|
||||
assert_eq!(f.host.select_tests("main", "cargo:test", vec![vec!["Test".into()]]).unwrap(), None);
|
||||
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![]).unwrap(), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_test_selection_traps_and_timeouts_are_unsupported() {
|
||||
for failure in ["trap", "timeout"] {
|
||||
let f = execution_fixture();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_stage3_terminal_calls_are_denied_without_process_grants() {
|
||||
let f = execution_fixture();
|
||||
f.host.set_capability("execution", "process:execution-tool", false).unwrap();
|
||||
assert!(f.run("execution", "terminal", None).unwrap_err().contains("not granted"));
|
||||
}
|
||||
@@ -27,6 +27,7 @@ struct Client {
|
||||
events: Mutex<Vec<EventMessage>>,
|
||||
plugin_events: Mutex<Vec<PluginEventMessage>>,
|
||||
secret_requests: Mutex<Vec<SecretRequest>>,
|
||||
desktop: Option<Arc<PluginHost>>,
|
||||
}
|
||||
|
||||
impl Handler for Client {
|
||||
@@ -45,6 +46,19 @@ impl Handler for Client {
|
||||
self.secret_requests.lock().push(r);
|
||||
return Ok(serde_json::to_value(value).unwrap());
|
||||
}
|
||||
if let Some(desktop) = &self.desktop {
|
||||
use ide_host::plugins::remote_host::{AUTH_REQUEST, SECRET_WRITE};
|
||||
if method == AUTH_REQUEST {
|
||||
return Ok(
|
||||
serde_json::to_value(desktop.remote_auth("main", serde_json::from_value(params).unwrap())).unwrap()
|
||||
);
|
||||
}
|
||||
if method == SECRET_WRITE {
|
||||
return Ok(
|
||||
serde_json::to_value(desktop.remote_secret_write(serde_json::from_value(params).unwrap())).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
|
||||
}
|
||||
}
|
||||
@@ -207,11 +221,11 @@ async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
|
||||
})
|
||||
.await;
|
||||
|
||||
// Disabled on the desktop: gone from the server.
|
||||
// Disabled on the desktop: retained but stopped on the server.
|
||||
desktop.set_enabled("openidea.checkstyle", false).unwrap();
|
||||
let removed = sync(&peer, &desktop, &[]).await;
|
||||
assert_eq!(removed.removed, ["openidea.checkstyle"]);
|
||||
assert!(plugins.host().info_of("openidea.checkstyle").is_err());
|
||||
assert!(removed.removed.is_empty());
|
||||
assert_eq!(plugins.host().info_of("openidea.checkstyle").unwrap().status, PluginStatus::Disabled);
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
|
||||
@@ -350,3 +364,515 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
|
||||
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let desktop = Arc::new(
|
||||
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.unwrap(),
|
||||
);
|
||||
desktop.install_from_dir(&fixture("api14"), &Consent::all()).unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
let project = project.canonicalize().unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer_slot.clone();
|
||||
let host = Arc::new(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(move |w| rpc::event_sink(sink_peer.clone(), Some(w.into()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
let server = tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let client = Arc::new(Client { desktop: Some(desktop.clone()), ..Client::default() });
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
|
||||
sync(&peer, &desktop, &[]).await;
|
||||
let report = sync(&peer, &desktop, &["api14"]).await;
|
||||
assert!(report.failed.is_empty(), "{report:?}");
|
||||
let run = |command: &str, arg: Option<&str>| json!({ "pluginId": "api14", "commandId": command, "arg": arg, "editor": null });
|
||||
let remote_file = project.join("remote.txt");
|
||||
std::fs::write(&remote_file, "remote text").unwrap();
|
||||
invoke(&peer, "mcp_set_open_editors", json!({"paths": [remote_file]})).await.unwrap();
|
||||
invoke(&peer, "mcp_set_focused_editor", json!({"editor": {"path": remote_file, "selection": {"start": {"line": 0, "col": 0}, "end": {"line": 0, "col": 0}}, "selectedText": ""}, "focused": true})).await.unwrap();
|
||||
let editors = invoke(&peer, "plugin_run_command", run("open-editors", None)).await.unwrap();
|
||||
assert!(editors.as_str().unwrap().contains("remote.txt"));
|
||||
assert!(editors.as_str().unwrap().contains("active: true"));
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert_eq!(problems.as_str(), Some("[]"));
|
||||
let other_file = project.join("other.txt");
|
||||
std::fs::write(&other_file, "other text").unwrap();
|
||||
let outside_file = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside_file, "outside").unwrap();
|
||||
let problem = |path: &Path, message: &str| {
|
||||
json!({"path": path, "items": [{
|
||||
"range": {"start": {"line": 0, "col": 1}, "end": {"line": 0, "col": 3}},
|
||||
"severity": "warning", "message": message, "source": "remote-check", "code": null
|
||||
}]})
|
||||
};
|
||||
let files = json!([
|
||||
problem(&remote_file, "remote warning"),
|
||||
problem(&other_file, "other warning"),
|
||||
problem(&outside_file, "outside warning")
|
||||
]);
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(problems.contains("other warning"), "{problems}");
|
||||
assert!(!problems.contains("outside warning"), "{problems}");
|
||||
for path in ["remote.txt", remote_file.to_str().unwrap()] {
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(!problems.contains("other warning"), "{problems}");
|
||||
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
|
||||
}
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
|
||||
.await
|
||||
.unwrap();
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(!problems.as_str().unwrap().contains("remote warning"));
|
||||
assert!(problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(
|
||||
&peer,
|
||||
"mcp_report_problems",
|
||||
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(problems.as_str().unwrap().contains("replacement warning"));
|
||||
assert!(!problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
|
||||
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
|
||||
let sets = invoke(&peer, "plugin_decoration_sets", json!({})).await.unwrap();
|
||||
assert_eq!(sets["api14"][0]["root"], project.to_str().unwrap());
|
||||
until("forwarded editor and decoration events", 5, || {
|
||||
let events = client.plugin_events.lock();
|
||||
events.iter().any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::EditorOpen { .. }))
|
||||
&& events.iter().any(|m| matches!(m.event, PluginEvent::DecorationsChanged { .. }))
|
||||
})
|
||||
.await;
|
||||
invoke(&peer, "plugin_run_command", run("secret", Some("remote-token"))).await.unwrap();
|
||||
assert_eq!(desktop.declared_secret("api14", "token").as_deref(), Some("remote-token"));
|
||||
invoke(&peer, "plugin_run_command", run("delete-secret", None)).await.unwrap();
|
||||
assert_eq!(desktop.declared_secret("api14", "token"), None);
|
||||
invoke(&peer, "plugin_run_command", run("window-status", Some("remote"))).await.unwrap();
|
||||
let status = invoke(&peer, "plugin_status_items", json!({})).await.unwrap();
|
||||
assert!(status.as_array().unwrap().iter().any(|s| s["window"] == true && s["item"]["text"] == "remote"));
|
||||
let oauth_peer = peer.clone();
|
||||
let oauth = tokio::spawn(async move {
|
||||
invoke(
|
||||
&oauth_peer,
|
||||
"plugin_run_command",
|
||||
json!({ "pluginId": "api14", "commandId": "oauth", "arg": null, "editor": null }),
|
||||
)
|
||||
.await
|
||||
});
|
||||
until("remote OAuth URI", 5, || {
|
||||
client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|m| matches!(&m.event, PluginEvent::OutputChanged { channel, .. } if channel == "oauth"))
|
||||
})
|
||||
.await;
|
||||
let uri = client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|m| match &m.event {
|
||||
PluginEvent::OutputChanged { channel, text: Some(text), .. } if channel == "oauth" => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let authority = uri.strip_prefix("http://").unwrap().split('/').next().unwrap();
|
||||
let mut stream = tokio::net::TcpStream::connect(authority).await.unwrap();
|
||||
stream.write_all(b"GET /oauth/callback?code=desktop%2Bcode HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap();
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response).await.unwrap();
|
||||
assert!(response.contains("You can close this tab"));
|
||||
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
|
||||
let plugin_host = plugins.host().clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
plugin_host
|
||||
.run_command(
|
||||
"api14",
|
||||
"problems",
|
||||
None,
|
||||
ide_plugin::Invocation {
|
||||
client: Some("main".into()),
|
||||
workspace_root: Some(project),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.as_deref(), Some("[]"));
|
||||
plugins.host().shutdown();
|
||||
desktop.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution_host() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = tmp.path().join("bin");
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
let executable = bin.join("execution-tool");
|
||||
std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path_var = {
|
||||
let bin = bin.clone();
|
||||
Arc::new(move || Some(bin.clone().into_os_string()))
|
||||
};
|
||||
let desktop =
|
||||
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.unwrap();
|
||||
desktop.set_program_path(path_var.clone());
|
||||
desktop.install_from_dir(&fixture("execution"), &Consent::all()).unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
let project = project.canonicalize().unwrap();
|
||||
std::fs::write(project.join("execution.marker"), "").unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer_slot.clone();
|
||||
let host = Arc::new(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(move |w| rpc::event_sink(sink_peer.clone(), Some(w.into()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
|
||||
plugins.host().set_program_path(path_var);
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host, dispatcher, peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let client = Arc::new(Client::default());
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
|
||||
sync(&peer, &desktop, &[]).await;
|
||||
assert!(sync(&peer, &desktop, &["execution"]).await.failed.is_empty());
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":"execution", "commandId":"target", "arg":null, "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!(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();
|
||||
assert_eq!(settings["run"]["defaultTarget"], "");
|
||||
let config = json!({"id":"wrapped-php", "name":"Wrapped PHP", "kind":"php", "command":"php", "args":["x.php", "literal ; $(touch bad)"], "env":{}, "detected":false, "isTest":false, "target":id});
|
||||
invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":38}})).await.unwrap();
|
||||
until("remote wrapped run", 5, || project.join("wrapped.txt").exists()).await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(project.join("wrapped.txt")).unwrap(),
|
||||
"<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n"
|
||||
);
|
||||
assert!(!project.join("bad").exists());
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":"execution", "commandId":"untarget", "arg":null, "editor":null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(), json!([]));
|
||||
client.plugin_events.lock().clear();
|
||||
client.events.lock().clear();
|
||||
let runner = peer.clone();
|
||||
let run = tokio::spawn(async move {
|
||||
invoke(
|
||||
&runner,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "terminal", "arg": null, "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
until("terminal request", 5, || {
|
||||
client.plugin_events.lock().iter().any(|m| matches!(m.event, PluginEvent::WindowRequest { .. }))
|
||||
})
|
||||
.await;
|
||||
let (request_id, operation) = client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|m| match &m.event {
|
||||
PluginEvent::WindowRequest {
|
||||
request_id,
|
||||
request: ide_host::plugins::WindowRequest::Terminal { operation, .. },
|
||||
..
|
||||
} => {
|
||||
assert_eq!(m.window.as_deref(), Some("main"));
|
||||
Some((request_id.clone(), operation.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let terminal = invoke(
|
||||
&peer,
|
||||
"plugin_terminal_session_spawn",
|
||||
json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 35}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(terminal["id"].as_u64().is_some());
|
||||
assert!(
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_terminal_session_spawn",
|
||||
json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 36}})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_answer_command",
|
||||
json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(run.await.unwrap(), operation);
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "write", "arg": "finished\n", "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
until("PTY exit", 5, || client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::TerminalExited { .. })))
|
||||
.await;
|
||||
plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3));
|
||||
plugins.host().wait_events(Duration::from_secs(5));
|
||||
let received = invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap();
|
||||
assert_eq!(received.as_array().unwrap().len(), 1);
|
||||
assert_eq!(received[0]["kind"], "terminal-exited");
|
||||
let data: Value = serde_json::from_str(received[0]["data"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(data["id"], operation);
|
||||
assert_eq!(data["status"], 0);
|
||||
assert_eq!(data["closed"], false);
|
||||
|
||||
client.plugin_events.lock().clear();
|
||||
client.events.lock().clear();
|
||||
let runner = peer.clone();
|
||||
let run = tokio::spawn(async move {
|
||||
invoke(
|
||||
&runner,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "run", "arg": null, "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
until("run request", 5, || {
|
||||
client.plugin_events.lock().iter().any(|m| {
|
||||
matches!(&m.event, PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::Run { .. }, .. })
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (request_id, operation, config) = client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|m| match &m.event {
|
||||
PluginEvent::WindowRequest {
|
||||
request_id,
|
||||
request: ide_host::plugins::WindowRequest::Run { operation, config, .. },
|
||||
..
|
||||
} => Some((request_id.clone(), operation.clone(), config.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let info = invoke(&peer, "run_start", json!({"config": config, "onOutput": {"$channel": 37}})).await.unwrap();
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_answer_command",
|
||||
json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(run.await.unwrap(), operation);
|
||||
invoke(&peer, "run_stdin", json!({"id": info["id"], "data": "finished\n"})).await.unwrap();
|
||||
until("run exit", 5, || client.events.lock().iter().any(|m| matches!(&m.event, IdeEvent::RunExited { .. }))).await;
|
||||
plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3));
|
||||
plugins.host().wait_events(Duration::from_secs(5));
|
||||
let received = invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap();
|
||||
assert_eq!(received.as_array().unwrap().len(), 2);
|
||||
assert_eq!(received[1]["kind"], "run-exited");
|
||||
let data: Value = serde_json::from_str(received[1]["data"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(data["id"], operation);
|
||||
assert_eq!(data["status"], 0);
|
||||
assert_eq!(data["killed"], false);
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn api14_database_sources_credentials_and_disable_lifetime_run_on_the_server() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let id = "test.data-tools";
|
||||
let desktop =
|
||||
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.unwrap();
|
||||
desktop.install_from_dir(&fixture("data-tools"), &Consent::all()).unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
let project = project.canonicalize().unwrap();
|
||||
std::fs::write(project.join("data.sqlite"), "").unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let slot = Arc::new(OnceLock::new());
|
||||
let sink = slot.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(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| rpc::event_sink(sink.clone(), Some(w.into()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let plugins = RemotePlugins::start(&base.join("data"), &host, slot.clone()).unwrap();
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host.clone(), dispatcher, slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let client = Arc::new(Client::default());
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
let _: Value = peer.request("initialize", json!({"version":rpc::PROTOCOL_VERSION}), None).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path":project})).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({"path":project,"trusted":true,"folder":false})).await.unwrap();
|
||||
assert!(sync(&peer, &desktop, &[id]).await.failed.is_empty());
|
||||
let source =
|
||||
json!({"id":"local","name":"Remote local","driver":"sqlite","database":"data.sqlite","options":[]}).to_string();
|
||||
invoke(&peer, "plugin_run_command", json!({"pluginId":id,"commandId":"upsert","arg":source,"editor":null}))
|
||||
.await
|
||||
.unwrap();
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":id,"commandId":"credential","arg":"server-only-secret","editor":null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let sources = invoke(&peer, "db_sources", json!({})).await.unwrap();
|
||||
assert_eq!(sources[0]["pluginOwner"], id);
|
||||
let internal = sources[0]["id"].as_str().unwrap();
|
||||
let store = ide_db::secrets::SecretStore::new(base.join("data/db-passwords.json"), &project);
|
||||
assert_eq!(store.get(internal).as_deref(), Some("server-only-secret"));
|
||||
until("database source change", 5, || {
|
||||
client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::DbSourcesChanged))
|
||||
})
|
||||
.await;
|
||||
desktop.set_enabled(id, false).unwrap();
|
||||
let report = sync(&peer, &desktop, &[]).await;
|
||||
assert!(report.removed.is_empty());
|
||||
assert!(!invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty());
|
||||
desktop.set_enabled(id, true).unwrap();
|
||||
assert!(sync(&peer, &desktop, &[]).await.failed.is_empty());
|
||||
let runner = peer.clone();
|
||||
let connection = tokio::spawn(async move {
|
||||
invoke(
|
||||
&runner,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":"test.data-tools","commandId":"connect","arg":null,"editor":null}),
|
||||
)
|
||||
.await
|
||||
});
|
||||
until("database connect request", 5, || {
|
||||
client.plugin_events.lock().iter().any(|m| {
|
||||
matches!(
|
||||
m.event,
|
||||
PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::DatabaseConnect { .. }, .. }
|
||||
)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let request = client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|m| match &m.event {
|
||||
PluginEvent::WindowRequest {
|
||||
request_id,
|
||||
request: ide_host::plugins::WindowRequest::DatabaseConnect { .. },
|
||||
..
|
||||
} => Some(request_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
invoke(&peer, "db_connect", json!({"id":internal})).await.unwrap();
|
||||
invoke(&peer, "plugin_answer_command", json!({"requestId":request,"answer":{"kind":"value","value":null}}))
|
||||
.await
|
||||
.unwrap();
|
||||
connection.await.unwrap().unwrap();
|
||||
assert!(project.join("data.sqlite").exists());
|
||||
desktop.uninstall(id).unwrap();
|
||||
assert_eq!(sync(&peer, &desktop, &[]).await.removed, [id]);
|
||||
assert!(invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty());
|
||||
assert!(store.get(internal).is_none());
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! 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());
|
||||
}
|
||||
@@ -26,6 +26,11 @@ pub fn with_proxy(builder: reqwest::ClientBuilder, proxy: &ProxySettings) -> req
|
||||
|
||||
/// A client for `proxy`.
|
||||
pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
|
||||
build_with_redirect(proxy, reqwest::redirect::Policy::default())
|
||||
}
|
||||
|
||||
/// A client with an explicit redirect policy, for callers enforcing network grants.
|
||||
pub fn build_with_redirect(proxy: &ProxySettings, redirect: reqwest::redirect::Policy) -> IdeResult<reqwest::Client> {
|
||||
use rustls_platform_verifier::BuilderVerifierExt;
|
||||
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
@@ -44,6 +49,7 @@ pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
|
||||
];
|
||||
let builder = reqwest::Client::builder()
|
||||
.tls_backend_preconfigured(tls)
|
||||
.redirect(redirect)
|
||||
.resolve_to_addrs("localhost", &loopback)
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
@@ -248,6 +248,7 @@ 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`],
|
||||
@@ -255,11 +256,38 @@ 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)
|
||||
@@ -387,6 +415,19 @@ 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
|
||||
@@ -412,7 +453,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, framing, Some(on_output.clone()));
|
||||
let peer = Self::start(stdout, stdin, handler, false, pid, options, Some(on_output.clone()));
|
||||
|
||||
let stderr_task = stderr.map(|stderr| {
|
||||
let name = command.to_owned();
|
||||
@@ -466,7 +507,7 @@ impl Peer {
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start(reader, writer, handler, true, None, Framing::ContentLength, None)
|
||||
Self::start(reader, writer, handler, true, None, Framing::ContentLength.into(), None)
|
||||
}
|
||||
|
||||
/// [`connect`](Self::connect) with the given wire framing.
|
||||
@@ -475,7 +516,7 @@ impl Peer {
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start(reader, writer, handler, true, None, framing, None)
|
||||
Self::start(reader, writer, handler, true, None, framing.into(), None)
|
||||
}
|
||||
|
||||
/// [`Peer::connect`] over streams served by `child` (a server reached over a socket
|
||||
@@ -492,7 +533,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, None);
|
||||
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength.into(), None);
|
||||
peer.supervise(child, None);
|
||||
peer
|
||||
}
|
||||
@@ -503,13 +544,14 @@ impl Peer {
|
||||
handler: Arc<dyn Handler>,
|
||||
close_on_eof: bool,
|
||||
pid: Option<u32>,
|
||||
framing: Framing,
|
||||
options: TransportOptions,
|
||||
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 {
|
||||
@@ -520,6 +562,7 @@ impl Peer {
|
||||
closed,
|
||||
kill: CancellationToken::new(),
|
||||
pid,
|
||||
transform,
|
||||
reader_done: CancellationToken::new(),
|
||||
exited: CancellationToken::new(),
|
||||
});
|
||||
@@ -785,13 +828,16 @@ 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 msg: Value = match serde_json::from_slice(body) {
|
||||
let mut 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;
|
||||
|
||||
@@ -176,11 +176,30 @@ 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,
|
||||
|
||||
@@ -468,6 +468,25 @@ 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);
|
||||
@@ -566,6 +585,13 @@ 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 {
|
||||
|
||||
@@ -17,15 +17,30 @@ 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()) }
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -62,7 +77,11 @@ 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);
|
||||
Some(Location { path: path.into(), range, uri })
|
||||
let loc = Location { path: path.into(), range, uri };
|
||||
match &self.map_location {
|
||||
Some(map) => map(loc),
|
||||
None => Some(loc),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_edit(&self, path: &Path, e: lsp::TextEdit) -> TextEdit {
|
||||
@@ -662,6 +681,9 @@ 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
|
||||
}
|
||||
@@ -829,7 +851,13 @@ 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());
|
||||
Location { path: path.into(), range: Range::default(), uri }
|
||||
{
|
||||
let loc = Location { path: path.into(), range: Range::default(), uri };
|
||||
match &conv.map_location {
|
||||
Some(map) => map(loc)?,
|
||||
None => loc,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Some(WorkspaceSymbol { name: s.name, kind: symbol_kind(s.kind), container: s.container_name, location })
|
||||
@@ -949,6 +977,7 @@ 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
|
||||
@@ -1442,6 +1471,11 @@ 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());
|
||||
|
||||
@@ -139,6 +139,9 @@ 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))
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,33 @@ 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.
|
||||
@@ -125,6 +152,9 @@ 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();
|
||||
}
|
||||
|
||||
+231
-56
@@ -1,6 +1,6 @@
|
||||
//! Language server manager.
|
||||
//!
|
||||
//! Servers start lazily per `(server name, workspace root)` when a document of
|
||||
//! Servers start lazily per `(server name, workspace root, target)` when a document of
|
||||
//! a matching language opens. Several servers may serve one language (e.g.
|
||||
//! `vtsls` + `eslint`); results are merged. Every request takes positions in
|
||||
//! IDE coordinates (UTF-16, 0-based) and returns IDE types from [`types`],
|
||||
@@ -15,6 +15,7 @@ mod file_ops;
|
||||
mod file_watch;
|
||||
mod go_scope;
|
||||
mod handler;
|
||||
mod injections;
|
||||
mod inspect;
|
||||
pub mod jdtls;
|
||||
mod launch;
|
||||
@@ -23,6 +24,7 @@ mod loading;
|
||||
mod pull;
|
||||
mod scope;
|
||||
mod server;
|
||||
mod target;
|
||||
mod tcp;
|
||||
mod text;
|
||||
mod ts_file_moves;
|
||||
@@ -31,6 +33,7 @@ pub mod types;
|
||||
mod uri;
|
||||
mod virtual_doc;
|
||||
|
||||
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
|
||||
@@ -83,6 +86,10 @@ pub type ApplyEditHook = Arc<dyn Fn(WorkspaceEdit) -> bool + Send + Sync>;
|
||||
/// settings before each launch; receives the server's workspace root.
|
||||
pub type ConfigFn = dyn Fn(&mut LanguageServerConfig, &Path) + Send + Sync;
|
||||
pub type ConfigHook = Arc<ConfigFn>;
|
||||
/// Returns true when target configuration replaces host toolchain configuration.
|
||||
pub type TargetConfigFn = dyn Fn(&LanguageId, &mut LanguageServerConfig) -> bool + Send + Sync;
|
||||
/// Fetches a bounded read-only container document after rechecking grants and trust.
|
||||
pub type TargetReadFn = dyn Fn(&ide_language::ServerTarget, &str) -> IdeResult<String> + Send + Sync;
|
||||
/// Prepares dependencies on a blocking worker immediately before launching an installed server.
|
||||
pub type PrepareFn = dyn Fn(&LanguageServerConfig, &Path, &CancellationToken) + Send + Sync;
|
||||
/// The servers a language uses, from its definition's list (user settings
|
||||
@@ -181,6 +188,8 @@ pub struct LspManager {
|
||||
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
|
||||
applied_edits: tokio::sync::broadcast::Sender<(ide_core::ServerId, WorkspaceEdit)>,
|
||||
config_hook: RwLock<Option<ConfigHook>>,
|
||||
target_hook: RwLock<Option<Arc<TargetConfigFn>>>,
|
||||
target_read_hook: RwLock<Option<Arc<TargetReadFn>>>,
|
||||
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
|
||||
servers_hook: RwLock<Option<Arc<ServersFn>>>,
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
@@ -196,6 +205,7 @@ pub struct LspManager {
|
||||
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
|
||||
/// Contents fetched for virtual URIs.
|
||||
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
|
||||
injections: injections::State,
|
||||
}
|
||||
|
||||
/// Fetched virtual documents kept before the cache is reset.
|
||||
@@ -361,12 +371,23 @@ impl LspManager {
|
||||
sink: SharedSink,
|
||||
launcher: Arc<dyn Launcher>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new_cyclic(|me| Self {
|
||||
Arc::<Self>::new_cyclic(|me| Self {
|
||||
me: me.clone(),
|
||||
project_root,
|
||||
content_roots: RwLock::new(Vec::new()),
|
||||
languages,
|
||||
sink,
|
||||
sink: {
|
||||
let me = me.clone();
|
||||
Arc::new(move |event| {
|
||||
if let Some(mgr) = me.upgrade() {
|
||||
if let Some(event) = mgr.map_injected_event(event) {
|
||||
sink.emit(event);
|
||||
}
|
||||
} else {
|
||||
sink.emit(event);
|
||||
}
|
||||
})
|
||||
},
|
||||
launcher,
|
||||
runtime: runtime_handle(),
|
||||
servers: Mutex::new(Vec::new()),
|
||||
@@ -374,6 +395,8 @@ impl LspManager {
|
||||
apply_edit_hook: RwLock::new(None),
|
||||
applied_edits: tokio::sync::broadcast::channel(64).0,
|
||||
config_hook: RwLock::new(None),
|
||||
target_hook: RwLock::new(None),
|
||||
target_read_hook: RwLock::new(None),
|
||||
prepare_hook: RwLock::new(None),
|
||||
servers_hook: RwLock::new(None),
|
||||
candidates_hook: RwLock::new(None),
|
||||
@@ -386,6 +409,7 @@ impl LspManager {
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
injections: injections::State::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -412,12 +436,14 @@ impl LspManager {
|
||||
// Outside the project (library sources, …): reuse a running instance.
|
||||
servers
|
||||
.iter()
|
||||
.filter(|s| s.name == cfg.name)
|
||||
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
|
||||
.min_by_key(|s| s.root != self.project_root)
|
||||
.map(|s| s.root.clone())
|
||||
.unwrap_or_else(|| self.project_root.clone())
|
||||
});
|
||||
if let Some(s) = servers.iter().find(|s| s.name == cfg.name && s.root == root) {
|
||||
if let Some(s) =
|
||||
servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key)
|
||||
{
|
||||
s.add_language(language);
|
||||
return s.clone();
|
||||
}
|
||||
@@ -464,8 +490,13 @@ impl LspManager {
|
||||
/// to the open documents (and start them). Also attaches servers newly
|
||||
/// enabled in settings.
|
||||
fn attach_fallbacks(&self) {
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> =
|
||||
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> = self
|
||||
.docs
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|(_, d)| !d.uri.as_str().starts_with("openidea-target:"))
|
||||
.map(|(id, d)| (*id, d.language.clone(), d.path.clone()))
|
||||
.collect();
|
||||
let wanted: Vec<(DocId, Vec<Arc<Server>>)> =
|
||||
open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect();
|
||||
let mut start = Vec::new();
|
||||
@@ -542,7 +573,8 @@ impl LspManager {
|
||||
}
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if !cancel.is_cancelled()
|
||||
if cfg.target.is_none()
|
||||
&& !cancel.is_cancelled()
|
||||
&& me.launcher.available(&cfg, &tail.root)
|
||||
&& let Some(prepare) = prepare
|
||||
{
|
||||
@@ -553,7 +585,7 @@ impl LspManager {
|
||||
let root = tail.root.clone();
|
||||
if let Some(message) = &cfg.launch_error {
|
||||
// A missing binary still asks to be installed first.
|
||||
let missing = !me.launcher.available(&cfg, &root);
|
||||
let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root);
|
||||
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
|
||||
}
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
@@ -812,22 +844,27 @@ impl LspManager {
|
||||
|
||||
pub(crate) fn conv(&self, enc: Encoding) -> Conv<'_> {
|
||||
Conv::new(enc, move |p: &Path| self.text_for_path(p))
|
||||
.with_injections(move |loc| self.map_injected_location(loc), move |edit| self.map_injected_edit(edit))
|
||||
}
|
||||
|
||||
// ---- document sync (driven by ide-workspace's DocumentObserver) ----
|
||||
pub fn did_open(&self, doc: DocId, path: &Path, language: &LanguageId, version: u32, text: &str) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
if self.docs.lock().contains_key(&doc) {
|
||||
self.did_close(doc);
|
||||
self.close_injections(doc);
|
||||
self.close_document(doc);
|
||||
}
|
||||
let servers = self.servers_for(language, path);
|
||||
self.attach(doc, path.to_owned(), uri::path_to_uri(path), language, version, text, servers);
|
||||
self.schedule_injections(doc);
|
||||
}
|
||||
|
||||
/// Opens a read-only virtual document (`jdt://…`) on the server that
|
||||
/// returned its URI (`didOpen` only: it is never changed or saved).
|
||||
pub fn did_open_virtual(&self, doc: DocId, uri: &str, language: &LanguageId, version: u32, text: &str) {
|
||||
if self.docs.lock().contains_key(&doc) {
|
||||
self.did_close(doc);
|
||||
self.close_injections(doc);
|
||||
self.close_document(doc);
|
||||
}
|
||||
let Ok(lsp_uri) = uri.parse::<lsp::Uri>() else {
|
||||
tracing::debug!("not a valid URI: {uri}");
|
||||
@@ -887,6 +924,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub fn did_change(&self, doc: DocId, version: u32, changes: &[AppliedChange], full_text: &dyn Fn() -> String) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
let mut docs = self.docs.lock();
|
||||
let Some(d) = docs.get_mut(&doc) else { return };
|
||||
d.version = version as i32;
|
||||
@@ -929,9 +967,6 @@ impl LspManager {
|
||||
tracing::warn!("lsp text copy of {} out of sync; resending full text", d.path.display());
|
||||
text.set(&full_text());
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let full = json!([{ "text": text.as_str() }]);
|
||||
for (server, run, sync, enc) in targets {
|
||||
let key = match enc {
|
||||
@@ -954,6 +989,8 @@ impl LspManager {
|
||||
}
|
||||
drop(docs);
|
||||
self.schedule_pull(doc, None, pull::AFTER_EDIT);
|
||||
self.invalidate_injections(doc);
|
||||
self.schedule_injections(doc);
|
||||
}
|
||||
|
||||
/// Sends the documents open on instance `generation` of `server` again, as a `didChange`
|
||||
@@ -1004,6 +1041,12 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub fn did_close(&self, doc: DocId) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
self.close_injections(doc);
|
||||
self.close_document(doc);
|
||||
}
|
||||
|
||||
fn close_document(&self, doc: DocId) {
|
||||
let Some(d) = self.docs.lock().remove(&doc) else { return };
|
||||
for a in &d.servers {
|
||||
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
|
||||
@@ -1067,19 +1110,25 @@ impl LspManager {
|
||||
cancel: Option<&CancellationToken>,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<R> {
|
||||
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let stamp = self.injected_request_stamp(¶ms);
|
||||
let fut = async {
|
||||
if t.server.name == "csharp-ls" {
|
||||
let mut value = t.run.peer.request::<P, Value>(method, params, cancel.cloned()).await?;
|
||||
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
|
||||
virtual_doc::normalize_csharp_uris(&mut value);
|
||||
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
|
||||
} else {
|
||||
t.run.peer.request::<P, R>(method, params, cancel.cloned()).await
|
||||
t.run.peer.request::<Value, R>(method, params, cancel.cloned()).await
|
||||
}
|
||||
};
|
||||
match tokio::time::timeout(timeout, fut).await {
|
||||
let result = match tokio::time::timeout(timeout, fut).await {
|
||||
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
|
||||
Err(_) => Err(rpc_err(&t.server.name, method, RpcError::Timeout)),
|
||||
};
|
||||
if let Some((doc, version)) = stamp {
|
||||
self.check_injected_request(doc, version)?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---- features ----
|
||||
@@ -1114,8 +1163,10 @@ impl LspManager {
|
||||
let conv = self.conv(t.enc());
|
||||
let list = out.value.get_or_insert(CompletionList { is_incomplete: false, items: Vec::new() });
|
||||
list.is_incomplete |= incomplete;
|
||||
list.items
|
||||
.extend(items.into_iter().map(|i| convert::completion_item(&conv, &snap.path, i, t.server.id)));
|
||||
list.items.extend(items.into_iter().filter_map(|i| {
|
||||
let mut item = convert::completion_item(&conv, &snap.path, i, t.server.id);
|
||||
self.bound_completion(doc, &mut item).then_some(item)
|
||||
}));
|
||||
}
|
||||
Err(e) => out.fail(e),
|
||||
}
|
||||
@@ -1157,21 +1208,33 @@ impl LspManager {
|
||||
out.finish(Vec::new())
|
||||
}
|
||||
|
||||
pub async fn resolve_completion(&self, doc: DocId, item: CompletionItem) -> IdeResult<CompletionItem> {
|
||||
pub async fn resolve_completion(&self, doc: DocId, mut item: CompletionItem) -> IdeResult<CompletionItem> {
|
||||
let original = item.clone();
|
||||
let host_doc = doc;
|
||||
let doc = self.resolve_injected_origin(doc, &mut item.data)?;
|
||||
let snap = self.snapshot(doc)?;
|
||||
let Some(server) = snap.servers.iter().find(|s| s.id == item.server) else { return Ok(item) };
|
||||
let Some(server) = snap.servers.iter().find(|s| s.id == item.server) else { return Ok(original) };
|
||||
let Some(t) = self
|
||||
.ready_targets(std::slice::from_ref(server), |c| c.has_sub("completionProvider", "resolveProvider"))
|
||||
.await
|
||||
.pop()
|
||||
else {
|
||||
return Ok(item);
|
||||
return Ok(original);
|
||||
};
|
||||
if let Some(command) = item.command.take() {
|
||||
item.command = Some(self.injected_command(doc, command)?.1);
|
||||
}
|
||||
let lsp_item = convert::lsp_completion_item(&snap.text, t.enc(), &item);
|
||||
let resolved: lsp::CompletionItem =
|
||||
self.call(&t, "completionItem/resolve", lsp_item, None, DEFAULT_TIMEOUT).await?;
|
||||
if doc != host_doc {
|
||||
self.check_injected_request(doc, snap.version)?;
|
||||
}
|
||||
let mut out = item;
|
||||
convert::overlay_completion(&self.conv(t.enc()), &snap.path, &mut out, resolved, false);
|
||||
if !self.bound_completion(doc, &mut out) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -1188,7 +1251,11 @@ impl LspManager {
|
||||
// Still loading: no documentation yet, not an error.
|
||||
Err(e) if e.kind == ErrorKind::TimedOut && t.server.is_loading() => out.value = Some(None),
|
||||
Ok(Some(h)) => {
|
||||
if let Some(h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
|
||||
if let Some(mut h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
|
||||
if let Some(range) = h.range {
|
||||
let Some(range) = self.clip_injected_range(doc, range) else { continue };
|
||||
h.range = Some(range);
|
||||
}
|
||||
return Ok(Some(h));
|
||||
}
|
||||
out.value = Some(None);
|
||||
@@ -1395,8 +1462,8 @@ impl LspManager {
|
||||
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
|
||||
}
|
||||
|
||||
/// [`Self::references`]. Java, `project_wide`: without `libraries`, only the
|
||||
/// workspace's sources are searched (`libraries_skipped`); with it, those found
|
||||
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources
|
||||
/// are searched (`libraries_skipped`); with it and `project_wide`, those found
|
||||
/// there arrive on `partial` before the dependencies and the JDK are searched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn references_streaming(
|
||||
@@ -1563,7 +1630,10 @@ impl LspManager {
|
||||
};
|
||||
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
|
||||
} else {
|
||||
let first = query(false).await?;
|
||||
// Scoped probes (usage counts, bulk scans) run for many symbols at once:
|
||||
// without `libraries`, the dependencies and the JDK are not searched.
|
||||
libraries_skipped = !libraries;
|
||||
let first = query(!libraries).await?;
|
||||
let empty = first.is_empty();
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
@@ -1697,7 +1767,14 @@ impl LspManager {
|
||||
match r {
|
||||
Ok(Some(hs)) if !hs.is_empty() => {
|
||||
let conv = self.conv(t.enc());
|
||||
return Ok(hs.into_iter().map(|h| convert::highlight(&conv, &snap.path, h)).collect());
|
||||
return Ok(hs
|
||||
.into_iter()
|
||||
.filter_map(|h| {
|
||||
let mut h = convert::highlight(&conv, &snap.path, h);
|
||||
h.range = self.clip_injected_range(doc, h.range)?;
|
||||
Some(h)
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
Ok(_) => out.value = Some(Vec::new()),
|
||||
Err(e) => out.fail(e),
|
||||
@@ -1848,7 +1925,12 @@ impl LspManager {
|
||||
actions
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|a| convert::code_action(&conv, a, t.server.id))
|
||||
.map(|a| {
|
||||
let mut action = convert::code_action(&conv, a, t.server.id);
|
||||
self.tag_injected_origin(doc, &mut action.data);
|
||||
self.tag_injected_command(doc, action.server, &mut action.command);
|
||||
action
|
||||
})
|
||||
// Servers may ignore `only`, and the kinds kept for this one.
|
||||
.filter(|a| caps.restriction.keeps_action(a.kind.as_deref())),
|
||||
);
|
||||
@@ -1859,7 +1941,10 @@ impl LspManager {
|
||||
out.finish(Vec::new()).map(|actions| CodeActionQuery { actions, advertised_kinds })
|
||||
}
|
||||
|
||||
pub async fn resolve_code_action(&self, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
|
||||
pub async fn resolve_code_action(&self, doc: DocId, mut action: CodeAction) -> IdeResult<CodeAction> {
|
||||
let original = action.clone();
|
||||
let host_doc = doc;
|
||||
let doc = self.resolve_injected_origin(doc, &mut action.data)?;
|
||||
let snap = self.snapshot(doc)?;
|
||||
let servers: Vec<Arc<Server>> = match snap.servers.iter().find(|s| s.id == action.server) {
|
||||
Some(s) => vec![s.clone()],
|
||||
@@ -1867,19 +1952,33 @@ impl LspManager {
|
||||
};
|
||||
let Some(t) = self.ready_targets(&servers, |c| c.has_sub("codeActionProvider", "resolveProvider")).await.pop()
|
||||
else {
|
||||
return Ok(action);
|
||||
return Ok(original);
|
||||
};
|
||||
if let Some(command) = action.command.take() {
|
||||
action.command = Some(self.injected_command(doc, command)?.1);
|
||||
}
|
||||
let resolved: lsp::CodeAction =
|
||||
self.call(&t, "codeAction/resolve", convert::lsp_code_action(&action), None, DEFAULT_TIMEOUT).await?;
|
||||
Ok(convert::merge_code_action(&self.conv(t.enc()), action, resolved))
|
||||
if doc != host_doc {
|
||||
self.check_injected_request(doc, snap.version)?;
|
||||
}
|
||||
let mut action = convert::merge_code_action(&self.conv(t.enc()), action, resolved);
|
||||
self.tag_injected_origin(doc, &mut action.data);
|
||||
self.tag_injected_command(doc, action.server, &mut action.command);
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
pub async fn execute_command(&self, doc: DocId, command: Command) -> IdeResult<()> {
|
||||
if command.command == injections::COMMAND {
|
||||
self.refresh_injections(doc).await?;
|
||||
}
|
||||
let (doc, command, server) = self.injected_command(doc, command)?;
|
||||
let servers = match self.snapshot(doc) {
|
||||
Ok(snap) => snap.servers,
|
||||
Err(_) => self.servers.lock().clone(),
|
||||
};
|
||||
let name = command.command.clone();
|
||||
let servers: Vec<_> = servers.into_iter().filter(|s| server.is_none_or(|id| s.id == id)).collect();
|
||||
let targets = self.ready_targets(&servers, |c| c.commands().contains(&name)).await;
|
||||
let t = targets
|
||||
.first()
|
||||
@@ -1893,6 +1992,9 @@ impl LspManager {
|
||||
/// can carry commands other editors' extensions run themselves after the edit (vtsls'
|
||||
/// `_typescript.didOrganizeImports`): no server provides those.
|
||||
pub async fn provides_command(&self, doc: DocId, server: Option<ide_core::ServerId>, name: &str) -> bool {
|
||||
if name == injections::COMMAND {
|
||||
return self.provides_injected_command(doc, server);
|
||||
}
|
||||
let servers = match self.snapshot(doc) {
|
||||
Ok(snap) => snap.servers,
|
||||
Err(_) => self.servers.lock().clone(),
|
||||
@@ -1921,6 +2023,10 @@ impl LspManager {
|
||||
answers: Vec<FormAnswer>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Option<CommandForm>> {
|
||||
let (doc, command, pinned) = self.injected_command(doc, command)?;
|
||||
if pinned.is_some_and(|id| id != server) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// Nothing to ask for a command its server doesn't provide (executing it decides).
|
||||
if !self.provides_command(doc, Some(server), &command.command).await {
|
||||
return Ok(None);
|
||||
@@ -1944,17 +2050,19 @@ impl LspManager {
|
||||
return Err(IdeError::unavailable("unsupported command input type"));
|
||||
}
|
||||
}
|
||||
let mut command = Some(Command {
|
||||
title: command.title,
|
||||
command: response
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| IdeError::internal("command form omitted command"))?
|
||||
.to_owned(),
|
||||
arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([])))
|
||||
.map_err(parse_error)?,
|
||||
});
|
||||
self.tag_injected_command(doc, server, &mut command);
|
||||
Ok(Some(CommandForm {
|
||||
command: Command {
|
||||
title: command.title,
|
||||
command: response
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| IdeError::internal("command form omitted command"))?
|
||||
.to_owned(),
|
||||
arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([])))
|
||||
.map_err(parse_error)?,
|
||||
},
|
||||
command: command.expect("resolved command"),
|
||||
form_fields: serde_json::from_value(fields).map_err(parse_error)?,
|
||||
form_answers: serde_json::from_value(
|
||||
response.get("formAnswers").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])),
|
||||
@@ -1972,6 +2080,13 @@ impl LspManager {
|
||||
answers: Vec<FormAnswer>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
if command.command == injections::COMMAND {
|
||||
self.refresh_injections(doc).await?;
|
||||
}
|
||||
let (doc, command, pinned) = self.injected_command(doc, command)?;
|
||||
if pinned.is_some_and(|id| id != server) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let t = self.command_target(doc, server, &command.command).await?;
|
||||
let open_created = t.server.name == "gopls" && command.command == "gopls.add_test";
|
||||
let mut edits = self.applied_edits.subscribe();
|
||||
@@ -2670,8 +2785,15 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn origin_server(&self, uri: &str) -> Option<Arc<Server>> {
|
||||
let id = *self.virtual_origins.lock().get(uri)?;
|
||||
self.servers.lock().iter().find(|s| s.id == id).cloned()
|
||||
let id = self.virtual_origins.lock().get(uri).copied();
|
||||
self.servers
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|s| {
|
||||
Some(s.id) == id
|
||||
|| s.effective_config().target.as_ref().is_some_and(|t| target::external_path(t, uri).is_some())
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Contents of a virtual document, from the server that owns `uri`:
|
||||
@@ -2680,6 +2802,29 @@ impl LspManager {
|
||||
/// is read from its archive, a `jar:` class shown as its library source
|
||||
/// (see [`library_source`]) or else decompiled by the server. Cached.
|
||||
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
|
||||
if uri.starts_with("openidea-target:") {
|
||||
let server = self
|
||||
.origin_server(uri)
|
||||
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
|
||||
let target = server
|
||||
.effective_config()
|
||||
.target
|
||||
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
|
||||
let path = target::external_path(&target, uri)
|
||||
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
|
||||
let read = self
|
||||
.target_read_hook
|
||||
.read()
|
||||
.clone()
|
||||
.ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
|
||||
let languages = server.languages();
|
||||
let title = Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned());
|
||||
let description = Some(path.clone());
|
||||
let text = tokio::task::spawn_blocking(move || read(&target, &path))
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(e.to_string()))??;
|
||||
return Ok(self.store_virtual(uri, text, title, description, &languages, Some(server.id)));
|
||||
}
|
||||
if let Some(d) = self.virtual_docs.lock().get(uri) {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
@@ -2834,6 +2979,11 @@ impl LspManager {
|
||||
|
||||
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
|
||||
/// so edits flow through `ide-workspace::Documents`.
|
||||
pub fn set_target_hooks(&self, configure: Box<TargetConfigFn>, read: Box<TargetReadFn>) {
|
||||
*self.target_hook.write() = Some(Arc::from(configure));
|
||||
*self.target_read_hook.write() = Some(Arc::from(read));
|
||||
}
|
||||
|
||||
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
|
||||
*self.config_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
@@ -2854,9 +3004,23 @@ impl LspManager {
|
||||
cfg.launch_error = Some("Project is in Restricted Mode".into());
|
||||
return cfg;
|
||||
}
|
||||
if let Some(hook) = self.config_hook.read().clone() {
|
||||
let in_target = self.target_hook.read().clone().is_some_and(|hook| {
|
||||
server
|
||||
.languages()
|
||||
.iter()
|
||||
.filter(|language| {
|
||||
self.language_servers(language)
|
||||
.iter()
|
||||
.any(|c| c.name == server.name && c.target_key == server.config.target_key)
|
||||
})
|
||||
.any(|language| hook(language, &mut cfg))
|
||||
});
|
||||
if !in_target && let Some(hook) = self.config_hook.read().clone() {
|
||||
hook(&mut cfg, &server.root);
|
||||
}
|
||||
if let Some(target) = &mut cfg.target {
|
||||
target.origin = Some(server.id);
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
@@ -2877,6 +3041,9 @@ impl LspManager {
|
||||
self.ensure_started(server);
|
||||
}
|
||||
}
|
||||
if allowed != previous {
|
||||
self.injection_providers_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::reconfigure`] on the manager's runtime, for callers outside one (file watchers).
|
||||
@@ -2924,6 +3091,11 @@ impl LspManager {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if next.target != current.target {
|
||||
for doc in self.docs.lock().values_mut().filter(|d| d.uri.as_str().starts_with("openidea-target:")) {
|
||||
doc.servers.retain(|a| a.server.id != s.id);
|
||||
}
|
||||
}
|
||||
match s.status() {
|
||||
ide_core::event::ServerStatus::Ready
|
||||
| ide_core::event::ServerStatus::Busy
|
||||
@@ -2944,12 +3116,13 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
self.attach_fallbacks();
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
self.detach_shadowed_servers().await;
|
||||
self.injection_providers_changed();
|
||||
}
|
||||
|
||||
/// A server enabled again shadows the fallbacks attached in its place: they
|
||||
/// leave the open documents, and stop once no document uses them.
|
||||
async fn detach_shadowed_fallbacks(&self) {
|
||||
/// Detaches fallbacks shadowed by enabled servers and instances whose target
|
||||
/// no longer matches the document's language. Stop them once unused.
|
||||
async fn detach_shadowed_servers(&self) {
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> =
|
||||
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
|
||||
let mut shadowed: Vec<(DocId, Vec<ide_core::ServerId>)> = Vec::new();
|
||||
@@ -2963,7 +3136,10 @@ impl LspManager {
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
!wanted.iter().any(|w| w.id == a.server.id)
|
||||
&& configs.iter().any(|c| c.fallback && c.name == a.server.name)
|
||||
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|
||||
|| !configs
|
||||
.iter()
|
||||
.any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key))
|
||||
})
|
||||
.map(|a| a.server.id)
|
||||
.collect();
|
||||
@@ -3019,7 +3195,11 @@ impl LspManager {
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
let unlisted: Vec<Arc<Server>> = servers
|
||||
.into_iter()
|
||||
.filter(|s| !s.languages().iter().any(|l| self.language_servers(l).iter().any(|c| c.name == s.name)))
|
||||
.filter(|s| {
|
||||
!s.languages().iter().any(|l| {
|
||||
self.language_servers(l).iter().any(|c| c.name == s.name && c.target_key == s.config.target_key)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if unlisted.is_empty() {
|
||||
return;
|
||||
@@ -3053,13 +3233,8 @@ impl LspManager {
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
for server in servers {
|
||||
let encoding = server.running().map(|r| r.caps.read().encoding).unwrap_or_default();
|
||||
let conv = self.conv(encoding);
|
||||
for (path, items) in server.diagnostics_by_path() {
|
||||
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
|
||||
server.emit(
|
||||
&self.sink,
|
||||
ide_core::IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items },
|
||||
);
|
||||
self.emit_diagnostics(&server, path, items, encoding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! and shown together.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::{LspManager, convert, virtual_doc};
|
||||
use crate::{LspManager, virtual_doc};
|
||||
use ide_core::{DocId, IdeEvent};
|
||||
use ide_jsonrpc::RpcError;
|
||||
use lsp_types as lsp;
|
||||
@@ -189,8 +189,8 @@ impl LspManager {
|
||||
items: Vec<lsp::Diagnostic>,
|
||||
enc: crate::text::Encoding,
|
||||
) {
|
||||
let conv = self.conv(enc);
|
||||
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
|
||||
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
|
||||
if let Some((path, items)) = self.injected_diagnostics(server, &path, enc, items) {
|
||||
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 620 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user