Compare commits

...
Author SHA1 Message Date
arookieofc c90d3a7a8e fix(java): run jdtls on a new-enough JDK behind Scoop shims and project JAVA_HOME (#2230)
* fix(java): run jdtls on a new-enough JDK behind Scoop shims and project JAVA_HOME

Two ways jdtls ended up on a JDK too old for its bundles (#2227):

- jdtls_min_java resolved the command's own path to derive the install
  root, but a Scoop shim (shims\jdtls.exe) hides the release layout, so
  the bundles' real requirement fell back to JDTLS_MIN_JAVA (21) while a
  1.64 install needs JavaSE 25. Extract the resolve/follow-shim flow
  from launch_directly into jdtls::script_for and share it.
- configure_jdtls only wrote JAVA_HOME when the config had none, so a
  project JAVA_HOME (JDK 8 for compilation) won over the chosen
  server_jdk. Probe the configured value: a home too old or incomplete
  for the server is replaced by server_jdk or any usable JDK; an
  unresolvable explicit value stays (the user's call).

* test(java): compare JDK homes against canonical paths

jdk_at reports the canonicalized home (macOS /var -> /private/var,
Windows 8.3 short names), which configure_jdtls now writes to JAVA_HOME.
Have test_jdk canonicalize too, and assert the runtimes/JAVA_HOME values
against the Jdk.home fields instead of raw fixtures.path() joins, which
also differ on Windows when the join arg uses forward slashes.

* test(jdtls): compare JAVA_HOME against the canonical jdk home

test_jdk now canonicalizes home, so the duplicate-versions assertion must
compare against server.home rather than the raw tempdir join, which on
Windows carries 8.3 short names.
2026-11-09 11:08:28 +08:00
l0ng-ai 115d789091 perf(agents): make switching to a long conversation tab cheap (#2252)
Folded conversation sections render their content only when opened (or while Find in Conversation is open), and off-screen rows skip rendering once laid out, so showing a kept-alive agent tab no longer restyles the whole history.
2026-11-09 11:05:53 +08:00
Kamal ElFadlandelfadl fb92a82101 feat(debugger): support clickable file and URL links in debug console (#2245)
Co-authored-by: elfadl <[email protected]>
2026-11-09 10:56:44 +08:00
arookieofc 7829bee55c fix(vcs): edit the full commit message, not only the subject (#2242)
A paged Git log only carries each commit's summary line; the Edit Commit
Message dialog therefore opened with the body missing, and OK rewrote the
commit to the subject alone.

Load the full message through loadLogDetails when the commit's
messageTruncated flag is set, the same path Copy Commit Message already
uses, so the dialog edits subject and body together.

Fixes #2238
2026-11-09 10:48:39 +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
414 changed files with 24105 additions and 1222 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))
})
+268 -14
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
}
@@ -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")));
+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"
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-execution"
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.
Loaded 100 of 414 files, more files were not shown because too many files have changed in this diff. Show more