Compare commits

...
Author SHA1 Message Date
l0ng-ai e7301fdb49 perf(java): count usage lenses in the project sources only
Usage lenses asked JDT LS for a full references search per declaration,
which also scans every dependency jar and the JDK. In a library class such
as java.lang.String that kept JDT LS busy for tens of seconds per pass,
and failed counts were asked again on every scroll.

- Scoped Java searches without libraries pass the "sources" scope.
- Lenses count declarations near the viewport (10 lines instead of 60).
- Incomplete counts are remembered per document version and asked again
  once the server settles, instead of on every pass.
2026-11-09 10:34:13 +08:00
l0ng-ai 13b590f2c7 feat(plugins): let test frameworks select tests to rerun (#2228)
* feat(plugins): let test frameworks select tests to rerun

* test(smoke): await Rerun Failed Tests
2026-11-09 10:08:51 +08:00
l0ng-ai 8d5504b376 fix(usages): keep the scope summary row from taking half of the Find Usages panel 2026-11-09 10:07:02 +08:00
l0ng-ai 60b19b2ffb Revert "fix(agents): stop defaulting new tasks to bypass" (#2234)
Reverts #2232: new agent tasks default to Bypass again.
2026-11-09 09:55:17 +08:00
l0ng-ai 73b3a99699 fix(agents): stop defaulting new tasks to bypass (#2232)
New agent tasks defaulted to Bypass whenever the provider offered it, whatever the user had chosen. A task now starts with the settings shown in the composer: the provider's reported mode, or Ask for Cursor. An explicit choice is kept, including Bypass, and running tasks keep their own mode. Cached provider settings could hold a Bypass written automatically with no record of its source, so a cached bypass is dropped on load, read and save while the option stays offered.

Fixes #740
2026-11-09 09:46:57 +08:00
arookieofc 95843ea12d fix(vue): highlight SCSS in <style lang="scss"> blocks (#2219) (#2221)
The Vue editor mode (`@codemirror/lang-vue` over the stock HTML base)
parses every `<style>` block as plain CSS, so SCSS — nested rules,
$-variables, and so on — rendered uncoloured or mis-highlighted.

Register a Vue mode of our own (overriding the language-data entry) that
gives the HTML base a nested Sass grammar for `<style>` whose `lang`
is `scss` or `sass`; blocks without a dialect keep CSS.
2026-11-09 09:23:17 +08:00
l0ng-ai 3dac5b8bdc fix(plugins): report Problems to plugins in remote projects (#2223) 2026-11-09 09:18:11 +08:00
l0ng-ai fd8b4ff14c fix(terminal): resume pasted input as soon as the PTY accepts more (#2220) 2026-11-09 09:17:30 +08:00
f70eb13f75 fix(process): don't spawn taskkill when the child started nothing (#2056)
`kill_tree` and `kill_tree_of` run `taskkill /T /F /PID` on Windows even when the child has no descendants, which is the common case for a program a plugin started, read from and closed. `taskkill` is a process spawn — ~0.7 s here, measured on this machine — and it runs inside host calls that are inside a plugin call's budget: dropping eight process handles at once overran a 5 s budget, so the plugin was trapped for something the host did.

When `descendants(pid)` is empty the child itself is all there is to kill, and both callers already do that (`kill_tree` follows with `child.kill()` + `child.wait()`, `kill_tree_of` leaves the child to its owner), so the spawn buys nothing. Skip it.

Verified:

- `cargo test -p ide-core` → 77 passed.
- `cargo test -p ide-host --test plugins` → 95 passed, including `a_plugin_keeps_a_process_running_behind_a_handle`, which fails on unmodified `main` on Windows (`plugin "misbehave" timed out after 5s` at its `childdrop` step, which drops eight handles) and now passes in 9.7 s instead of 20 s.
- clippy `--all-targets -D warnings` and `cargo fmt --check` clean; no component or WIT change, so nothing to rebuild.

Co-authored-by: chao <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-09 09:14:41 +08:00
l0ng-ai 337bf4b1ca fix(terminal): configure ConPTY compatibility on Windows (#2180) 2026-11-09 09:12:20 +08:00
l0ng-ai 047cac209b feat(plugins): plugin API 1.4 (#2207)
Plugin API 1.4: grants for unencrypted HTTP, local sockets, IDE commands and owned database sources; window status items, output channels, content tabs, editor selections, decorations, search, Problems and VCS reads; runs, keyed terminals with exit events, plugin run targets, dynamic run configurations and test parsers; language features, injections, snippets and menu locations; plugin MCP tools on the built-in server; four official API examples. Released 1.3 plugins keep working.
2026-11-09 08:40:40 +08:00
SunZhiQiang c5647a5934 feat(agents): show the model list and effort in one popover (#2213)
Choosing an agent model took two popovers, and the list cut a name on one row while showing it in full on another. The chip's popover now shows the effort slider above a searchable model list. Names with a description keep their full width, and the description is cut instead. The list is extracted from `Select` as `SelectListbox`; `Select` keeps its previews across popup open and close.

Co-authored-by: panda-student <[email protected]>
2026-11-09 08:39:44 +08:00
SunZhiQiang 1f2d86f793 feat(agents): load Claude's models and effort before the first task (#2212)
New Agent Task showed no model, effort or mode for Claude until an agent had run a task. The `initialize` request that already loads commands also returns those options, so they are now remembered and shown, with one extra `get_settings` request to read the configured effort. No prompt or session starts, a failed lookup is not repeated, and a session's own report always wins, in memory and on disk.

Co-authored-by: panda-student <[email protected]>
2026-11-09 08:34:46 +08:00
Lee Dong Geon 998b478de3 ci(plugins): skip the scheduled plugin index refresh on forks (#2211) 2026-11-09 08:16:56 +08:00
407 changed files with 23594 additions and 1061 deletions

No files matched your search

+2
View File
@@ -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
+144 -1
View File
@@ -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"
@@ -4256,7 +4323,9 @@ name = "ide-db"
version = "0.1.39"
dependencies = [
"bb8",
"blake3",
"dirs 6.0.0",
"dunce",
"futures",
"ide-core",
"ide-http",
@@ -4274,6 +4343,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,6 +4450,7 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"ignore",
"jsonschema",
"parking_lot",
"quick-xml 0.42.0",
"regex-lite",
@@ -4587,6 +4658,7 @@ name = "ide-plugin"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
"ide-core",
"ide-extension",
"parking_lot",
@@ -4598,6 +4670,7 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tracing",
"url",
"wasmtime",
"wasmtime-wasi",
"wat",
@@ -4620,6 +4693,7 @@ dependencies = [
name = "ide-remote"
version = "0.1.39"
dependencies = [
"dunce",
"globset",
"ide-core",
"ide-run",
@@ -5287,6 +5361,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 +5620,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 +5962,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 +6645,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 +7418,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 +10622,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 +10657,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
View File
@@ -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) {
+43 -18
View File
@@ -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
View File
@@ -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..]);
}
}
}
+1 -1
View File
@@ -22,4 +22,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"] }
+2
View File
@@ -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
View File
@@ -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());
+1
View File
@@ -16,6 +16,7 @@ pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod platform;
pub mod process;
pub mod project_dir;
pub mod project_roots;
+29
View File
@@ -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);
}
}
+6
View File
@@ -110,6 +110,12 @@ const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[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()])
+13
View File
@@ -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::*;
+7
View File
@@ -629,6 +629,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 +648,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 +770,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 +799,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,
}
+9 -1
View File
@@ -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,
}
}
}
+37 -8
View File
@@ -630,9 +630,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 +668,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 +697,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 +713,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;
}
+3
View File
@@ -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
View File
@@ -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 => {}
+271
View File
@@ -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]);
}
}
+8
View File
@@ -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,
}
}
}
+184 -5
View File
@@ -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);
}
}
}
+71
View File
@@ -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`,
+1 -1
View File
@@ -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,
+66 -3
View File
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.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());
}
}
+1
View File
@@ -52,6 +52,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
+32
View File
@@ -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")
+30
View File
@@ -55,6 +55,8 @@ pub struct HostConfig {
}
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: 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: RwLock::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() {
+56 -1
View File
@@ -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]
+3 -3
View File
@@ -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)) {
+6 -1
View File
@@ -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
+50 -1
View File
@@ -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))
})
+270 -16
View File
@@ -56,7 +56,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 +74,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);
@@ -222,7 +260,21 @@ 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 server = host.project(window)?.lsp.signature_help(doc, 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>> {
@@ -238,6 +290,23 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
}
}
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
let k =
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
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;
}
@@ -358,6 +427,20 @@ pub async fn lsp_implementations(
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let count = positions.len();
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
}
@@ -380,6 +463,10 @@ pub async fn lsp_goto_implementation(
})
.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
}
@@ -400,8 +487,8 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Java, `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)]
@@ -505,10 +592,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 +605,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 +643,12 @@ 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 server = host.project(window)?.lsp.document_highlights(doc, 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(
@@ -597,7 +690,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
};
@@ -1105,12 +1198,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 +1333,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 +1423,74 @@ 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 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,
};
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);
}
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 +1636,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 +1684,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 +1702,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 +2025,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()),
+67 -6
View File
@@ -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(),
+66 -57
View File
@@ -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::*;
File diff suppressed because it is too large. Load diff
+205
View File
@@ -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());
}
}
+225
View File
@@ -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");
}
}
+25 -2
View File
@@ -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}"),
+1 -1
View File
@@ -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());
+567
View File
@@ -0,0 +1,567 @@
//! 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 under the parser's soft/hard call budgets.
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
if tests.is_empty()
|| tests.len() > 10000
|| 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() };
let hard = self.limits.edit_hard;
let started = Instant::now();
let result = actor.call(Some(self.limits.edit_soft), move |rt| rt.select_tests(id, tests, invocation, hard));
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
}
/// 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;
}
}
}
+1
View File
@@ -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,
+126
View File
@@ -0,0 +1,126 @@
//! Embedded plugin-provider documents preserve the host's UTF-16 coordinates.
use super::{PluginHost, ProviderKind};
use ide_core::{Pos, Range};
use ide_plugin::{ProviderDocument, language_features::Injection};
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
}
#[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());
}
}
+174
View File
@@ -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");
}
}
+176
View File
@@ -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");
}
}
+188 -9
View File
@@ -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();
@@ -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"));
}
+24 -2
View File
@@ -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)))?;
+155 -20
View File
@@ -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)]
@@ -94,9 +107,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 +176,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()
@@ -211,7 +265,7 @@ pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
/// 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 +290,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 +388,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 +487,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 +508,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 +562,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()
}
+331 -18
View File
@@ -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::*;
+76 -9
View File
@@ -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)
+150
View File
@@ -0,0 +1,150 @@
//! 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 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.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
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.sink.emit(None, PluginEvent::RunTargetsChanged);
Ok(())
}
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
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> {
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())
}
+106
View File
@@ -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());
}
}
}
+74
View File
@@ -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,
},
}
+29 -2
View File
@@ -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"] {
+18 -6
View File
@@ -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()),
+5 -1
View File
@@ -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.
+5
View File
@@ -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"
)
+1
View File
@@ -59,6 +59,7 @@ fn fixture() -> Fixture {
fn inv() -> Invocation {
Invocation {
command_chain: Vec::new(),
client: Some("main".into()),
workspace_root: None,
editor: None,
+262
View File
@@ -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
}
+44 -1
View File
@@ -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,234 @@
//! 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 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));
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);
}
+2 -4
View File
@@ -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));
+354
View File
@@ -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();
}
+427
View File
@@ -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,409 @@ 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);
assert!(started.elapsed() < Duration::from_secs(5));
}
}
#[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"));
}
+529 -3
View File
@@ -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();
}
+6
View File
@@ -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")));
+6 -3
View File
@@ -1395,8 +1395,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 +1563,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)
};
+26
View File
@@ -3748,6 +3748,32 @@ async fn classified_java_references_widen_without_losing_metadata() {
}
}
/// Usage counts probe many symbols at once: without `libraries` they search the sources alone.
#[tokio::test(flavor = "multi_thread")]
async fn scoped_java_usages_search_the_sources_unless_libraries_are_asked_for() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
s.respond("workspace/executeCommand", json!([]));
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
let seen = grep_candidates(&h);
for libraries in [false, true] {
let refs = h
.mgr
.references_streaming(doc, Pos::new(0, 5), false, false, libraries, Default::default(), None)
.await
.unwrap();
assert_eq!(refs.libraries_skipped, !libraries);
let requested = s.requested("workspace/executeCommand");
let arguments = requested.last().unwrap()["arguments"].as_array().unwrap().clone();
assert_eq!(arguments.get(4) == Some(&json!("sources")), !libraries);
assert_eq!(arguments.len(), if libraries { 4 } else { 5 });
}
assert!(seen.lock().is_empty());
}
/// A library method's usages in the sources are shown before the dependencies and the JDK
/// are searched, which takes seconds; scope widening searches the sources alone again.
#[tokio::test(flavor = "multi_thread")]
+2
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
dunce.workspace = true
ide-extension.workspace = true
blake3.workspace = true
serde.workspace = true
@@ -16,6 +17,7 @@ thiserror.workspace = true
tracing.workspace = true
parking_lot.workspace = true
semver = "1"
url = "2"
wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation", "async"] }
wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] }
+153
View File
@@ -0,0 +1,153 @@
//! Runs, keyed terminals and plugin-owned content editors (API 1.4).
use serde::{Deserialize, Serialize};
use specta::Type;
/// ContentAction data exchanged with the IDE.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContentAction {
pub id: String,
pub label: String,
pub icon: Option<String>,
pub primary: bool,
pub save: bool,
}
/// ContentTab data exchanged with the IDE.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContentTab {
pub key: String,
pub title: String,
pub file_name: Option<String>,
pub language: Option<String>,
pub content: String,
pub read_only: bool,
pub actions: Vec<ContentAction>,
}
/// ContentDiff data exchanged with the IDE.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContentDiff {
pub left: String,
pub right: String,
pub left_label: Option<String>,
pub right_label: Option<String>,
pub title: Option<String>,
}
/// ContentTabReply data exchanged with the IDE.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContentTabReply {
pub content: Option<String>,
pub saved: Option<bool>,
pub read_only: Option<bool>,
pub status: Option<String>,
pub error: Option<String>,
pub diff: Option<ContentDiff>,
}
/// PluginRunConfiguration data exchanged with the IDE.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginRunConfiguration {
pub id: String,
pub name: String,
pub program: String,
pub args: Vec<String>,
pub cwd: Option<String>,
pub test: bool,
pub framework: Option<String>,
}
/// RunExited data exchanged with the IDE.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RunExited {
pub id: String,
pub config: String,
pub status: Option<i32>,
pub killed: bool,
}
/// TerminalExited data exchanged with the IDE.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TerminalExited {
pub id: String,
pub key: String,
pub status: Option<i32>,
pub closed: bool,
}
/// ContentTabAction data exchanged with the IDE.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContentTabAction {
pub request_id: String,
pub key: String,
pub action: String,
pub content: String,
}
/// Incremental test tree data.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TestStart {
pub id: String,
pub parent: Option<String>,
pub name: String,
pub location: Option<crate::Location>,
}
/// Incremental test tree data.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TestPassed {
pub id: String,
pub duration_ms: Option<u32>,
}
/// Incremental test tree data.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TestFailed {
pub id: String,
pub duration_ms: Option<u32>,
pub message: String,
pub expected: Option<String>,
pub actual: Option<String>,
pub location: Option<crate::Location>,
}
/// Incremental test tree data.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TestSkipped {
pub id: String,
pub reason: Option<String>,
}
/// Incremental test tree data.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TestOutput {
pub id: String,
pub text: String,
}
/// A test update, carried by the optional test-results export.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PluginTestEvent {
SuiteStarted(TestStart),
TestStarted(TestStart),
Passed(TestPassed),
Failed(TestFailed),
Skipped(TestSkipped),
Output(TestOutput),
Other { kind: String, data: String },
}
+109
View File
@@ -0,0 +1,109 @@
//! Optional API 1.4 language features and embedded language ranges.
use ide_core::Range;
use serde::{Deserialize, Serialize};
use specta::Type;
/// A parameter label, as text or UTF-16 offsets within the signature.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
pub enum ParameterLabel {
Text(String),
Offsets((u32, u32)),
}
/// FormatOptions data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FormatOptions {
pub tab_size: u32,
pub insert_spaces: bool,
}
/// Parameter data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Parameter {
pub label: ParameterLabel,
pub documentation: Option<String>,
}
/// Signature data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Signature {
pub label: String,
pub documentation: Option<String>,
pub parameters: Vec<Parameter>,
}
/// SignatureHelp data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SignatureHelp {
pub signatures: Vec<Signature>,
pub active_signature: u32,
pub active_parameter: u32,
}
/// Highlight data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Highlight {
pub range: Range,
pub kind: String,
}
/// DocumentLink data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DocumentLink {
pub range: Range,
pub target: String,
}
/// CodeLens data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CodeLens {
pub range: Range,
pub title: String,
pub command: String,
pub argument: Option<String>,
}
/// FoldRange data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FoldRange {
pub start_line: u32,
pub end_line: u32,
pub kind: String,
}
/// ColorInfo data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ColorInfo {
pub range: Range,
pub red: f32,
pub green: f32,
pub blue: f32,
pub alpha: f32,
}
/// DocumentSymbol data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DocumentSymbol {
pub name: String,
pub detail: String,
pub kind: String,
pub range: Range,
pub selection_range: Range,
pub parent: Option<u32>,
}
/// GutterAction data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GutterAction {
pub line: u32,
pub icon: Option<String>,
pub tooltip: String,
pub command: String,
pub argument: Option<String>,
}
/// Injection data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Injection {
pub range: Range,
pub language: String,
}
+7
View File
@@ -23,3 +23,10 @@ pub use runtime::{
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
pub use types::*;
pub use wasmtime::component::Component;
mod execution;
pub use execution::*;
pub use ide_core::remote_settings::PluginRunTarget;
pub mod language_features;
+779 -1
View File
@@ -16,6 +16,8 @@ use crate::types::{
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::injections_bindings::exports::openidea::plugin::injections as wit_inj;
use crate::wit_host::language_features_bindings::exports::openidea::plugin::language_features as wit_lf;
use crate::wit_host::{
bindings, commit_checks_bindings, details_bindings, endpoints_bindings, file_actions_bindings, hover_bindings,
indexing_bindings, inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
@@ -33,6 +35,8 @@ use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDea
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use crate::wit_host::run_configurations_bindings::exports::openidea::plugin::run_configurations as wit_configs;
use crate::wit_host::test_results_bindings::exports::openidea::plugin::test_results as wit_tests;
use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types;
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
@@ -50,7 +54,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.3";
pub const API_VERSION: &str = "1.4";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -62,6 +66,190 @@ pub const CANCELLED: &str = "was cancelled";
/// which routes notifications and status items to windows and edits to open
/// documents. Capabilities are already checked when these are called.
pub trait HostApi: Send + Sync {
/// Creates/replaces this plugin's non-secret project connection settings.
fn database_upsert(&self, root: &Path, source: crate::PluginDataSource) -> Result<(), HostError> {
let _ = (root, source);
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
}
/// Removes only an owned data source and its credentials.
fn database_remove(&self, root: &Path, id: &str) -> Result<(), HostError> {
let _ = (root, id);
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
}
/// Private ids of only this plugin's sources.
fn database_sources(&self, root: &Path) -> Result<Vec<String>, HostError> {
let _ = root;
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
}
/// Writes a password to the Database credential store, without a read path.
fn database_credential(&self, root: &Path, id: &str, password: String) -> Result<(), HostError> {
let _ = (root, id, password);
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
}
/// Requests normal connection/introspection in the invoking project's window.
fn database_connect(
&self,
root: &Path,
client: &str,
id: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let _ = (root, client, id, cancel, timeout);
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
}
/// Stdio argv only; no URL or bearer token.
fn mcp_stdio_command(&self, client: &str) -> Option<Vec<String>> {
let _ = client;
None
}
/// Registers a project-scoped wrapper after validating its process grant and pin.
fn register_run_target(&self, root: &Path, target: crate::PluginRunTarget) -> Result<(), HostError> {
let _ = (root, target);
Err(HostError::Failed("run targets unavailable".into()))
}
/// Withdraws this plugin's target, preserving the user's remembered prompt answer.
fn unregister_run_target(&self, root: &Path, id: &str) {
let _ = (root, id);
}
/// Starts one of this plugin's configurations in a window.
fn start_run(
&self,
client: &str,
config: &str,
args: Vec<String>,
debug: bool,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<String, HostError> {
let _ = (client, config, args, debug, cancel, timeout);
Err(HostError::Failed("runs unavailable".into()))
}
/// Opens/reuses a keyed terminal, retained across idle instance reclamation.
fn open_terminal_session(
&self,
client: &str,
key: String,
title: String,
cmd: ProcessCommand,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<String, HostError> {
let _ = (client, key, title, cmd, cancel, timeout);
Err(HostError::Failed("terminals unavailable".into()))
}
/// Writes only to an owned, live terminal.
fn write_terminal(
&self,
client: &str,
key: &str,
text: String,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let _ = (client, key, text, cancel, timeout);
Err(HostError::Failed("terminals unavailable".into()))
}
/// Opens a content tab through the shared editor implementation.
fn open_content_tab(
&self,
client: &str,
tab: crate::ContentTab,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let _ = (client, tab, cancel, timeout);
Err(HostError::Failed("content tabs unavailable".into()))
}
/// Answers a content action in the invoking window.
fn reply_content_tab(&self, client: &str, request: &str, reply: crate::ContentTabReply) -> Result<(), HostError> {
let _ = (client, request, reply);
Err(HostError::Failed("content tabs unavailable".into()))
}
/// The invoking window's editors.
fn open_editors(&self, client: &str) -> Result<Vec<crate::OpenEditor>, HostError> {
let _ = client;
Ok(Vec::new())
}
/// Opens a validated file and optionally selects a range.
fn editor_open(&self, client: &str, path: &Path, selection: Option<ide_core::Range>) -> Result<(), HostError> {
let _ = (client, path, selection);
Err(HostError::Failed("editor unavailable".into()))
}
/// Changes the selections, or reveals a range, only in an editor of this window.
fn editor_ranges(
&self,
client: &str,
path: &Path,
ranges: Vec<ide_core::Range>,
reveal: bool,
) -> Result<(), HostError> {
let _ = (client, path, ranges, reveal);
Err(HostError::Failed("editor unavailable".into()))
}
/// Find in Files with the project's document overlay.
fn search_text(
&self,
client: Option<&str>,
root: &Path,
query: crate::TextQuery,
limit: u32,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<crate::TextSearch, HostError> {
let _ = (client, root, query, limit, cancel, deadline);
Err(HostError::Failed("search unavailable".into()))
}
/// Window-reported Problems view snapshot.
fn problems(&self, client: &str, path: Option<&Path>) -> Result<Vec<crate::FileProblems>, HostError> {
let _ = (client, path);
Ok(Vec::new())
}
/// Replace or clear an editor decoration set.
fn decorations(&self, path: Option<&Path>, set: &str, items: Vec<crate::Decoration>) -> Result<(), HostError> {
let _ = (path, set, items);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Replace or clear a tree decoration set.
fn file_decorations(
&self,
path: Option<&Path>,
set: &str,
item: Option<crate::FileDecoration>,
) -> Result<(), HostError> {
let _ = (path, set, item);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Trusted repository history.
fn vcs_log(
&self,
root: &Path,
path: Option<&str>,
limit: u32,
cancel: &CancellationToken,
) -> Result<Vec<crate::CommitInfo>, HostError> {
let _ = (root, path, limit, cancel);
Err(HostError::Failed("git unavailable".into()))
}
/// Trusted repository attribution, including unsaved documents.
fn vcs_blame(&self, client: Option<&str>, root: &Path, path: &str) -> Result<Vec<crate::BlameLine>, HostError> {
let _ = (client, root, path);
Err(HostError::Failed("git unavailable".into()))
}
/// Trusted repository working-tree patch, bounded to 4 MiB.
fn vcs_diff(
&self,
root: &Path,
path: Option<&str>,
against: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<String>, HostError> {
let _ = (root, path, against, cancel);
Err(HostError::Failed("git unavailable".into()))
}
/// Shows a notification; `actions` name commands the plugin declares.
fn notify(
&self,
@@ -101,8 +289,45 @@ pub trait HostApi: Send + Sync {
let _ = (client, view);
Ok(())
}
/// Whether this stable id is a command declared by this plugin.
fn owns_command(&self, id: &str) -> bool {
let _ = id;
false
}
/// Executes a stable command in the invoking window and waits for its result.
fn execute_command(
&self,
invocation: &Invocation,
id: &str,
arg: Option<String>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<Option<String>, HostError> {
let _ = (invocation, id, arg, cancel, timeout);
Err(HostError::NotFound("command".into()))
}
/// Appends text, or clears (`None`), in a plugin-owned output channel.
fn output_write(&self, channel: &str, text: Option<&str>) -> Result<(), HostError> {
let _ = (channel, text);
Err(HostError::Failed("output unavailable".into()))
}
/// Reveals an output channel in the invoking window.
fn output_reveal(&self, client: &str, channel: &str) -> Result<(), HostError> {
let _ = (client, channel);
Err(HostError::Failed("output unavailable".into()))
}
/// Checks manifest minimum API requirements for new declarations or subscriptions.
fn require_api(&self, what: &str, minor: u64) -> Result<(), HostError> {
let _ = (what, minor);
Ok(())
}
/// Sets (`Some`) or removes the plugin's status bar item `id`.
fn status_item(&self, id: &str, item: Option<StatusItem>);
/// Sets or removes the status override in the invoking window.
fn window_status_item(&self, client: &str, id: &str, item: Option<StatusItem>) -> Result<(), HostError> {
let _ = (client, id, item);
Err(HostError::Invalid("window status unavailable".into()))
}
/// Sets (`Some`, validated JSON text) or removes the plugin's context key,
/// for window `client` (every window if `None`).
fn set_context(&self, client: Option<&str>, key: &str, value: Option<String>) -> Result<(), HostError> {
@@ -181,6 +406,62 @@ pub trait HostApi: Send + Sync {
/// Performs `request` (its host is granted), filling in secret placeholders.
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
fn secret_is_set(&self, name: &str) -> bool;
/// Stores or deletes a secret declared by this plugin; never returns its value.
fn secret_set(&self, name: &str, value: Option<&str>) -> Result<(), HostError> {
let _ = (name, value);
Err(HostError::NotFound("secret".into()))
}
/// Creates a desktop loopback listener and returns its handle and redirect URI.
fn begin_loopback(&self, client: &str, path: &str) -> Result<(u64, String), HostError> {
let _ = (client, path);
Err(HostError::Failed("loopback unavailable".into()))
}
/// Awaits a browser redirect, excluded from the caller's execution budget.
fn wait_loopback(
&self,
id: u64,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Option<String>, HostError> {
let _ = (id, timeout, cancel);
Err(HostError::NotFound("loopback".into()))
}
/// Releases a desktop listener when its resource or plugin stops.
fn close_loopback(&self, id: u64) {
let _ = id;
}
/// Connects to an approved host-local socket; owned by this plugin instance.
fn ipc_connect(&self, name: &str, cancel: &CancellationToken, timeout: Duration) -> Result<u64, HostError> {
let _ = (name, cancel, timeout);
Err(HostError::Failed("IPC unavailable".into()))
}
/// Writes to an owned IPC connection.
fn ipc_write(
&self,
id: u64,
bytes: Vec<u8>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let _ = (id, bytes, cancel, timeout);
Err(HostError::NotFound("connection".into()))
}
/// Reads from an owned IPC connection, empty on timeout or EOF.
fn ipc_read(
&self,
id: u64,
max: usize,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Vec<u8>, HostError> {
let _ = (id, max, timeout, cancel);
Err(HostError::NotFound("connection".into()))
}
/// Closes an owned IPC connection, including when its resource is dropped.
fn ipc_close(&self, id: u64) {
let _ = id;
}
/// Runs the approved file of `process:<program>` with `cmd.env` added to
/// the inherited environment, and waits.
fn run_process(
@@ -631,6 +912,8 @@ pub struct Runtime {
/// What the component exports: the `plugin` world, plus optional interfaces.
struct Exports {
language_features: Option<wit_lf::Guest>,
injections: Option<wit_inj::Guest>,
plugin: bindings::Plugin,
syntax: Option<wit_syntax::Guest>,
providers: Option<wit_prov::Guest>,
@@ -644,6 +927,9 @@ struct Exports {
commit_checks: Option<wit_cc::Guest>,
inlay: Option<wit_inlay::Guest>,
endpoints: Option<wit_endpoints::Guest>,
run_configurations: Option<wit_configs::Guest>,
test_results: Option<wit_tests::Guest>,
mcp: Option<crate::wit_host::mcp_bindings::exports::openidea::plugin::mcp_provider::Guest>,
}
/// Most results one syntax call may return (more are dropped).
@@ -653,6 +939,30 @@ const MAX_BATCH: usize = 1000;
fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
match e {
WorkbenchEvent::RunExited(value) => wit_events::Event::Other(wit_types::Extension {
kind: "run-exited".into(),
data: serde_json::to_string(&value).unwrap(),
}),
WorkbenchEvent::TerminalExited(value) => wit_events::Event::Other(wit_types::Extension {
kind: "terminal-exited".into(),
data: serde_json::to_string(&value).unwrap(),
}),
WorkbenchEvent::ContentTabAction(value) => wit_events::Event::Other(wit_types::Extension {
kind: "content-tab-action".into(),
data: serde_json::to_string(&value).unwrap(),
}),
WorkbenchEvent::FileClosed(p) => wit_events::Event::Other(wit_types::Extension {
kind: "file-closed".into(),
data: serde_json::to_string(&p).expect("path serializes"),
}),
WorkbenchEvent::SettingsChanged(keys) => wit_events::Event::Other(wit_types::Extension {
kind: "settings-changed".into(),
data: serde_json::to_string(&keys).expect("keys serialize"),
}),
WorkbenchEvent::InputModeChanged(mode) => wit_events::Event::Other(wit_types::Extension {
kind: "input-mode-changed".into(),
data: serde_json::to_string(&mode).expect("mode serializes"),
}),
WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p),
WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p),
WorkbenchEvent::FilesChanged(batch) => wit_events::Event::Other(wit_types::Extension {
@@ -737,7 +1047,19 @@ impl Runtime {
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
let configs_indices = wit_configs::GuestIndices::new(&pre).ok();
let tests_indices = wit_tests::GuestIndices::new(&pre).ok();
let lf_indices = wit_lf::GuestIndices::new(&pre).ok();
let inj_indices = wit_inj::GuestIndices::new(&pre).ok();
let mcp_indices =
crate::wit_host::mcp_bindings::exports::openidea::plugin::mcp_provider::GuestIndices::new(&pre).ok();
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
let mcp = mcp_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let run_configurations =
configs_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let test_results = tests_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let language_features = lf_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let injections = inj_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
let syntax = match syntax_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
@@ -790,6 +1112,8 @@ impl Runtime {
Ok(Self {
store,
exports: Exports {
language_features,
injections,
plugin,
syntax,
providers,
@@ -802,6 +1126,9 @@ impl Runtime {
commit_checks,
inlay,
endpoints,
run_configurations,
test_results,
mcp,
file_actions,
},
limits: setup.limits,
@@ -809,6 +1136,71 @@ impl Runtime {
})
}
/// Complete configurations from the optional API 1.4 export.
pub fn run_configurations(
&mut self,
root: String,
timeout: Duration,
) -> Result<Vec<crate::PluginRunConfiguration>, CallError> {
if self.exports.run_configurations.is_none() {
return Err(CallError::missing_export("run-configurations"));
}
let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() };
let items = self.call(invocation, timeout, |e, store| {
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
Ok(items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
})
.collect())
}
/// Selects tests under the same budget as the test parser.
pub fn select_tests(
&mut self,
config: String,
tests: Vec<Vec<String>>,
invocation: Invocation,
timeout: Duration,
) -> Result<Option<Vec<String>>, CallError> {
if self.exports.test_results.is_none() {
return Err(CallError::missing_export("test-results"));
}
self.call(invocation, timeout, |e, store| {
e.test_results.as_ref().unwrap().call_select_tests(store, &config, &tests)
})
}
/// One editor-budget batch of a plugin test parser.
pub fn parse_test_results(
&mut self,
config: String,
chunk: String,
invocation: Invocation,
timeout: Duration,
) -> Result<Vec<crate::PluginTestEvent>, CallError> {
if self.exports.test_results.is_none() {
return Err(CallError::missing_export("test-results"));
}
let items = self.call(invocation, timeout, |e, store| {
e.test_results.as_ref().unwrap().call_parse(store, &config, &chunk)
})?;
if items.len() > 10000 {
return Err(CallError::Guest(GuestError::Invalid("too many test events".into())));
}
Ok(items.into_iter().map(test_event_from_wit).collect())
}
/// The component exports `syntax-provider`.
pub fn has_syntax(&self) -> bool {
self.exports.syntax.is_some()
@@ -1809,6 +2201,392 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
}
}
fn test_event_from_wit(event: wit_tests::TestEvent) -> crate::PluginTestEvent {
use crate::{PluginTestEvent as E, TestFailed, TestOutput, TestPassed, TestSkipped, TestStart};
let location = |l: wit_types::Location| crate::Location {
path: l.path.into(),
range: ide_core::Range::new(
ide_core::Pos::new(l.range.start.line, l.range.start.character),
ide_core::Pos::new(l.range.end.line, l.range.end.character),
),
};
let start = |v: wit_tests::TestStart| TestStart {
id: v.id,
parent: v.parent,
name: v.name,
location: v.location.map(location),
};
match event {
wit_tests::TestEvent::SuiteStarted(v) => E::SuiteStarted(start(v)),
wit_tests::TestEvent::TestStarted(v) => E::TestStarted(start(v)),
wit_tests::TestEvent::Passed(v) => E::Passed(TestPassed { id: v.id, duration_ms: v.duration_ms }),
wit_tests::TestEvent::Failed(v) => E::Failed(TestFailed {
id: v.id,
duration_ms: v.duration_ms,
message: v.message,
expected: v.expected,
actual: v.actual,
location: v.location.map(location),
}),
wit_tests::TestEvent::Skipped(v) => E::Skipped(TestSkipped { id: v.id, reason: v.reason }),
wit_tests::TestEvent::Output(v) => E::Output(TestOutput { id: v.id, text: v.text }),
wit_tests::TestEvent::Other(v) => E::Other { kind: v.kind, data: v.data },
}
}
fn parameter_from(v: wit_lf::Parameter) -> crate::language_features::Parameter {
crate::language_features::Parameter {
label: parameter_label_from(v.label),
documentation: v.documentation.map(|v| crate::runtime::clip(v, 65536)),
}
}
fn signature_from(v: wit_lf::Signature) -> crate::language_features::Signature {
crate::language_features::Signature {
label: crate::runtime::clip(v.label, 65536),
documentation: v.documentation.map(|v| crate::runtime::clip(v, 65536)),
parameters: v.parameters.into_iter().take(10000).map(parameter_from).collect(),
}
}
fn signature_help_from(v: wit_lf::SignatureHelpResult) -> crate::language_features::SignatureHelp {
crate::language_features::SignatureHelp {
signatures: v.signatures.into_iter().take(10000).map(signature_from).collect(),
active_signature: v.active_signature,
active_parameter: v.active_parameter,
}
}
fn highlight_from(v: wit_lf::Highlight) -> crate::language_features::Highlight {
crate::language_features::Highlight { range: range_from_wit(v.range), kind: crate::runtime::clip(v.kind, 65536) }
}
fn document_link_from(v: wit_lf::DocumentLink) -> crate::language_features::DocumentLink {
crate::language_features::DocumentLink {
range: range_from_wit(v.range),
target: crate::runtime::clip(v.target, 65536),
}
}
fn code_lens_from(v: wit_lf::CodeLens) -> crate::language_features::CodeLens {
crate::language_features::CodeLens {
range: range_from_wit(v.range),
title: crate::runtime::clip(v.title, 65536),
command: crate::runtime::clip(v.command, 65536),
argument: v.argument.map(|v| crate::runtime::clip(v, 65536)),
}
}
fn fold_range_from(v: wit_lf::FoldRange) -> crate::language_features::FoldRange {
crate::language_features::FoldRange {
start_line: v.start_line,
end_line: v.end_line,
kind: crate::runtime::clip(v.kind, 65536),
}
}
fn color_info_from(v: wit_lf::ColorInfo) -> crate::language_features::ColorInfo {
crate::language_features::ColorInfo {
range: range_from_wit(v.range),
red: v.red,
green: v.green,
blue: v.blue,
alpha: v.alpha,
}
}
fn document_symbol_from(v: wit_lf::DocumentSymbol) -> crate::language_features::DocumentSymbol {
crate::language_features::DocumentSymbol {
name: crate::runtime::clip(v.name, 65536),
detail: crate::runtime::clip(v.detail, 65536),
kind: crate::runtime::clip(v.kind, 65536),
range: range_from_wit(v.range),
selection_range: range_from_wit(v.selection_range),
parent: v.parent,
}
}
fn gutter_action_from(v: wit_lf::GutterAction) -> crate::language_features::GutterAction {
crate::language_features::GutterAction {
line: v.line,
icon: v.icon.map(|v| crate::runtime::clip(v, 65536)),
tooltip: crate::runtime::clip(v.tooltip, 65536),
command: crate::runtime::clip(v.command, 65536),
argument: v.argument.map(|v| crate::runtime::clip(v, 65536)),
}
}
fn parameter_label_from(v: wit_lf::ParameterLabel) -> crate::language_features::ParameterLabel {
use crate::language_features::ParameterLabel as P;
match v {
wit_lf::ParameterLabel::Text(t) => P::Text(clip(t, 4096)),
wit_lf::ParameterLabel::Offsets(t) => P::Offsets(t),
}
}
impl Runtime {
fn language_feature_call<R>(
&mut self,
timeout: Duration,
f: impl FnOnce(&wit_lf::Guest, &mut Store<StoreData>) -> wasmtime::Result<R>,
) -> Result<R, CallError> {
if self.exports.language_features.is_none() {
return Err(CallError::missing_export("language-features"));
}
self.call(Invocation::default(), timeout, |e, s| f(e.language_features.as_ref().expect("checked"), s))
}
pub fn language_format(
&mut self,
doc: &ProviderDocument,
range: Option<ide_core::Range>,
options: crate::language_features::FormatOptions,
timeout: Duration,
) -> Result<Vec<ide_core::TextEdit>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| {
p.call_format(
s,
&d,
range.map(range_to_wit),
wit_lf::FormatOptions { tab_size: options.tab_size, insert_spaces: options.insert_spaces },
)
})?;
let result = result.map_err(|e| CallError::Guest(e.into()))?;
Ok(result.into_iter().take(10000).map(edit_from_wit).collect())
}
pub fn language_signature_help(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Option<crate::language_features::SignatureHelp>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_signature_help(s, &d, pos_to_wit(at)))?;
Ok(result.map(signature_help_from))
}
pub fn language_document_highlights(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Vec<crate::language_features::Highlight>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_document_highlights(s, &d, pos_to_wit(at)))?;
Ok(result.into_iter().take(10000).map(highlight_from).collect())
}
pub fn language_document_links(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<crate::language_features::DocumentLink>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_document_links(s, &d))?;
Ok(result.into_iter().take(10000).map(document_link_from).collect())
}
pub fn language_code_lenses(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<crate::language_features::CodeLens>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_code_lenses(s, &d))?;
Ok(result.into_iter().take(10000).map(code_lens_from).collect())
}
pub fn language_selection_ranges(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Vec<ide_core::Range>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_selection_ranges(s, &d, pos_to_wit(at)))?;
Ok(result.into_iter().take(10000).map(range_from_wit).collect())
}
pub fn language_folding(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<crate::language_features::FoldRange>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_folding(s, &d))?;
Ok(result.into_iter().take(10000).map(fold_range_from).collect())
}
pub fn language_colors(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<crate::language_features::ColorInfo>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_colors(s, &d))?;
Ok(result.into_iter().take(10000).map(color_info_from).collect())
}
pub fn language_color_presentations(
&mut self,
doc: &ProviderDocument,
color: crate::language_features::ColorInfo,
timeout: Duration,
) -> Result<Vec<String>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| {
p.call_color_presentations(
s,
&d,
wit_lf::ColorInfo {
range: range_to_wit(color.range),
red: color.red,
green: color.green,
blue: color.blue,
alpha: color.alpha,
},
)
})?;
Ok(result.into_iter().take(10000).map(|v| crate::runtime::clip(v, 65536)).collect())
}
pub fn language_document_symbols(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<crate::language_features::DocumentSymbol>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_document_symbols(s, &d))?;
Ok(result.into_iter().take(10000).map(document_symbol_from).collect())
}
pub fn language_implementation(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Vec<crate::Location>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_implementation(s, &d, pos_to_wit(at)))?;
Ok(result.into_iter().take(10000).map(location_from_wit).collect())
}
pub fn language_type_definition(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Vec<crate::Location>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_type_definition(s, &d, pos_to_wit(at)))?;
Ok(result.into_iter().take(10000).map(location_from_wit).collect())
}
pub fn language_gutter_actions(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<crate::language_features::GutterAction>, CallError> {
let d = wit_lf::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.language_feature_call(timeout, |p, s| p.call_gutter_actions(s, &d))?;
Ok(result.into_iter().take(10000).map(gutter_action_from).collect())
}
pub fn provide_injections(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<crate::language_features::Injection>, CallError> {
if self.exports.injections.is_none() {
return Err(CallError::missing_export("injections"));
}
let d = wit_inj::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let result = self.call(Invocation::default(), timeout, |e, s| {
e.injections.as_ref().expect("checked").call_injections(s, &d)
})?;
Ok(result
.into_iter()
.take(10000)
.map(|i| crate::language_features::Injection {
range: range_from_wit(i.range),
language: clip(i.language, 64),
})
.collect())
}
}
impl Runtime {
/// Optional MCP export, with the same wall-clock and fuel budgets as commands.
pub fn mcp_call(&mut self, tool: &str, arguments: &str, invocation: Invocation) -> Result<String, CallError> {
if self.exports.mcp.is_none() {
return Err(CallError::missing_export("mcp-provider"));
}
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let result = e.mcp.as_ref().unwrap().call_call(
&mut *store,
tool,
arguments,
wasmtime::component::Resource::new_borrow(ctx.rep()),
);
store.data_mut().drop_invocation(ctx);
result
})?;
let result = result.map_err(|e| CallError::Guest(e.into()))?;
if result.len() > 4 << 20 {
return Err(CallError::Guest(GuestError::Invalid("MCP result exceeds 4 MiB".into())));
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
+69 -17
View File
@@ -17,7 +17,7 @@ fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> {
if path.is_empty() {
return Err("empty path".into());
}
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
let requested = Path::new(path);
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
Ok((root, joined))
@@ -31,7 +31,7 @@ pub fn resolve_real(joined: &Path) -> PathBuf {
let mut tail = Vec::new();
let mut candidate = joined.to_path_buf();
let mut real = loop {
match candidate.canonicalize() {
match dunce::canonicalize(&candidate) {
Ok(real) => break real,
Err(_) => {
let Some(last) = candidate.components().next_back() else {
@@ -58,7 +58,7 @@ pub fn resolve_real(joined: &Path) -> PathBuf {
/// path — after following `..` and symlinks — stays inside `root`.
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let (root, joined) = joined_path(root, path)?;
let real = joined.canonicalize().map_err(|_| format!("{path}: no such file"))?;
let real = dunce::canonicalize(&joined).map_err(|_| format!("{path}: no such file"))?;
if !real.starts_with(&root) {
return Err(format!("{path}: access outside the project root is not allowed"));
}
@@ -83,7 +83,7 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
return Err("empty path".into());
}
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
let requested = Path::new(path);
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
let mut lexical = PathBuf::new();
@@ -108,7 +108,7 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
missing.push(dir);
existing = above;
}
let mut real = existing.canonicalize().map_err(|e| format!("{path}: {e}"))?;
let mut real = dunce::canonicalize(existing).map_err(|e| format!("{path}: {e}"))?;
real.extend(missing.into_iter().rev());
real.push(name);
if !real.starts_with(&root) {
@@ -207,9 +207,8 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
return Err(Refusal::Invalid(format!("{path}: use an absolute path (or ~/…)")));
}
let real = match wanted {
Wanted::Read => asked.canonicalize().map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?,
Wanted::Write => asked
.canonicalize()
Wanted::Read => dunce::canonicalize(&asked).map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?,
Wanted::Write => dunce::canonicalize(&asked)
.or_else(|_| resolve_unsaved(&asked))
.map_err(|e| Refusal::Invalid(format!("{path}: {e}")))?,
};
@@ -223,7 +222,7 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
};
// The grant is compared where it really is too: `/tmp/x` is `/private/tmp/x`
// on macOS, and `~` is the home directory.
let base = match expand_tilde(base).canonicalize() {
let base = match dunce::canonicalize(expand_tilde(base)) {
Ok(real) => strip_verbatim(&real),
Err(_) => strip_verbatim(&expand_tilde(base)),
};
@@ -246,20 +245,73 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
fn resolve_unsaved(asked: &Path) -> Result<PathBuf, String> {
let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?;
let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?;
let dir = parent.canonicalize().map_err(|_| "its directory does not exist".to_string())?;
let dir = dunce::canonicalize(parent).map_err(|_| "its directory does not exist".to_string())?;
Ok(dir.join(name))
}
/// `\\?\C:\…` (what `canonicalize` returns on Windows) as `C:\…`.
/// Simplifies Windows drive paths only when the plain form preserves their meaning.
fn strip_verbatim(path: &Path) -> String {
let text = path.to_string_lossy();
text.strip_prefix(r"\\?\").unwrap_or(&text).to_owned()
dunce::simplified(path).to_string_lossy().into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn grant_paths_simplify_drive_paths_and_preserve_unc_prefixes() {
assert_eq!(strip_verbatim(Path::new(r"\\?\C:\project\a.txt")), r"C:\project\a.txt");
for path in [r"C:\project\a.txt", r"\\?\UNC\server\share\a.txt", r"\\server\share\a.txt"] {
assert_eq!(strip_verbatim(Path::new(path)), path);
}
}
#[cfg(windows)]
#[test]
fn resolvers_return_plain_paths_for_plain_and_verbatim_inputs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("a.txt"), "text").unwrap();
let plain = dunce::canonicalize(&root).unwrap();
let verbatim = std::fs::canonicalize(&root).unwrap();
assert_ne!(plain, verbatim);
let file = plain.join("a.txt");
let missing = plain.join("new/deep/a.txt");
for root in [&plain, &verbatim] {
for path in ["a.txt", file.to_str().unwrap(), verbatim.join("a.txt").to_str().unwrap()] {
assert_eq!(resolve_in_root(root, path).unwrap(), file);
assert_eq!(resolve_new_in_root(root, path).unwrap(), file);
assert!(inside_root(root, path).unwrap());
}
for path in ["new/deep/a.txt", missing.to_str().unwrap(), verbatim.join("new/deep/a.txt").to_str().unwrap()]
{
assert_eq!(resolve_new_in_root(root, path).unwrap(), missing);
assert_eq!(resolve_real(&root.join(path)), missing);
assert!(inside_root(root, path).unwrap());
}
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "secret").unwrap();
assert!(resolve_in_root(root, outside.to_str().unwrap()).is_err());
assert!(resolve_new_in_root(root, outside.to_str().unwrap()).is_err());
}
for root in [&plain, &verbatim] {
let read = Capability::FsRead(root.to_string_lossy().into_owned());
let write = Capability::FsWrite(root.to_string_lossy().into_owned());
for path in [file.clone(), verbatim.join("a.txt")] {
assert_eq!(resolve_granted(std::slice::from_ref(&read), path.to_str().unwrap()).unwrap(), file);
assert_eq!(resolve_writable(std::slice::from_ref(&write), path.to_str().unwrap()).unwrap(), file);
}
for path in [plain.join("new.txt"), verbatim.join("new.txt")] {
assert_eq!(
resolve_writable(std::slice::from_ref(&write), path.to_str().unwrap()).unwrap(),
plain.join("new.txt")
);
}
}
}
#[test]
fn confines_reads_to_root() {
let tmp = tempfile::tempdir().unwrap();
@@ -310,7 +362,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
let root_real = root.canonicalize().unwrap();
let root_real = dunce::canonicalize(&root).unwrap();
assert_eq!(resolve_new_in_root(&root, "src/new/a.txt").unwrap(), root_real.join("src/new/a.txt"));
assert_eq!(resolve_new_in_root(&root, "src/../b.txt").unwrap(), root_real.join("b.txt"));
@@ -329,7 +381,7 @@ mod tests {
std::fs::write(root.join("target.txt"), "t").unwrap();
std::os::unix::fs::symlink(root.join("target.txt"), root.join("link.txt")).unwrap();
std::os::unix::fs::symlink(tmp.path(), root.join("out")).unwrap();
let root_real = root.canonicalize().unwrap();
let root_real = dunce::canonicalize(&root).unwrap();
assert_eq!(resolve_new_in_root(&root, "link.txt").unwrap(), root_real.join("link.txt"));
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
@@ -362,13 +414,13 @@ mod tests {
/// The grant a manifest author would write for `dir`: the plain path, no
/// `\\?\` prefix.
fn grant_for(dir: &Path) -> Capability {
Capability::FsRead(strip_verbatim(&dir.canonicalize().unwrap()))
Capability::FsRead(strip_verbatim(&dunce::canonicalize(dir).unwrap()))
}
fn text_at(dir: &Path, name: &str, body: &str) -> PathBuf {
let file = dir.join(name);
std::fs::write(&file, body).unwrap();
file.canonicalize().unwrap()
dunce::canonicalize(file).unwrap()
}
#[test]
+167 -22
View File
@@ -25,6 +25,8 @@ pub struct EditorContext {
/// Where a command or event batch comes from: the window and its project.
#[derive(Clone, Debug, Default)]
pub struct Invocation {
/// Plugin instances waiting on this command chain; never re-enter one.
pub command_chain: Vec<String>,
pub client: Option<String>,
pub workspace_root: Option<PathBuf>,
/// Settings actions can read the global layer while retaining their project context.
@@ -35,7 +37,7 @@ pub struct Invocation {
}
/// A failed host call, as the plugin sees it (WIT `types.error`).
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum HostError {
/// Not granted; names the capability.
Denied(String),
@@ -98,6 +100,12 @@ pub enum Prompt {
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum EventKind {
FileClosed,
SettingsChanged,
InputModeChanged,
RunExited,
TerminalExited,
ContentTabAction,
FileOpened,
FileSaved,
FilesChanged,
@@ -108,7 +116,13 @@ pub enum EventKind {
}
impl EventKind {
pub const ALL: [Self; 7] = [
pub const ALL: [Self; 13] = [
Self::FileClosed,
Self::SettingsChanged,
Self::InputModeChanged,
Self::RunExited,
Self::TerminalExited,
Self::ContentTabAction,
Self::FileOpened,
Self::FileSaved,
Self::FilesChanged,
@@ -120,6 +134,12 @@ impl EventKind {
pub fn name(self) -> &'static str {
match self {
Self::FileClosed => "file-closed",
Self::SettingsChanged => "settings-changed",
Self::InputModeChanged => "input-mode-changed",
Self::RunExited => "run-exited",
Self::TerminalExited => "terminal-exited",
Self::ContentTabAction => "content-tab-action",
Self::FileOpened => "file-opened",
Self::FileSaved => "file-saved",
Self::FilesChanged => "files-changed",
@@ -138,8 +158,8 @@ impl EventKind {
/// manifest declares).
pub fn capability(self) -> Option<ide_extension::Capability> {
match self {
Self::ActionPerformed => Some(ide_extension::Capability::InputEvents),
Self::Scheduled => None,
Self::ActionPerformed | Self::InputModeChanged => Some(ide_extension::Capability::InputEvents),
Self::Scheduled | Self::RunExited | Self::TerminalExited | Self::ContentTabAction => None,
_ => Some(ide_extension::Capability::Events),
}
}
@@ -188,10 +208,24 @@ pub struct FilesChanged {
pub removed: Vec<String>,
}
/// The focused editor's input mode and optional visual sub-mode.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct InputMode {
pub path: String,
pub mode: String,
pub sub: Option<String>,
}
/// A workbench event delivered to plugins.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum WorkbenchEvent {
RunExited(crate::RunExited),
TerminalExited(crate::TerminalExited),
ContentTabAction(crate::ContentTabAction),
FileClosed(String),
SettingsChanged(Vec<String>),
InputModeChanged(InputMode),
FileOpened(String),
FileSaved(String),
FilesChanged(FilesChanged),
@@ -204,6 +238,12 @@ pub enum WorkbenchEvent {
impl WorkbenchEvent {
pub fn kind(&self) -> EventKind {
match self {
Self::RunExited(_) => EventKind::RunExited,
Self::TerminalExited(_) => EventKind::TerminalExited,
Self::ContentTabAction(_) => EventKind::ContentTabAction,
Self::FileClosed(_) => EventKind::FileClosed,
Self::SettingsChanged(_) => EventKind::SettingsChanged,
Self::InputModeChanged(_) => EventKind::InputModeChanged,
Self::FileOpened(_) => EventKind::FileOpened,
Self::FileSaved(_) => EventKind::FileSaved,
Self::FilesChanged(_) => EventKind::FilesChanged,
@@ -350,6 +390,8 @@ pub struct HttpRequest {
pub headers: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
pub timeout: Duration,
/// Grants checked again before following a redirect.
pub grants: Vec<ide_extension::Capability>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -371,6 +413,8 @@ pub struct ProcessOutput {
/// the environment to add to the inherited one (WIT `process.command`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProcessCommand {
/// The invoking project whose executable override applies, independent of cwd.
pub approval_root: Option<PathBuf>,
pub program: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
@@ -388,23 +432,27 @@ pub struct ProcessChunk {
/// Where `net:` requests may go: the URL's host when it is `https`, or
/// `http` to the loopback interface.
pub fn net_host(url: &str) -> Result<String, String> {
let (scheme, rest) = url.split_once("://").ok_or_else(|| format!("{url:?} is not an absolute URL"))?;
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let host = if let Some(v6) = authority.strip_prefix('[') {
v6.split(']').next().unwrap_or_default().to_owned()
} else {
authority.split(':').next().unwrap_or_default().to_ascii_lowercase()
};
if host.is_empty() {
return Err(format!("{url:?} has no host"));
let parsed = url::Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(format!("unsupported scheme {:?}", parsed.scheme()));
}
let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
match scheme.to_ascii_lowercase().as_str() {
"https" => Ok(host),
"http" if loopback => Ok(host),
"http" => Err(format!("{url:?}: plain http is only allowed to localhost")),
other => Err(format!("{url:?}: unsupported scheme {other:?}")),
match parsed.host() {
Some(url::Host::Domain(host)) => Ok(host.trim_end_matches('.').to_ascii_lowercase()),
Some(url::Host::Ipv4(host)) => Ok(host.to_string()),
Some(url::Host::Ipv6(host)) => Ok(host.to_string()),
None => Err("URL has no host".into()),
}
}
/// The grant required by a URL; loopback HTTP retains the original `net:` rule.
pub fn net_capability(url: &str) -> Result<ide_extension::Capability, String> {
let host = net_host(url)?;
if url::Url::parse(url).is_ok_and(|u| u.scheme() == "http")
&& !matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1")
{
Ok(ide_extension::Capability::NetHttp(host))
} else {
Ok(ide_extension::Capability::Net(host))
}
}
@@ -427,7 +475,10 @@ mod tests {
assert_eq!(net_host("https://API.example.com:8443/v1?x").unwrap(), "api.example.com");
assert_eq!(net_host("http://localhost:11434/api").unwrap(), "localhost");
assert_eq!(net_host("https://user:[email protected]/").unwrap(), "h.io");
assert!(net_host("http://example.com/").is_err());
assert_eq!(
net_capability("http://example.com/").unwrap(),
ide_extension::Capability::NetHttp("example.com".into())
);
assert!(net_host("file:///etc/passwd").is_err());
assert!(net_host("example.com").is_err());
}
@@ -442,7 +493,8 @@ pub struct ProviderDocument {
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename = "PluginLocation")]
pub struct Location {
pub path: PathBuf,
pub range: ide_core::Range,
@@ -710,3 +762,96 @@ pub struct NotificationAction {
pub command: String,
pub argument: Option<String>,
}
/// An open editor reported by its window.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct OpenEditor {
pub path: String,
pub language: String,
pub dirty: bool,
pub active: bool,
}
/// Find in Files options.
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TextQuery {
pub pattern: String,
pub regex: bool,
pub case_sensitive: bool,
pub whole_word: bool,
pub include: Vec<String>,
pub exclude: Vec<String>,
}
/// A search hit, using UTF-16 coordinates.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
pub struct TextMatch {
pub location: Location,
pub line: String,
}
/// Bounded search results.
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TextSearch {
pub matches: Vec<TextMatch>,
pub limit_hit: bool,
}
/// A file's problems from one producer.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
pub struct FileProblems {
pub path: String,
pub source: String,
pub diagnostics: Vec<ide_core::Diagnostic>,
}
/// An editor decoration.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
pub struct Decoration {
pub range: ide_core::Range,
pub style: String,
pub hover: Option<String>,
pub after: Option<String>,
}
/// A tree decoration.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
pub struct FileDecoration {
pub badge: Option<String>,
pub style: String,
pub tooltip: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitInfo {
pub id: String,
pub short_id: String,
pub author: String,
pub author_email: String,
pub date: i64,
pub subject: String,
pub body: String,
pub parents: Vec<String>,
}
/// One zero-based line's attribution, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
pub struct BlameLine {
pub line: u32,
pub commit: String,
pub author: String,
pub date: i64,
pub summary: String,
}
/// Non-secret settings of a plugin-owned project database source (API 1.4).
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginDataSource {
pub id: String,
pub name: String,
pub driver: String,
pub host: Option<String>,
pub port: Option<u16>,
pub database: Option<String>,
pub user: Option<String>,
pub url: Option<String>,
pub options: Vec<(String, String)>,
}
+786 -7
View File
@@ -6,7 +6,7 @@ use crate::runtime::{MAX_TITLE, StoreData, clip};
use crate::sandbox;
use crate::types::{
EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, ProcessChunk, ProcessCommand,
Prompt, StatusItem, net_host,
Prompt, StatusItem, net_capability,
};
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
@@ -25,6 +25,8 @@ pub(crate) mod bindings {
"openidea:plugin/invocation.invocation": super::InvocationRes,
"openidea:plugin/tasks.task": super::TaskRes,
"openidea:plugin/process.child": super::ChildRes,
"openidea:plugin/ipc.connection": super::ConnectionRes,
"openidea:plugin/auth.loopback": super::LoopbackRes,
},
});
}
@@ -42,6 +44,7 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/decorations": super::bindings::openidea::plugin::decorations,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
@@ -50,10 +53,17 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
"openidea:plugin/net": super::bindings::openidea::plugin::net,
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
"openidea:plugin/run": super::bindings::openidea::plugin::run,
"openidea:plugin/process": super::bindings::openidea::plugin::process,
"openidea:plugin/output": super::bindings::openidea::plugin::output,
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
"openidea:plugin/database": super::bindings::openidea::plugin::database,
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
},
});
}
@@ -71,6 +81,7 @@ pub(crate) mod providers_bindings {
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/decorations": super::bindings::openidea::plugin::decorations,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
@@ -79,10 +90,17 @@ pub(crate) mod providers_bindings {
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
"openidea:plugin/net": super::bindings::openidea::plugin::net,
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
"openidea:plugin/run": super::bindings::openidea::plugin::run,
"openidea:plugin/process": super::bindings::openidea::plugin::process,
"openidea:plugin/output": super::bindings::openidea::plugin::output,
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
"openidea:plugin/database": super::bindings::openidea::plugin::database,
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
},
});
}
@@ -100,6 +118,7 @@ pub(crate) mod views_bindings {
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/decorations": super::bindings::openidea::plugin::decorations,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
@@ -108,17 +127,24 @@ pub(crate) mod views_bindings {
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
"openidea:plugin/net": super::bindings::openidea::plugin::net,
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
"openidea:plugin/run": super::bindings::openidea::plugin::run,
"openidea:plugin/process": super::bindings::openidea::plugin::process,
"openidea:plugin/output": super::bindings::openidea::plugin::output,
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
"openidea:plugin/database": super::bindings::openidea::plugin::database,
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
},
});
}
use bindings::openidea::plugin::{
commit, detail_types, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage,
tasks, types as wit, ui, vcs, views, workspace,
commit, detail_types, diagnostics, editor, events, fs, invocation, ipc, log, net, process, secrets, settings,
storage, tasks, types as wit, ui, vcs, views, workspace,
};
/// A project handle: its root.
@@ -319,6 +345,14 @@ impl StoreData {
self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple })
}
fn require_window(&self) -> Result<(), wit::Error> {
self.require_interactive()?;
if self.invocation.client.is_none() {
return Err(wit::Error::Invalid("requires an invoking window".into()));
}
Ok(())
}
/// APIs reserved for commands and events exclude code-action resolution.
fn require_interactive(&self) -> Result<(), wit::Error> {
if self.interactive {
@@ -381,6 +415,29 @@ impl ui::Host for StoreData {
Ok(())
}
fn set_window_status_item(&mut self, id: String, item: ui::StatusItem) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
let item = StatusItem {
text: clip(item.text, MAX_STATUS_TEXT),
tooltip: item.tooltip.map(|t| clip(t, 1024)),
command: item.command.map(|c| clip(c, 128)),
};
Ok(self
.host
.window_status_item(self.invocation.client.as_deref().expect("checked"), &clip(id, 64), Some(item))
.map_err(to_wit))
}
fn remove_window_status_item(&mut self, id: String) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
Ok(self
.host
.window_status_item(self.invocation.client.as_deref().expect("checked"), &clip(id, 64), None)
.map_err(to_wit))
}
fn set_context(&mut self, key: String, value: ui::ContextValue) -> Fallible<()> {
let valid_key =
(1..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'));
@@ -532,6 +589,44 @@ impl workspace::Host for StoreData {
}
impl workspace::HostProject for StoreData {
fn search_text(
&mut self,
this: Resource<ProjectRes>,
query: workspace::TextQuery,
limit: u32,
) -> Fallible<workspace::TextSearch> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let root = &self.table.get(&this)?.root;
let query = crate::TextQuery {
pattern: query.pattern,
regex: query.regex,
case_sensitive: query.case_sensitive,
whole_word: query.whole_word,
include: query.include,
exclude: query.exclude,
};
Ok(self
.host
.search_text(self.client(), root, query, limit.min(10_000), &self.cancel.token(), self.deadline)
.map(|s| workspace::TextSearch {
limit_hit: s.limit_hit,
matches: s
.matches
.into_iter()
.map(|m| workspace::TextMatch {
location: wit::Location {
path: to_ipc(&m.location.path),
range: range_to_wit(&m.location.range),
},
line: m.line,
})
.collect(),
})
.map_err(to_wit))
}
fn root(&mut self, this: Resource<ProjectRes>) -> Fallible<String> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
@@ -659,11 +754,94 @@ impl workspace::HostProject for StoreData {
}
}
impl editor::Host for StoreData {}
impl editor::Host for StoreData {
fn open_editors(&mut self) -> Fallible<Vec<editor::OpenEditor>> {
if let Err(e) = self.require(Capability::EditorRead).and_then(|_| self.require_window()) {
return Ok(Err(e));
}
Ok(self
.host
.open_editors(self.client().unwrap())
.map(|items| {
items
.into_iter()
.map(|e| editor::OpenEditor {
path: e.path,
language: e.language,
dirty: e.dirty,
active: e.active,
})
.collect()
})
.map_err(to_wit))
}
fn open(&mut self, path: String, selection: Option<wit::Range>) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
let real = match self.openable_path(&path) {
Ok(p) => p,
Err(e) => return Ok(Err(e)),
};
Ok(self.host.editor_open(self.client().unwrap(), &real, selection.map(core_range)).map_err(to_wit))
}
}
fn core_range(r: wit::Range) -> ide_core::Range {
ide_core::Range::new(pos(r.start), pos(r.end))
}
impl StoreData {
fn decoration_path(&self, path: &str) -> Result<PathBuf, wit::Error> {
if Path::new(path).is_absolute() {
return Ok(PathBuf::from(path));
}
let root = self
.invocation
.workspace_root
.as_ref()
.ok_or_else(|| wit::Error::Invalid("relative decorations need an invoking project".into()))?;
sandbox::resolve_in_root(root, path).map_err(wit::Error::Invalid)
}
fn openable_path(&self, path: &str) -> Result<PathBuf, wit::Error> {
if let Ok(dir) = self.host.plugin_dir()
&& let Ok(real) = sandbox::resolve_granted(&[Capability::FsRead(to_ipc(&dir))], path)
{
return Ok(real);
}
self.require(Capability::ProjectRead)?;
let root = self
.invocation
.workspace_root
.as_ref()
.ok_or_else(|| wit::Error::Invalid("no project is open in this window".into()))?;
sandbox::resolve_in_root(root, path).map_err(wit::Error::Invalid)
}
}
impl detail_types::Host for StoreData {}
impl editor::HostTextEditor for StoreData {
fn set_selections(&mut self, this: Resource<EditorRes>, selections: Vec<wit::Range>) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
if selections.is_empty() || selections.len() > 1000 {
return Ok(Err(wit::Error::Invalid("requires 1-1000 selections".into())));
}
let path = &self.table.get(&this)?.ctx.path;
Ok(self
.host
.editor_ranges(self.client().unwrap(), path, selections.into_iter().map(core_range).collect(), false)
.map_err(to_wit))
}
fn reveal(&mut self, this: Resource<EditorRes>, range: wit::Range) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
let path = &self.table.get(&this)?.ctx.path;
Ok(self.host.editor_ranges(self.client().unwrap(), path, vec![core_range(range)], true).map_err(to_wit))
}
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
}
@@ -748,6 +926,69 @@ impl invocation::HostInvocation for StoreData {
}
impl vcs::Host for StoreData {
fn log(
&mut self,
project: Resource<ProjectRes>,
path: Option<String>,
limit: u32,
) -> Fallible<Vec<vcs::CommitInfo>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
let root = &self.table.get(&project)?.root;
Ok(self
.host
.vcs_log(root, path.as_deref(), limit.min(1000), &self.cancel.token())
.map(|v| {
v.into_iter()
.map(|c| vcs::CommitInfo {
id: c.id,
short_id: c.short_id,
author: c.author,
author_email: c.author_email,
date: c.date,
subject: c.subject,
body: c.body,
parents: c.parents,
})
.collect()
})
.map_err(to_wit))
}
fn blame(&mut self, project: Resource<ProjectRes>, path: String) -> Fallible<Vec<vcs::BlameLine>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
let root = &self.table.get(&project)?.root;
Ok(self
.host
.vcs_blame(self.client(), root, &path)
.map(|v| {
v.into_iter()
.map(|c| vcs::BlameLine {
line: c.line,
commit: c.commit,
author: c.author,
date: c.date,
summary: c.summary,
})
.collect()
})
.map_err(to_wit))
}
fn diff(
&mut self,
project: Resource<ProjectRes>,
path: Option<String>,
against: Option<String>,
) -> Fallible<Option<String>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
let root = &self.table.get(&project)?.root;
Ok(self.host.vcs_diff(root, path.as_deref(), against.as_deref(), &self.cancel.token()).map_err(to_wit))
}
fn status(&mut self, project: Resource<ProjectRes>) -> Fallible<Option<vcs::RepositoryStatus>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
@@ -850,6 +1091,18 @@ impl events::Host for StoreData {
let Some(kind) = EventKind::parse(&kind) else {
return Ok(Err(wit::Error::Invalid(format!("no event kind {kind:?} in this OpenIDEA"))));
};
if matches!(
kind,
EventKind::FileClosed
| EventKind::SettingsChanged
| EventKind::InputModeChanged
| EventKind::RunExited
| EventKind::TerminalExited
| EventKind::ContentTabAction
) && let Err(e) = self.host.require_api(kind.name(), 4)
{
return Ok(Err(to_wit(e)));
}
if let Some(cap) = kind.capability()
&& let Err(e) = self.require(cap)
{
@@ -941,6 +1194,40 @@ impl StoreData {
}
impl diagnostics::Host for StoreData {
fn problems(&mut self, path: Option<String>) -> Fallible<Vec<diagnostics::FileProblems>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
if self.client().is_none() {
return Ok(Err(wit::Error::Invalid("requires an invoking window".into())));
}
let real = match path {
None => None,
Some(path) => {
let Some(root) = self.invocation.workspace_root.as_ref() else {
return Ok(Err(wit::Error::Invalid("no project in this window".into())));
};
match sandbox::resolve_new_in_root(root, &path) {
Ok(p) => Some(p),
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
}
}
};
Ok(self
.host
.problems(self.client().unwrap(), real.as_deref())
.map(|v| {
v.into_iter()
.map(|f| diagnostics::FileProblems {
path: f.path,
source: f.source,
diagnostics: f.diagnostics.into_iter().map(diagnostic_to_wit).collect(),
})
.collect()
})
.map_err(to_wit))
}
fn publish(&mut self, path: String, items: Vec<diagnostics::Diagnostic>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
@@ -952,11 +1239,11 @@ impl diagnostics::Host for StoreData {
impl net::Host for StoreData {
fn fetch(&mut self, req: net::Request) -> Fallible<net::Response> {
let host = match net_host(&req.url) {
let capability = match net_capability(&req.url) {
Ok(h) => h,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
if let Err(e) = self.require(Capability::Net(host)) {
if let Err(e) = self.require(capability) {
return Ok(Err(e));
}
if req.headers.iter().any(|h| h.value.contains("{{secret:"))
@@ -974,6 +1261,7 @@ impl net::Host for StoreData {
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
body: req.body,
timeout: wanted.min(self.remaining()),
grants: self.granted.clone(),
};
let token = self.cancel.token();
Ok(self
@@ -989,6 +1277,21 @@ impl net::Host for StoreData {
}
impl secrets::Host for StoreData {
fn set(&mut self, name: String, value: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::Secrets) {
return Ok(Err(e));
}
if name.len() > 128 || value.len() > 64 << 10 {
return Ok(Err(wit::Error::Invalid("secret name or value too long".into())));
}
Ok(self.host.secret_set(&name, Some(&value)).map_err(to_wit))
}
fn delete(&mut self, name: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::Secrets) {
return Ok(Err(e));
}
Ok(self.host.secret_set(&name, None).map_err(to_wit))
}
fn is_set(&mut self, name: String) -> wasmtime::Result<bool> {
Ok(self.granted.iter().any(|g| g.covers(&Capability::Secrets)) && self.host.secret_is_set(&name))
}
@@ -1013,7 +1316,12 @@ impl StoreData {
return Err(wit::Error::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
}
}
Ok(ProcessCommand { program: cmd.program, args: cmd.args, env: cmd.env })
Ok(ProcessCommand {
program: cmd.program,
args: cmd.args,
env: cmd.env,
approval_root: self.invocation.workspace_root.clone(),
})
}
/// The project a call runs in, when it named one.
@@ -1120,6 +1428,68 @@ impl process::HostChild for StoreData {
}
impl views::Host for StoreData {
fn open_terminal_session(&mut self, key: String, title: String, cmd: process::Command) -> Fallible<String> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
let cmd = match self.command(cmd) {
Ok(cmd) => cmd,
Err(e) => return Ok(Err(e)),
};
Ok(self
.host
.open_terminal_session(
self.invocation.client.as_deref().unwrap(),
key,
title,
cmd,
&self.cancel.token(),
self.remaining(),
)
.map_err(to_wit))
}
fn write_terminal(&mut self, key: String, text: String) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
if text.len() > MAX_STDIN_BYTES {
return Ok(Err(wit::Error::Invalid("terminal write too large".into())));
}
Ok(self
.host
.write_terminal(
self.invocation.client.as_deref().unwrap(),
&key,
text,
&self.cancel.token(),
self.remaining(),
)
.map_err(to_wit))
}
fn open_content_tab(&mut self, tab: views::ContentTab) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
Ok(self
.host
.open_content_tab(
self.invocation.client.as_deref().unwrap(),
tab.into(),
&self.cancel.token(),
self.remaining(),
)
.map_err(to_wit))
}
fn reply_content_tab(&mut self, request_id: String, reply: views::ContentTabReply) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
Ok(self
.host
.reply_content_tab(self.invocation.client.as_deref().unwrap(), &request_id, reply.into())
.map_err(to_wit))
}
fn refresh(&mut self, view: String) -> wasmtime::Result<()> {
self.host.refresh_view(&clip(view, 64));
Ok(())
@@ -1443,6 +1813,108 @@ fn validate_ui_path(path: &str) -> Result<(), wit::Error> {
Ok(())
}
impl From<bindings::openidea::plugin::views::ContentAction> for crate::ContentAction {
fn from(v: bindings::openidea::plugin::views::ContentAction) -> Self {
Self { id: v.id, label: v.label, icon: v.icon, primary: v.primary, save: v.save }
}
}
impl From<bindings::openidea::plugin::views::ContentTab> for crate::ContentTab {
fn from(v: bindings::openidea::plugin::views::ContentTab) -> Self {
Self {
key: v.key,
title: v.title,
file_name: v.file_name,
language: v.language,
content: v.content,
read_only: v.read_only,
actions: v.actions.into_iter().map(Into::into).collect(),
}
}
}
impl From<bindings::openidea::plugin::views::ContentDiff> for crate::ContentDiff {
fn from(v: bindings::openidea::plugin::views::ContentDiff) -> Self {
Self { left: v.left, right: v.right, left_label: v.left_label, right_label: v.right_label, title: v.title }
}
}
impl From<bindings::openidea::plugin::views::ContentTabReply> for crate::ContentTabReply {
fn from(v: bindings::openidea::plugin::views::ContentTabReply) -> Self {
Self {
content: v.content,
saved: v.saved,
read_only: v.read_only,
status: v.status,
error: v.error,
diff: v.diff.map(Into::into),
}
}
}
impl bindings::openidea::plugin::run_targets::Host for StoreData {
fn register(
&mut self,
project: Resource<ProjectRes>,
target: bindings::openidea::plugin::run_targets::RunTarget,
) -> Fallible<()> {
if let Err(e) = self.require(Capability::Process(target.program.clone())) {
return Ok(Err(e));
}
let root = self.table.get(&project)?.root.clone();
Ok(self
.host
.register_run_target(
&root,
crate::PluginRunTarget {
id: target.id,
name: target.name,
program: target.program,
args: target.args,
workdir_args: target.workdir_args,
container_root: target.container_root,
commands: target.commands,
},
)
.map_err(to_wit))
}
fn unregister(&mut self, project: Resource<ProjectRes>, id: String) -> wasmtime::Result<()> {
let root = self.table.get(&project)?.root.clone();
self.host.unregister_run_target(&root, &id);
Ok(())
}
}
impl bindings::openidea::plugin::run::Host for StoreData {
fn start(&mut self, config: String, args: Vec<String>, debug: bool) -> Fallible<String> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
if args.len() > MAX_ARGS || args.iter().any(|a| a.len() > MAX_ARG_BYTES || a.contains('\0')) {
return Ok(Err(wit::Error::Invalid("invalid run arguments".into())));
}
Ok(self
.host
.start_run(
self.invocation.client.as_deref().unwrap(),
&config,
args,
debug,
&self.cancel.token(),
self.remaining(),
)
.map_err(to_wit))
}
}
pub(crate) mod run_configurations_bindings {
wasmtime::component::bindgen!({ world: "configurations-provider", path: "../../wit" });
}
pub(crate) mod test_results_bindings {
wasmtime::component::bindgen!({ world: "test-results-provider", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
#[cfg(test)]
mod project_ui_tests {
use super::validate_ui_path;
@@ -1489,3 +1961,310 @@ pub(crate) fn diagnostics_from_wit(items: Vec<diagnostics::Diagnostic>, source:
})
.collect()
}
/// An IPC connection owned by this plugin instance.
pub struct ConnectionRes {
id: u64,
host: std::sync::Arc<dyn crate::HostApi>,
}
impl Drop for ConnectionRes {
fn drop(&mut self) {
self.host.ipc_close(self.id);
}
}
impl ipc::Host for StoreData {
fn connect(&mut self, name: String) -> Fallible<Resource<ConnectionRes>> {
if let Err(e) = ide_extension::validate_ipc_name(&name, false) {
return Ok(Err(wit::Error::Invalid(e)));
}
if let Err(e) = self.require(Capability::Ipc(name.clone())) {
return Ok(Err(e));
}
let name = match ide_extension::expand_ipc_name(&name) {
Ok(name) => name,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
match self.host.ipc_connect(&name, &self.cancel.token(), self.remaining()) {
Ok(id) => self.push(ConnectionRes { id, host: self.host.clone() }).map(Ok),
Err(e) => Ok(Err(to_wit(e))),
}
}
}
impl ipc::HostConnection for StoreData {
fn write(&mut self, this: Resource<ConnectionRes>, bytes: Vec<u8>) -> Fallible<()> {
if bytes.len() > 1 << 20 {
return Ok(Err(wit::Error::Invalid("at most 1 MiB per write".into())));
}
let id = self.table.get(&this)?.id;
Ok(self.host.ipc_write(id, bytes, &self.cancel.token(), self.remaining()).map_err(to_wit))
}
fn read(&mut self, this: Resource<ConnectionRes>, max_bytes: u32, timeout_ms: u32) -> Fallible<Vec<u8>> {
let id = self.table.get(&this)?.id;
let timeout = Duration::from_millis(u64::from(timeout_ms)).min(self.remaining());
Ok(self.host.ipc_read(id, (max_bytes as usize).min(1 << 20), timeout, &self.cancel.token()).map_err(to_wit))
}
fn close(&mut self, this: Resource<ConnectionRes>) -> Fallible<()> {
let id = self.table.get(&this)?.id;
self.host.ipc_close(id);
Ok(Ok(()))
}
fn drop(&mut self, this: Resource<ConnectionRes>) -> wasmtime::Result<()> {
self.table.delete(this)?;
Ok(())
}
}
impl bindings::openidea::plugin::command_runner::Host for StoreData {
fn execute(&mut self, id: String, arg: Option<String>) -> Fallible<Option<String>> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
if !self.host.owns_command(&id)
&& let Err(e) = self.require(Capability::IdeCommands)
{
return Ok(Err(e));
}
if id.len() > 256 || arg.as_ref().is_some_and(|a| a.len() > 64 << 10) {
return Ok(Err(wit::Error::Invalid("command id or argument too long".into())));
}
Ok(self
.host
.execute_command(&self.invocation, &id, arg, &self.cancel.token(), self.remaining())
.map_err(to_wit))
}
}
/// A desktop OAuth listener; its URI remains available after the one-shot wait.
pub struct LoopbackRes {
id: u64,
uri: String,
host: std::sync::Arc<dyn crate::HostApi>,
}
impl Drop for LoopbackRes {
fn drop(&mut self) {
self.host.close_loopback(self.id);
}
}
impl bindings::openidea::plugin::auth::Host for StoreData {
fn begin_loopback(&mut self, path: String) -> Fallible<Resource<LoopbackRes>> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
match self.host.begin_loopback(self.invocation.client.as_deref().expect("checked"), &path) {
Ok((id, uri)) => self.push(LoopbackRes { id, uri, host: self.host.clone() }).map(Ok),
Err(e) => Ok(Err(to_wit(e))),
}
}
}
impl bindings::openidea::plugin::auth::HostLoopback for StoreData {
fn redirect_uri(&mut self, this: Resource<LoopbackRes>) -> wasmtime::Result<String> {
Ok(self.table.get(&this)?.uri.clone())
}
fn wait(&mut self, this: Resource<LoopbackRes>, timeout_ms: u32) -> Fallible<Option<String>> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
let id = self.table.get(&this)?.id;
let started = Instant::now();
let result = self.host.wait_loopback(id, Duration::from_millis(u64::from(timeout_ms)), &self.cancel.token());
let waited = started.elapsed();
self.deadline += waited;
self.call_started += waited;
Ok(result.map_err(to_wit))
}
fn drop(&mut self, this: Resource<LoopbackRes>) -> wasmtime::Result<()> {
self.table.delete(this)?;
Ok(())
}
}
impl bindings::openidea::plugin::output::Host for StoreData {
fn append(&mut self, channel: String, text: String) -> Fallible<()> {
Ok(self.host.output_write(&channel, Some(&text)).map_err(to_wit))
}
fn clear(&mut self, channel: String) -> Fallible<()> {
Ok(self.host.output_write(&channel, None).map_err(to_wit))
}
fn reveal(&mut self, channel: String) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
Ok(self.host.output_reveal(self.invocation.client.as_deref().expect("checked"), &channel).map_err(to_wit))
}
}
fn diagnostic_to_wit(d: ide_core::Diagnostic) -> diagnostics::Diagnostic {
diagnostics::Diagnostic {
range: range_to_wit(&d.range),
severity: match d.severity {
ide_core::Severity::Error => diagnostics::Severity::Error,
ide_core::Severity::Warning => diagnostics::Severity::Warning,
ide_core::Severity::Info => diagnostics::Severity::Info,
ide_core::Severity::Hint => diagnostics::Severity::Hint,
},
message: d.message,
code: d.code,
source: d.source,
tags: [(d.unnecessary, "unnecessary"), (d.deprecated, "deprecated")]
.into_iter()
.filter(|(yes, _)| *yes)
.map(|(_, tag)| tag.into())
.collect(),
related: Vec::new(),
}
}
impl bindings::openidea::plugin::decorations::Host for StoreData {
fn set(
&mut self,
path: String,
set_id: String,
items: Vec<bindings::openidea::plugin::decorations::Decoration>,
) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
if items.len() > 10_000 {
return Ok(Err(wit::Error::Invalid("at most 10000 decorations per file".into())));
}
let items = items
.into_iter()
.map(|d| crate::Decoration { range: core_range(d.range), style: d.style, hover: d.hover, after: d.after })
.collect();
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
Ok(self.host.decorations(Some(&real), &set_id, items).map_err(to_wit))
}
fn clear(&mut self, set_id: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
Ok(self.host.decorations(None, &set_id, Vec::new()).map_err(to_wit))
}
fn set_file(
&mut self,
path: String,
set_id: String,
item: bindings::openidea::plugin::decorations::FileDecoration,
) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
Ok(self
.host
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
)
.map_err(to_wit))
}
fn clear_file(&mut self, set_id: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
wasmtime::component::bindgen!({ world: "editor-language-features", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
pub(crate) mod injections_bindings {
wasmtime::component::bindgen!({ world: "language-injections", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
impl bindings::openidea::plugin::database::Host for StoreData {
fn upsert_source(
&mut self,
project: Resource<ProjectRes>,
source: bindings::openidea::plugin::database::DataSource,
) -> Fallible<()> {
if let Err(e) = self.require(Capability::DatabaseSources) {
return Ok(Err(e));
}
let root = self.table.get(&project)?.root.clone();
Ok(self
.host
.database_upsert(
&root,
crate::PluginDataSource {
id: source.id,
name: source.name,
driver: source.driver,
host: source.host,
port: source.port,
database: source.database,
user: source.user,
url: source.url,
options: source.options,
},
)
.map_err(to_wit))
}
fn remove_source(&mut self, project: Resource<ProjectRes>, id: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::DatabaseSources) {
return Ok(Err(e));
}
let root = self.table.get(&project)?.root.clone();
Ok(self.host.database_remove(&root, &id).map_err(to_wit))
}
fn sources(&mut self, project: Resource<ProjectRes>) -> Fallible<Vec<String>> {
if let Err(e) = self.require(Capability::DatabaseSources) {
return Ok(Err(e));
}
let root = self.table.get(&project)?.root.clone();
Ok(self.host.database_sources(&root).map_err(to_wit))
}
fn set_credential(&mut self, project: Resource<ProjectRes>, id: String, password: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::DatabaseSources) {
return Ok(Err(e));
}
if password.len() > 64 << 10 {
return Ok(Err(wit::Error::Invalid("credential exceeds 64 KiB".into())));
}
let root = self.table.get(&project)?.root.clone();
Ok(self.host.database_credential(&root, &id, password).map_err(to_wit))
}
fn connect(&mut self, project: Resource<ProjectRes>, id: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::DatabaseSources).and_then(|_| self.require_window()) {
return Ok(Err(e));
}
let root = self.table.get(&project)?.root.clone();
let started = Instant::now();
let result = self.host.database_connect(
&root,
self.client().unwrap(),
&id,
&self.cancel.token(),
Duration::from_secs(180),
);
let waited = started.elapsed();
self.deadline += waited;
self.call_started += waited;
Ok(result.map_err(to_wit))
}
}
impl bindings::openidea::plugin::mcp::Host for StoreData {
fn stdio_command(&mut self) -> wasmtime::Result<Option<Vec<String>>> {
Ok(self.client().and_then(|client| self.host.mcp_stdio_command(client)))
}
}
pub(crate) mod mcp_bindings {
wasmtime::component::bindgen!({ world: "mcp-tools", path: "../../wit",
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
},
});
}
+8 -5
View File
@@ -32,7 +32,10 @@ fn components() -> Vec<PathBuf> {
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm"),
];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
@@ -88,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.3.0");
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.0.0-rc.1"] {
let host = v("1.4.0");
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.4.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.4.0"), &host).unwrap_err();
assert!(newer.contains("1.4") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.5.0"), &host).unwrap_err();
assert!(newer.contains("1.5") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.3 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.4 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
+337
View File
@@ -0,0 +1,337 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-api14"
version = "0.1.0"
dependencies = [
"openidea-plugin",
"serde_json",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-api14"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -0,0 +1 @@
f8c5b18f9734fd19f65a32e5087655d75ad944dddda31780f61672b94da3da6e
+137
View File
@@ -0,0 +1,137 @@
id = "api14"
name = "API 1.4 host tests"
version = "0.1.0"
engines.openidea = "^1.4"
wasm = "dist/plugin.wasm"
activation = ["on-command:*", "on-event:file-closed", "on-event:settings-changed", "on-event:input-mode-changed"]
[capabilities]
required = ["storage"]
optional = ["ide.commands", "project.read", "vcs.read", "events", "events.input", "editor.read", "secrets", "net:localhost", "net.http:example.com", "ipc:$TMPDIR/api14-*"]
[[contributes.secrets]]
name = "token"
label = "Token"
[contributes.settings]
schema = "settings.json"
[[contributes.commands]]
id = "ipc"
title = "ipc"
[[contributes.commands]]
id = "fetch"
title = "fetch"
[[contributes.commands]]
id = "window-status"
title = "window-status"
[[contributes.commands]]
id = "remove-window-status"
title = "remove-window-status"
[[contributes.commands]]
id = "global-status"
title = "global-status"
[[contributes.commands]]
id = "execute"
title = "execute"
[[contributes.commands]]
id = "oauth"
title = "oauth"
[[contributes.commands]]
id = "secret"
title = "secret"
[[contributes.commands]]
id = "delete-secret"
title = "delete-secret"
[[contributes.commands]]
id = "undeclared-secret"
title = "undeclared-secret"
[[contributes.commands]]
id = "output"
title = "output"
[[contributes.commands]]
id = "events"
title = "events"
[[contributes.commands]]
id = "process-run"
title = "process-run"
[[contributes.commands]]
id = "process-run-command"
title = "process-run-command"
[[contributes.commands]]
id = "process-start"
title = "process-start"
[[contributes.commands]]
id = "terminal"
title = "terminal"
[[contributes.commands]]
id = "log"
title = "log"
[[contributes.commands]]
id = "open-editors"
title = "open-editors"
[[contributes.commands]]
id = "open-editor"
title = "open-editor"
[[contributes.commands]]
id = "set-selections"
title = "set-selections"
[[contributes.commands]]
id = "reveal"
title = "reveal"
[[contributes.commands]]
id = "search-text"
title = "search-text"
[[contributes.commands]]
id = "problems"
title = "problems"
[[contributes.commands]]
id = "decorations"
title = "decorations"
[[contributes.commands]]
id = "clear-decorations"
title = "clear-decorations"
[[contributes.commands]]
id = "file-decoration"
title = "file-decoration"
[[contributes.commands]]
id = "clear-file-decorations"
title = "clear-file-decorations"
[[contributes.commands]]
id = "vcs-log"
title = "vcs-log"
[[contributes.commands]]
id = "vcs-blame"
title = "vcs-blame"
[[contributes.commands]]
id = "vcs-diff"
title = "vcs-diff"
+1
View File
@@ -0,0 +1 @@
{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":false}}}
+166
View File
@@ -0,0 +1,166 @@
use openidea_plugin::{Error, Event, Invocation, Plugin, StatusItem, host, register_plugin};
#[derive(Default)]
struct Api14;
impl Plugin for Api14 {
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
let arg = arg.unwrap_or_default();
match id {
"open-editors" => Ok(Some(format!("{:?}", host::open_editors()?))),
"open-editor" => {
host::open_editor(arg, Some(openidea_plugin::Range::default()))?;
Ok(None)
}
"set-selections" => {
ctx.editor().unwrap().set_selections(&[openidea_plugin::Range::default()])?;
Ok(None)
}
"reveal" => {
ctx.editor().unwrap().reveal(openidea_plugin::Range::default())?;
Ok(None)
}
"search-text" => {
let result = ctx
.project()
.unwrap()
.search_text(&openidea_plugin::TextQuery { pattern: arg.into(), ..Default::default() }, 10)?;
Ok(Some(format!("{result:?}")))
}
"problems" => Ok(Some(format!("{:?}", host::problems(if arg.is_empty() { None } else { Some(arg) })?))),
"decorations" => {
host::set_decorations(
arg,
"test",
&[openidea_plugin::Decoration {
range: openidea_plugin::Range::default(),
style: "highlight".into(),
hover: Some("hover".into()),
after: Some("after".into()),
}],
)?;
Ok(None)
}
"clear-decorations" => {
host::clear_decorations("test")?;
Ok(None)
}
"file-decoration" => {
host::set_file_decoration(
arg,
"test",
&openidea_plugin::FileDecoration { badge: Some("OK".into()), style: "added".into(), tooltip: None },
)?;
Ok(None)
}
"clear-file-decorations" => {
host::clear_file_decorations("test")?;
Ok(None)
}
"vcs-log" => Ok(Some(format!(
"{:?}",
ctx.project().unwrap().vcs_log(if arg.is_empty() { None } else { Some(arg) }, 10)?
))),
"vcs-blame" => Ok(Some(format!("{:?}", ctx.project().unwrap().vcs_blame(arg)?))),
"vcs-diff" => ctx.project().unwrap().vcs_diff(if arg.is_empty() { None } else { Some(arg) }, None),
"ipc" => {
let stream = host::ipc_connect(arg)?;
stream.write(b"echo")?;
let bytes = stream.read(4, 2000)?;
stream.close()?;
Ok(Some(String::from_utf8_lossy(&bytes).into()))
}
"fetch" => {
let response = host::fetch(&openidea_plugin::HttpRequest::get(arg))?;
Ok(Some(String::from_utf8_lossy(&response.body).into()))
}
"global-status" | "window-status" => {
let item = StatusItem { text: arg.into(), tooltip: None, command: Some("events".into()) };
if id == "global-status" {
host::set_status_item("state", &item);
} else {
host::set_window_status_item("state", &item)?;
}
Ok(None)
}
"remove-window-status" => {
host::remove_window_status_item("state")?;
Ok(None)
}
"execute" => host::execute_command(arg, Some("argument")),
"oauth" => {
let redirect = host::begin_loopback("oauth/callback")?;
host::output_append("oauth", &redirect.redirect_uri())?;
redirect.wait(2000)
}
"secret" => {
host::secret_set("token", arg)?;
Ok(None)
}
"delete-secret" => {
host::secret_delete("token")?;
Ok(None)
}
"undeclared-secret" => {
host::secret_set("undeclared", arg)?;
Ok(None)
}
"output" => {
host::output_append("test", arg)?;
host::output_reveal("test")?;
Ok(None)
}
"process-run" => Ok(Some(host::run_process("api14-tool", &[], None)?.stdout)),
"process-run-command" => Ok(Some(host::run_command(&host::Command::new("api14-tool"), None, None)?.stdout)),
"process-start" => {
let child = host::start(&host::Command::new("api14-tool"), None)?;
Ok(Some(child.wait(2000)?.stdout))
}
"terminal" => {
host::open_terminal("API test", "api14-tool", &[])?;
Ok(None)
}
"log" => {
host::open_log_tab(
"api-test",
"API test",
&openidea_plugin::details::ProcessLog {
program: "api14-tool".into(),
args: Vec::new(),
toggles: Vec::new(),
},
)?;
Ok(None)
}
"events" => host::storage::get("events"),
_ => Err(Error::NotFound(id.into())),
}
}
fn handle_events(&mut self, events: &[Event], ctx: &Invocation) {
let mut received: Vec<serde_json::Value> =
host::storage::get("events").unwrap().map(|s| serde_json::from_str(&s).unwrap()).unwrap_or_default();
for event in events {
if let Event::Other { kind, data } = event {
let editor = ctx.editor().map(|e| {
(
e.path(),
e.selections()
.unwrap()
.into_iter()
.map(|r| [r.start.line, r.start.character, r.end.line, r.end.character])
.collect::<Vec<_>>(),
)
});
received.push(serde_json::json!({ "kind": kind, "data": data, "editor": editor }));
}
}
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
}
}
impl openidea_plugin::TestResults for Api14 {
fn parse(&mut self, _config: &str, _chunk: &str) -> Vec<openidea_plugin::PluginTestEvent> {
vec![]
}
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
(config == "tests").then(|| tests.iter().map(|p| p.join("::")).collect())
}
}
register_plugin!(Api14, test_results);
Binary file not shown.
@@ -1 +1 @@
6d7d707c7492d487fbd9debae791ad7e3755ac005f1f518efa54e6536ec59f08
f4ae5c053230e2a805b097d79392501ad4654f715d6d194937299273b67fda7c
+337
View File
@@ -0,0 +1,337 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-data-tools"
version = "0.1.0"
dependencies = [
"openidea-plugin",
"serde_json",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-data-tools"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
@@ -0,0 +1 @@
{"type":"object","properties":{"text":{"type":"string"}},"additionalProperties":false}
Binary file not shown.
@@ -0,0 +1 @@
7631939096b867c4707f7e0f929d2b75f91ef2b76da764d6e21233a5c06e8af9
+68
View File
@@ -0,0 +1,68 @@
id = "test.data-tools"
name = "Data tools fixture"
version = "0.1.0"
engines.openidea = "^1.4"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
required = ["project.read"]
optional = ["database.sources"]
[[contributes.commands]]
id = "upsert"
title = "upsert"
[[contributes.commands]]
id = "remove"
title = "remove"
[[contributes.commands]]
id = "sources"
title = "sources"
[[contributes.commands]]
id = "credential"
title = "credential"
[[contributes.commands]]
id = "connect"
title = "connect"
[[contributes.commands]]
id = "stdio"
title = "stdio"
[[contributes.mcpTools]]
name = "echo"
description = "Fixture echo"
inputSchema = "arguments.json"
readOnly = true
destructive = false
[[contributes.mcpTools]]
name = "write"
description = "Fixture write"
inputSchema = "arguments.json"
readOnly = false
destructive = false
[[contributes.mcpTools]]
name = "erase"
description = "Fixture erase"
inputSchema = "arguments.json"
readOnly = false
destructive = true
[[contributes.mcpTools]]
name = "spin"
description = "Fixture spin"
inputSchema = "arguments.json"
readOnly = true
destructive = false
[[contributes.mcpTools]]
name = "denied"
description = "Fixture denied"
inputSchema = "arguments.json"
readOnly = true
destructive = false
+39
View File
@@ -0,0 +1,39 @@
use openidea_plugin::{Error, Invocation, McpProvider, Plugin, PluginDataSource, host, register_plugin};
#[derive(Default)]
struct DataTools;
impl Plugin for DataTools {
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
if id == "stdio" {
return Ok(Some(serde_json::to_string(&host::mcp_stdio_command()).unwrap()));
}
let p = ctx.project().ok_or("project required")?;
match id {
"upsert" => {
let source: PluginDataSource =
serde_json::from_str(arg.unwrap()).map_err(|e| Error::Invalid(e.to_string()))?;
p.upsert_data_source(&source)?;
}
"remove" => p.remove_data_source(arg.unwrap_or("local"))?,
"sources" => return Ok(Some(serde_json::to_string(&p.data_sources()?).unwrap())),
"credential" => p.set_data_source_credential("local", arg.unwrap_or("secret"))?,
"connect" => p.connect_data_source(arg.unwrap_or("local"))?,
_ => return Err(Error::NotFound(id.into())),
}
Ok(None)
}
}
impl McpProvider for DataTools {
fn call(&mut self, tool: &str, arguments: &str, ctx: &Invocation) -> Result<String, Error> {
match tool {
"spin" => loop {
std::hint::spin_loop();
},
"denied" => {
host::run_process("sh", &[], None)?;
Ok("unexpected".into())
}
_ => Ok(format!("{}:{}:{}", tool, ctx.project().ok_or("project required")?.root()?, arguments)),
}
}
}
register_plugin!(DataTools, mcp);
Binary file not shown.
@@ -1 +1 @@
652977e0bf63bc7664807016998a4d4795d2a7eb841ee1912ef562057e5437cb
f5a2d43df242aa0ba9f358d9abf7296fd6a56b163bb526e62ce9c8b4d17e4b9a
Binary file not shown.
@@ -1 +1 @@
42d3856b634bdaa69e21f4a42a9f375b73c2d19753a8986b82f2228bf92d37bc
f19481ef89309e6fc65736cb18018a4bf9b679e5a1c6c8e9f92adf39578ca4cb
Binary file not shown.
@@ -1 +1 @@
cacd381d84e1070fffe8f0ac64fad91b768df63b759c0b3e5e2d22a6a2fdeb73
a6ba20c5bd531d965e64041b05f1dabfb1634c3192b06cdb61578bd2bf6f1af2
+337
View File
@@ -0,0 +1,337 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-execution"
version = "0.1.0"
dependencies = [
"openidea-plugin",
"serde_json",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Loaded 100 of 407 files, more files were not shown because too many files have changed in this diff. Show more