From 1891119f9d456223bc9c6be7d0e373013d1f9f9c Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:30:28 +0100 Subject: [PATCH] feat(plugins): filter file events by path and resolve a document's project (#2003) Adds API 1.3 `events.subscribe-paths(kind, globs)` for `file-opened` and `file-saved`: project-relative globs checked before an event is queued or wakes an idle plugin, and again before a queued event is delivered. A new filter replaces the old one atomically; `subscribe` restores unfiltered delivery; filters survive idle reclamation. Also `workspace.project-of(path)`, the most specific open project containing a document (reading it still needs `project.read`). Closes #1958. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/api.rs | 27 +- crates/ide-host/src/plugins/event_paths.rs | 176 +++++++++ crates/ide-host/src/plugins/mod.rs | 18 +- crates/ide-host/tests/plugins.rs | 99 +++++- crates/ide-plugin/src/lib.rs | 2 +- crates/ide-plugin/src/runtime.rs | 17 +- crates/ide-plugin/src/sandbox.rs | 2 +- crates/ide-plugin/src/wit_host.rs | 17 +- .../fixtures/commit/dist/plugin.wasm.sources | 2 +- .../endpoints/dist/plugin.wasm.sources | 2 +- .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/hover/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../inlay-hints/dist/plugin.wasm.sources | 2 +- .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../misbehave/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/path-events/Cargo.lock | 333 ++++++++++++++++++ .../tests/fixtures/path-events/Cargo.toml | 20 ++ .../fixtures/path-events/dist/plugin.wasm | Bin 0 -> 92727 bytes .../path-events/dist/plugin.wasm.sources | 1 + .../tests/fixtures/path-events/plugin.toml | 34 ++ .../tests/fixtures/path-events/src/lib.rs | 47 +++ .../semantic-tokens/dist/plugin.wasm.sources | 2 +- .../fixtures/storage/dist/plugin.wasm.sources | 2 +- .../fixtures/tables/dist/plugin.wasm.sources | 2 +- plugins/README.md | 21 ++ .../ai-completion/dist/plugin.wasm.sources | 2 +- .../checkstyle/dist/plugin.wasm.sources | 2 +- .../csv-editor/dist/plugin.wasm.sources | 2 +- .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../key-promoter/dist/plugin.wasm.sources | 2 +- .../license-header/dist/plugin.wasm.sources | 2 +- .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/host.rs | 21 ++ sdk/rust/openidea-plugin/src/mock.rs | 96 ++++- sdk/rust/openidea-plugin/wit/events.wit | 4 + sdk/rust/openidea-plugin/wit/workspace.wit | 4 + wit/events.wit | 4 + wit/workspace.wit | 4 + 43 files changed, 949 insertions(+), 42 deletions(-) create mode 100644 crates/ide-host/src/plugins/event_paths.rs create mode 100644 crates/ide-plugin/tests/fixtures/path-events/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/path-events/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/path-events/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/path-events/src/lib.rs diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 4c55f35e4..418ace365 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -188,7 +188,7 @@ pub(super) struct Shared { contexts: Mutex>, closed_clients: Mutex>, /// plugin → kinds subscribed at run time (kept across idle reclaim). - pub subscriptions: Mutex>>, + pub subscriptions: Mutex, /// plugin → files it shows problems for, withdrawn when it stops. diagnostics: Mutex>>, pub secrets: RwLock>, @@ -331,7 +331,7 @@ impl Shared { let _ = workspace.publish_diagnostics(plugin, &path, Vec::new()); } } - if subscriptions && self.subscriptions.lock().remove(plugin).is_some() { + if subscriptions && self.subscriptions.lock().remove(plugin) { self.sink.emit(None, PluginEvent::SubscriptionsChanged); } } @@ -1166,13 +1166,22 @@ impl HostApi for PluginApi { }) } - fn subscribe(&self, kind: EventKind, on: bool) { - let changed = { - let mut subs = self.shared.subscriptions.lock(); - let set = subs.entry(self.id().to_owned()).or_default(); - if on { set.insert(kind) } else { set.remove(&kind) } - }; - if changed { + fn subscribe(&self, kind: EventKind) { + if self.shared.subscriptions.lock().set(self.id(), kind) { + self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); + } + } + + fn subscribe_paths(&self, kind: &str, globs: Vec) -> Result<(), HostError> { + let filter = super::event_paths::PathFilter::new(kind, globs)?; + if self.shared.subscriptions.lock().set_paths(self.id(), kind, filter) { + self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); + } + Ok(()) + } + + fn unsubscribe(&self, kind: &str) { + if self.shared.subscriptions.lock().unsubscribe(self.id(), kind) { self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); } } diff --git a/crates/ide-host/src/plugins/event_paths.rs b/crates/ide-host/src/plugins/event_paths.rs new file mode 100644 index 000000000..b8ff5eb54 --- /dev/null +++ b/crates/ide-host/src/plugins/event_paths.rs @@ -0,0 +1,176 @@ +//! Project-relative event filters, kept with runtime subscriptions across idle reclaim. +use super::{LoadedPlugin, PluginHost}; +use globset::{GlobBuilder, GlobMatcher}; +use ide_plugin::{EventKind, HostError, WorkbenchEvent}; +use std::{ + collections::{BTreeSet, HashMap}, + path::Path, +}; + +#[derive(Clone, Default)] +pub(super) struct Subscriptions { + pub kinds: HashMap>, + pub paths: HashMap>, +} + +#[derive(Clone)] +pub(super) struct PathFilter { + pub globs: Vec, + matchers: Vec, +} + +impl PathFilter { + pub fn new(kind: &str, globs: Vec) -> Result { + if !matches!(kind, "file-opened" | "file-saved") { + return Err(HostError::Invalid("subscribe-paths supports file-opened and file-saved".into())); + } + if globs.len() > 64 { + return Err(HostError::Invalid("at most 64 event path globs".into())); + } + let mut matchers = Vec::new(); + for input in &globs { + let glob = input.trim_start_matches("./"); + if glob.is_empty() + || input.len() > 1024 + || glob.starts_with('/') + || glob.contains('\\') + || glob.contains(':') + || glob.chars().any(char::is_control) + || glob.split('/').any(|c| c == "..") + { + return Err(HostError::Invalid( + "event globs must be project-relative paths, at most 1024 bytes each".into(), + )); + } + matchers.push( + GlobBuilder::new(glob) + .literal_separator(true) + .build() + .map_err(|e| HostError::Invalid(format!("invalid event glob: {e}")))? + .compile_matcher(), + ); + } + Ok(Self { globs, matchers }) + } + + pub fn matches(&self, root: &Path, path: &Path) -> bool { + let path = ide_plugin::resolve_real(path); + let root = ide_plugin::resolve_real(root); + path.strip_prefix(&root).is_ok_and(|relative| self.matchers.iter().any(|m| m.is_match(relative))) + } +} + +impl Subscriptions { + pub fn remove(&mut self, plugin: &str) -> bool { + self.kinds.remove(plugin).is_some() | self.paths.remove(plugin).is_some() + } + /// Subscribes to every event of `kind`, dropping its path filter. + pub fn set(&mut self, plugin: &str, kind: EventKind) -> bool { + let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind.name()).is_some()); + self.kinds.entry(plugin.to_owned()).or_default().insert(kind) | removed + } + pub fn set_paths(&mut self, plugin: &str, kind: &str, filter: PathFilter) -> bool { + let mut changed = false; + if let Some(kind) = EventKind::parse(kind) { + changed = self.kinds.entry(plugin.to_owned()).or_default().insert(kind); + } + let previous = self.paths.entry(plugin.to_owned()).or_default().insert(kind.to_owned(), filter.clone()); + previous.is_none_or(|old| old.globs != filter.globs) | changed + } + pub fn unsubscribe(&mut self, plugin: &str, kind: &str) -> bool { + let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind).is_some()); + let off = EventKind::parse(kind) + .is_some_and(|kind| self.kinds.get_mut(plugin).is_some_and(|kinds| kinds.remove(&kind))); + removed | off + } +} + +impl PluginHost { + pub(super) fn filter_path_events( + &self, + plugin: &LoadedPlugin, + client: Option<&str>, + events: &[WorkbenchEvent], + subs: &Subscriptions, + ) -> Vec { + let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() }; + let workspace = self.shared.workspace(); + events + .iter() + .filter(|event| { + let path = match event { + WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path), + _ => return true, + }; + let Some(filter) = filters.get(event.kind().name()) else { return true }; + if !path.is_absolute() { + return false; + } + let Some(workspace) = &workspace else { return false }; + let root = match client { + Some(client) => workspace.project_of(client), + None => { + let real = ide_plugin::resolve_real(path); + workspace + .projects() + .into_iter() + .filter(|r| real.starts_with(ide_plugin::resolve_real(r))) + .max_by_key(|r| r.components().count()) + } + }; + let Some(root) = root else { return false }; + if plugin.dev_root.as_deref().is_some_and(|r| r != root) { + return false; + } + filter.matches(&root, path) + }) + .cloned() + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glob_validation_is_bounded_and_project_relative() { + for invalid in ["", "/etc/*", "../*", "a/../b", "C:/a", "a\\b", "a\nb", "["] { + assert!(PathFilter::new("file-opened", vec![invalid.into()]).is_err(), "{invalid:?}"); + } + assert!(PathFilter::new("editor-focused", vec!["*".into()]).is_err()); + assert!(PathFilter::new("file-saved", vec!["*".into(); 65]).is_err()); + assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err()); + assert!(PathFilter::new("file-saved", vec![]).is_ok()); + } + + #[test] + fn matchers_use_or_semantics_and_component_boundaries() { + let root = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(root.path()).unwrap(); + let filter = PathFilter::new("file-opened", vec!["./config/*.yaml".into(), "src/**/*.rs".into()]).unwrap(); + for path in ["config/a.yaml", "src/main.rs", "src/深い/a.rs"] { + assert!(filter.matches(&root, &root.join(path)), "{path}"); + } + for path in ["config/nested/a.yaml", "other/a.yaml", "src/a.ts"] { + assert!(!filter.matches(&root, &root.join(path)), "{path}"); + } + assert!(!filter.matches(&root, &root.with_extension("other").join("config/a.yaml"))); + assert!(!PathFilter::new("file-opened", vec![]).unwrap().matches(&root, &root.join("a"))); + } + + #[test] + fn subscriptions_replace_atomically_and_legacy_subscribe_clears_filters() { + let mut subs = Subscriptions::default(); + subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec!["*.rs".into()]).unwrap()); + assert!(subs.kinds["plugin"].contains(&EventKind::FileOpened)); + subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec![]).unwrap()); + assert!(subs.paths["plugin"]["file-opened"].globs.is_empty()); + subs.set("plugin", EventKind::FileOpened); + assert!(subs.paths["plugin"].is_empty()); + subs.set_paths("plugin", "file-saved", PathFilter::new("file-saved", vec!["*".into()]).unwrap()); + assert!(subs.unsubscribe("plugin", "file-saved")); + assert!(subs.remove("plugin")); + assert!(subs.paths.is_empty() && subs.kinds.is_empty()); + } +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 0aeb50452..b02846219 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -18,6 +18,7 @@ mod api; pub mod commit; pub mod endpoints; +mod event_paths; mod hover; mod icon_extensions; mod icon_patterns; @@ -1450,7 +1451,7 @@ impl PluginHost { let mut out = BTreeSet::new(); for p in self.plugins.read().values() { for kind in EventKind::ALL { - if self.wants(p, kind, subs.get(p.id())) { + if self.wants(p, kind, subs.kinds.get(p.id())) { out.insert(kind); } } @@ -1483,7 +1484,8 @@ impl PluginHost { let mut queues = self.queues.lock(); for p in plugins { let wanted: Vec = - events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect(); + events.iter().filter(|e| self.wants(&p, e.kind(), subs.kinds.get(p.id()))).cloned().collect(); + let wanted = self.filter_path_events(&p, client, &wanted, &subs); if wanted.is_empty() { continue; } @@ -1563,6 +1565,18 @@ impl PluginHost { /// Returns whether the plugin was activated. fn deliver(&self, id: &str, client: Option, events: Vec) -> bool { let Ok(plugin) = self.get(id) else { return false }; + let subs = self.shared.subscriptions.lock().clone(); + let events: Vec<_> = self + .filter_path_events(&plugin, client.as_deref(), &events, &subs) + .into_iter() + .filter(|event| { + !matches!(event, WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_)) + || self.wants(&plugin, event.kind(), subs.kinds.get(id)) + }) + .collect(); + if events.is_empty() { + return false; + } if !plugin.meta.lock().pending.is_empty() { return false; } diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index af9bf0d69..a5ff31292 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -19,6 +19,7 @@ type Events = Arc, PluginEvent)>>>; /// What plugins see of the test project: real `Documents`, as in the IDE. struct TestWorkspace { root: PathBuf, + other_roots: Mutex>, documents: Arc, trusted: bool, /// (producer, path) → published diagnostics. @@ -35,6 +36,7 @@ impl TestWorkspace { ); Arc::new(Self { root: root.to_path_buf(), + other_roots: Mutex::default(), documents: Arc::new(documents), trusted: true, diagnostics: Mutex::default(), @@ -67,7 +69,7 @@ impl TestWorkspace { impl PluginWorkspace for TestWorkspace { fn projects(&self) -> Vec { - vec![self.root.clone()] + std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect() } fn buffer_text(&self, path: &Path) -> Option { self.documents.text_of(path) @@ -1653,6 +1655,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() { f.install(&official("git-toolbox")); let untrusted = Arc::new(TestWorkspace { root: f.project.clone(), + other_roots: Mutex::default(), documents: f.workspace.documents.clone(), trusted: false, diagnostics: Mutex::default(), @@ -3565,3 +3568,97 @@ fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capab assert!(!f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::OpenProject { .. }))); } } + +#[test] +fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() { + let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() }); + f.install(&sample("path-events")); + f.run("test.path-events", "count", None).unwrap(); + let matched = to_ipc(&f.project.join("config/app.yaml")); + let other = to_ipc(&f.project.join("src/main.rs")); + let deliver = |events| { + f.host.post_events(Some("main"), events); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + }; + std::thread::sleep(Duration::from_millis(150)); + f.host.tick(); + assert_eq!(f.status("test.path-events"), PluginStatus::Inactive); + let activations = f.host.info_of("test.path-events").unwrap().stats.activations; + deliver(vec![WorkbenchEvent::FileOpened(other.clone()), WorkbenchEvent::FileSaved(other.clone())]); + assert_eq!(f.host.info_of("test.path-events").unwrap().stats.activations, activations); + deliver(vec![WorkbenchEvent::FileOpened(matched.clone()), WorkbenchEvent::FileSaved(matched.clone())]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("2")); + assert!(f.run("test.path-events", "filter", Some("../*")).is_err()); + deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]); + assert_eq!( + f.run("test.path-events", "count", None).unwrap().as_deref(), + Some("3"), + "invalid filter preserves previous one" + ); + + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(matched.clone())]); + f.run("test.path-events", "filter", None).unwrap(); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + assert_eq!( + f.run("test.path-events", "count", None).unwrap().as_deref(), + Some("3"), + "queued event uses current filter" + ); + deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("3")); + f.run("test.path-events", "all", None).unwrap(); + deliver(vec![WorkbenchEvent::FileOpened(other.clone())]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4")); + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(other.clone())]); + f.run("test.path-events", "off", None).unwrap(); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + deliver(vec![WorkbenchEvent::FileOpened(other)]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4")); + + f.host.set_enabled("test.path-events", false).unwrap(); + assert!(!f.host.event_kinds().contains(&WorkbenchEvent::FileOpened(String::new()).kind())); +} + +#[test] +fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_project() { + let f = fixture(Limits::default()); + f.install(&sample("path-events")); + let path = f.project.join("src/notes.txt"); + assert_eq!(f.run("test.path-events", "project", Some(&to_ipc(&path))).unwrap(), Some(to_ipc(&f.project))); + assert_eq!(f.run("test.path-events", "project", Some("src/notes.txt")).unwrap(), None); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&f.project.with_extension("other").join("a")))).unwrap(), + None + ); + let doc = ide_plugin::ProviderDocument { path, language: "text".into(), version: 1, text: "one".into() }; + let hovers = f.host.provide_hover(&doc, ide_core::Pos::new(0, 0)); + assert_eq!(hovers.len(), 1); + assert_eq!(hovers[0].markdown, to_ipc(&f.project)); + let nested = f.project.join("nested"); + std::fs::create_dir(&nested).unwrap(); + f.workspace.other_roots.lock().push(nested.clone()); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&nested.join("new.txt")))).unwrap(), + Some(to_ipc(&nested)) + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&nested, f.project.join("alias")).unwrap(); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("alias/new.txt")))).unwrap(), + Some(to_ipc(&nested)) + ); + std::os::unix::fs::symlink(f._tmp.path(), f.project.join("outside")).unwrap(); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("outside/new.txt")))).unwrap(), + None + ); + } + f.host.set_capability("test.path-events", "project.read", false).unwrap(); + assert!(f.run("test.path-events", "project", Some(&to_ipc(&doc.path))).unwrap_err().contains("project.read")); + f.host.set_capability("test.path-events", "events", false).unwrap(); + assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events")); +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 1b417b2b0..1878bb790 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -20,6 +20,6 @@ pub use runtime::{ API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api, component_api, host_api, }; -pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root}; +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; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index e0bdf562b..28991a97b 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -110,6 +110,17 @@ pub trait HostApi: Send + Sync { } /// Roots of the open projects. fn projects(&self) -> Vec; + /// The most specific open project containing a document (including a new file). + fn project_of(&self, path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let real = crate::resolve_real(path); + self.projects() + .into_iter() + .filter(|root| real.starts_with(crate::resolve_real(root))) + .max_by_key(|root| root.components().count()) + } /// Unsaved text of an open editor's file. fn buffer_text(&self, path: &Path) -> Option; /// Version and text of the open document at `path`. @@ -166,7 +177,11 @@ pub trait HostApi: Send + Sync { cancel: &CancellationToken, timeout: Duration, ) -> Result; - fn subscribe(&self, kind: EventKind, on: bool); + /// Delivers every event of `kind`, dropping its path filter. + fn subscribe(&self, kind: EventKind); + /// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`. + fn subscribe_paths(&self, kind: &str, globs: Vec) -> Result<(), HostError>; + fn unsubscribe(&self, kind: &str); /// Starts a progress indicator; cancelling it from the UI cancels `cancel`. fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64; fn task_report(&self, id: u64, done: u32, total: Option, message: Option); diff --git a/crates/ide-plugin/src/sandbox.rs b/crates/ide-plugin/src/sandbox.rs index 7d966ff16..71a03d615 100644 --- a/crates/ide-plugin/src/sandbox.rs +++ b/crates/ide-plugin/src/sandbox.rs @@ -21,7 +21,7 @@ fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> { /// the canonical path when it exists, else the canonicalized deepest existing /// ancestor with the still-missing tail resolved textually — so the location /// of a missing path is still judged where it would really land. -fn resolve_real(joined: &Path) -> PathBuf { +pub fn resolve_real(joined: &Path) -> PathBuf { let mut tail = Vec::new(); let mut candidate = joined.to_path_buf(); let mut real = loop { diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 568e3b03c..bf9d92b09 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -460,6 +460,10 @@ impl workspace::Host for StoreData { Ok(self.host.open_project(client.as_deref(), path, new_window).map_err(to_wit)) } + fn project_of(&mut self, path: String) -> wasmtime::Result>> { + self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose() + } + fn projects(&mut self) -> Fallible>> { if let Err(e) = self.require(Capability::ProjectRead) { return Ok(Err(e)); @@ -758,14 +762,19 @@ impl events::Host for StoreData { { return Ok(Err(e)); } - self.host.subscribe(kind, true); + self.host.subscribe(kind); Ok(Ok(())) } - fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> { - if let Some(kind) = EventKind::parse(&kind) { - self.host.subscribe(kind, false); + fn subscribe_paths(&mut self, kind: String, globs: Vec) -> Fallible<()> { + if let Err(e) = self.require(Capability::Events) { + return Ok(Err(e)); } + Ok(self.host.subscribe_paths(&kind, globs).map_err(to_wit)) + } + + fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> { + self.host.unsubscribe(&kind); Ok(()) } } diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 65cc7254d..80f5f73b6 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -f81a6b838d4bee81a058f79436a012f75f55a0c80ad1f10f0af7bd2aba9c32c6 +627be971ecc31efd268258eff214ed08c671c2a158677b773de9d5be462b17c4 diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index 2363d3bf4..d1fd3db1c 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -344660862897f17d7b8f7cdb1397f715201abeab3fc8d7ba1b62c82ca0d9e60b +56683bb1e102025801610a2d61ce393f03bde9a3e5b5e52dde2ff272b3f10662 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 3f4dc2f39..5b3b1f113 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -47c52d929c61fc852f232c0ba5eb798bbdbc9497797cd7f454b32969779d669b +d6a10bd1a97e7da44338fc9a28f5a64054c9269f553003298f816bb114f42a88 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 40bf2f7cf..4eb5d092c 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -c8b3c8b074c765a81f8d1096ffe36cd8b93999096b9b77959a4d469e5f6e25f6 +21d7a4cf9348568b7cb2b565c6e26a727165319bd685147fcedd3a8465565990 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 0418a5dbd..f5a605f66 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -58090f3c530ce4e736b7d182e73d1eaaee7f8db65f76eed0481a00f50828896a +636846f46f27ef901fde5c8767991fdc1060c86d7f90a12b0cb08933a2e60b37 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index a001ad8cc..6debc7b53 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -56b93b6a70821331e1bc835b64b4273d7a2989c66b6623e6a8d097464375e9b3 +a35dfaecf8d76d3ca95c80e810be26781671f58a48b50374e2656cdd6959cdf2 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index ef2677b58..3afdf228b 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -ec5a77e097fea4962ffa8227c0bfb247e61d360c5da983c7dabfc84159bf457a +a8b92a0ee9b46d8d414457ff287d6092e85d4536e913d146aa49662814ff1836 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 96e1c60f6..5c04746d3 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -89291c6203a966a5163358ffd64630874d014fe239d7e9747890ebbd8f686540 +2fa3d6146a99eee79d4aec12bf8777369ae5cc76c3f7d6a8c254d5a27d58eeb9 diff --git a/crates/ide-plugin/tests/fixtures/path-events/Cargo.lock b/crates/ide-plugin/tests/fixtures/path-events/Cargo.lock new file mode 100644 index 000000000..9295140e5 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/Cargo.lock @@ -0,0 +1,333 @@ +# 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 = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-test-path-events" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[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", +] + +[[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" diff --git a/crates/ide-plugin/tests/fixtures/path-events/Cargo.toml b/crates/ide-plugin/tests/fixtures/path-events/Cargo.toml new file mode 100644 index 000000000..f4ec67ef5 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-path-events" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..043a98de0a31ec29e20d39564fcc002cf39e5e92 GIT binary patch literal 92727 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 X|H>xwauc`ZA=ebWE}QICXP*B*cNsgg literal 0 HcmV?d00001 diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources new file mode 100644 index 000000000..56dee4cff --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -0,0 +1 @@ +c14d91010021e3b35f6ae29a2a327bb3ad66087f07cfd744814443862c0050cc diff --git a/crates/ide-plugin/tests/fixtures/path-events/plugin.toml b/crates/ide-plugin/tests/fixtures/path-events/plugin.toml new file mode 100644 index 000000000..c959419ad --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/plugin.toml @@ -0,0 +1,34 @@ +id = "test.path-events" +name = "Path event fixture" +version = "0.1.0" +engines.openidea = "^1.3" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["storage"] +optional = ["events", "project.read"] + +[[contributes.providers]] +kinds = ["hover"] +languages = ["text", "plaintext"] + +[[contributes.commands]] +id = "project" +title = "project" + +[[contributes.commands]] +id = "filter" +title = "filter" + +[[contributes.commands]] +id = "all" +title = "all" + +[[contributes.commands]] +id = "off" +title = "off" + +[[contributes.commands]] +id = "count" +title = "count" diff --git a/crates/ide-plugin/tests/fixtures/path-events/src/lib.rs b/crates/ide-plugin/tests/fixtures/path-events/src/lib.rs new file mode 100644 index 000000000..fc9a8eed3 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/src/lib.rs @@ -0,0 +1,47 @@ +use openidea_plugin::hover::{Document, Hover, HoverProvider}; +use openidea_plugin::{Context, Error, Event, EventKind, Invocation, Plugin, Position, host, register_plugin}; + +#[derive(Default)] +struct Fixture; + +impl Plugin for Fixture { + fn activate(&mut self, _: &Context) -> Result<(), Error> { + // Persist the chosen filter so reactivation does not overwrite it. + let glob = host::storage::get("filter")?.unwrap_or_else(|| "config/*.yaml".into()); + let globs: Vec<_> = glob.lines().collect(); + let _ = host::subscribe_paths("file-opened", &globs); + let _ = host::subscribe_paths("file-saved", &["config/*.yaml"]); + Ok(()) + } + fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result, Error> { + match id { + "project" => host::project_of(arg.unwrap_or_default()).map(|p| p.root()).transpose(), + "filter" => { + let glob = arg.unwrap_or_default(); + host::subscribe_paths("file-opened", &glob.lines().collect::>())?; + host::storage::set("filter", glob)?; + Ok(None) + } + "all" => { + host::subscribe(EventKind::FileOpened)?; + Ok(None) + } + "off" => { + host::unsubscribe(EventKind::FileOpened); + Ok(None) + } + "count" => Ok(host::storage::get("count")?), + _ => Err(Error::NotFound(id.into())), + } + } + fn handle_events(&mut self, events: &[Event], _: &Invocation) { + let count = host::storage::get("count").ok().flatten().and_then(|n| n.parse::().ok()).unwrap_or(0); + host::storage::set("count", &(count + events.len()).to_string()).unwrap(); + } +} +impl HoverProvider for Fixture { + fn hover(&mut self, doc: &Document, _: Position) -> Option { + Some(Hover::new(host::project_of(&doc.path)?.root().ok()?)) + } +} +register_plugin!(Fixture, hover); diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index 0b3aeaf03..cffc162fd 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -284efc25ade647bd1944024a04a944d63cbc69d8647cc9eff95eb4add0d8509c +e9b6c10973ce62057eb3c92a747bcb27fdef1cfb6dd1d8681aa0035d3a3b5fa2 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index 0f00f1fc0..207373302 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -243ac921ab396b2fc2735bb8552394d90246080264143f1205c59f184db4b504 +a873a709240a3d24c45481b220ac283103f10645166952cb5dcc809f7995e085 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index d554de5bb..c4a7d7e70 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -4d753f6d72e25afee529684219a0c69fcadd33c53a377d503343fb6532ac8979 +8801a468856e0e4783848f89d51d6d6f45426e6cadf2b2532518500ff23aa6f7 diff --git a/plugins/README.md b/plugins/README.md index 61d491c93..4dd12940f 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -419,6 +419,27 @@ a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON). The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm. +API 1.3 adds `host::subscribe_paths("file-opened", &["config/*.yaml"])` +(`events.subscribe-paths`). The host matches project-relative globs before +queueing an event or waking an idle plugin. `file-saved` works the same way. +Patterns use `/`, `*` stays within a directory, and `**` crosses +directories. Up to 64 patterns of 1024 bytes each are accepted; absolute paths, +parent traversal and malformed patterns fail without replacing the old filter. + +Register filters in `activate`, using a startup or command activation to install +them before observing events. Patterns are ORed; another call replaces the same +kind's filter, an empty list pauses it, `subscribe(kind)` restores all paths, +and `unsubscribe(kind)` removes it. Filters survive idle reclaim and disappear +on disable, uninstall or reload. Each window's events use its current project; +files outside that project do not match. + +Provider calls can use `host::project_of(&doc.path)` (`workspace.project-of`) +without an invocation project or enumerating every open project. It returns the +deepest open project containing the absolute path, or `None` outside open roots. +Symlinks and missing file tails are resolved before checking containment. +Obtaining a handle grants no read access: `Project::root` and file operations +still require `project.read`. + ### Commits A plugin can work on the commit the user is preparing, in the Commit tool diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 45abe7179..f7059eb82 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316 +d46e07036f0f59c18ca54affbca6446c320476998f3e92199cd93ed43dd725d9 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index ede72b964..47b51c2b6 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf +c28b0da5a21fc0fc5dd543d01eaeeed4f7bbd89a5d28b95ce911a1c092209adc diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index f934d73f2..00ba69d61 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a +f4258172c7a8278d00f5da9d76420380200d8b0d8bc387ceafab30703cbdac4a diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 024e3ba88..7f5ca161e 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696 +648b030335ecfdf82651561fc5e71edc9f9d4319ae4bfb56d1e6d7e1f029d781 diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index fab3d5b66..f1e73887b 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892 +f7bbe6097f18377a79f39ba2369140e24eff65501e78be94e3420c6667362743 diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index cb3f47b28..4cd2641dd 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852 +936d6e2c74f87cc1d95c52ea674716d934df917c12fb8431989f8dc64989b174 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 2f706ae66..bf49a1220 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809 +182af35d880a9adfa2566a42b62db6cf69f7d2a5157e0428228b649864e9417c diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 3e5f9a1d5..d2cba9a41 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a +f750b060f9a4217da81395aaa9875811d61d087e8d7fef1a0d7b46e3d08cd4a8 diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index f6e51b94e..6b1a93310 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268 +4edc8aad4feb31205d567e303a18f9d0d4969bc65dc8f3699b46cd05b93d8868 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index fe62a5e73..33245bb32 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949 +e1e58cd1ee112923dfc1debe86f8feb18c1a4e9f58490b5e371ee0272cad6ced diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index ff114fb2c..5813764a4 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb +318e264055acfd1d10491dfea784fdb2a034d3b4da8f7583e81720e21ea1947c diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 0d9d9b01f..b08e3df37 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -182,6 +182,10 @@ mod imp { wit::workspace::projects().map_err(error) } + pub fn project_of(path: &str) -> Option { + wit::workspace::project_of(path) + } + pub fn project_root(p: &ProjectRes) -> Result { p.root().map_err(error) } @@ -278,6 +282,10 @@ mod imp { } } + pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> { + wit::events::subscribe_paths(kind, &globs.iter().map(|g| (*g).to_owned()).collect::>()).map_err(error) + } + pub fn subscribe(k: EventKind) -> Result<(), Error> { wit::events::subscribe(k.name()).map_err(error) } @@ -621,6 +629,12 @@ pub fn projects() -> Result, Error> { Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect()) } +/// The deepest open project containing an absolute document path, including in providers. +/// Obtaining the handle needs no capability; reading it still needs `project.read`. +pub fn project_of(path: &str) -> Option { + imp::project_of(path).map(|inner| Project { inner }) +} + /// A file open in an editor. pub struct TextEditor { inner: imp::EditorRes, @@ -790,6 +804,13 @@ pub fn locale() -> String { imp::locale() } +/// Subscribe to project-relative path globs for file-opened or file-saved. +/// Replaces the previous filter. Empty globs pause delivery; `subscribe` restores all paths. +/// Requires `events`. Register during activation before the events you want to observe. +pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> { + imp::subscribe_paths(kind, globs) +} + /// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of /// the session (also after the instance is reclaimed: an event activates the /// plugin again). Needs `events` (`events.input` for `ActionPerformed`). diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index fb0c05b44..a01e8f510 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -72,6 +72,7 @@ struct State { context: BTreeMap, storage: BTreeMap, subscriptions: BTreeSet, + path_subscriptions: BTreeMap>, tasks: Vec, vcs: Option, remotes: Vec<(String, String)>, @@ -230,6 +231,10 @@ impl MockHost { self.state.borrow().storage.clone() } + pub fn path_subscriptions(&self) -> BTreeMap> { + self.state.borrow().path_subscriptions.clone() + } + pub fn subscriptions(&self) -> BTreeSet { self.state.borrow().subscriptions.clone() } @@ -613,6 +618,27 @@ pub mod current { }) } + pub fn project_of(path: &str) -> Option { + let path = path.replace('\\', "/"); + let absolute = + path.starts_with('/') || (path.as_bytes().get(1) == Some(&b':') && path.as_bytes().get(2) == Some(&b'/')); + if !absolute || path.split('/').any(|part| part == "..") { + return None; + } + with(|s| { + s.root + .iter() + .chain(&s.other_projects) + .filter(|root| { + let root = root.replace('\\', "/"); + path == root + || path.strip_prefix(root.trim_end_matches('/')).is_some_and(|rest| rest.starts_with('/')) + }) + .max_by_key(|root| root.replace('\\', "/").split('/').count()) + .map(|root| MockProject { root: root.clone() }) + }) + } + pub fn project_root(p: &ProjectRes) -> Result { with(|s| { require(s, "project.read")?; @@ -797,6 +823,38 @@ pub mod current { }) } + pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> { + with(|s| { + require(s, "events")?; + if !matches!(kind, "file-opened" | "file-saved") + || globs.len() > 64 + || globs.iter().any(|g| { + let relative = g.trim_start_matches("./"); + relative.is_empty() + || g.len() > 1024 + || relative.starts_with('/') + || g.contains('\\') + || g.contains(':') + || g.chars().any(char::is_control) + || g.split('/').any(|p| p == "..") + }) + { + return Err(Error::Invalid("invalid event path filter".into())); + } + match kind { + "file-opened" => { + s.subscriptions.insert(EventKind::FileOpened); + } + "file-saved" => { + s.subscriptions.insert(EventKind::FileSaved); + } + _ => {} + } + s.path_subscriptions.insert(kind.to_owned(), globs.iter().map(|g| (*g).to_owned()).collect()); + Ok(()) + }) + } + pub fn subscribe(kind: EventKind) -> Result<(), Error> { with(|s| { let cap = match kind { @@ -807,13 +865,17 @@ pub mod current { if let Some(cap) = cap { require(s, cap)?; } + s.path_subscriptions.remove(kind.name()); s.subscriptions.insert(kind); Ok(()) }) } pub fn unsubscribe(kind: EventKind) { - with(|s| s.subscriptions.remove(&kind)); + with(|s| { + s.path_subscriptions.remove(kind.name()); + s.subscriptions.remove(&kind); + }); } pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result, Error> { @@ -965,6 +1027,38 @@ mod tests { use super::*; use crate::host; + #[test] + fn path_subscriptions_replace_pause_and_restore() { + let mock = MockHost::install(); + host::subscribe_paths("file-opened", &["config/*.yaml"]).unwrap(); + assert_eq!(mock.path_subscriptions()["file-opened"], ["config/*.yaml"]); + host::subscribe_paths("file-opened", &[]).unwrap(); + assert!(mock.path_subscriptions()["file-opened"].is_empty()); + assert!(host::subscribe_paths("file-opened", &["../secret"]).is_err()); + host::subscribe(EventKind::FileOpened).unwrap(); + assert!(mock.path_subscriptions().is_empty()); + host::subscribe_paths("file-saved", &["**/*.rs"]).unwrap(); + host::unsubscribe(EventKind::FileSaved); + assert!(mock.path_subscriptions().is_empty()); + let _denied = MockHost::install().with_capabilities(&[]); + assert!(matches!(host::subscribe_paths("file-opened", &[]), Err(Error::Denied(_)))); + } + + #[test] + fn project_lookup_uses_deepest_root_without_granting_read_access() { + let mock = MockHost::install(); + mock.add_project("/project/nested"); + assert_eq!(host::project_of("/project/nested/new.txt").unwrap().root().unwrap(), "/project/nested"); + assert!(host::project_of("/project-other/file").is_none()); + assert!(host::project_of("relative/file").is_none()); + mock.add_project("C:/workspace"); + assert_eq!(host::project_of(r"C:\workspace\file.txt").unwrap().root().unwrap(), "C:/workspace"); + mock.add_project("//server/share"); + assert_eq!(host::project_of(r"\\server\share\file.txt").unwrap().root().unwrap(), "//server/share"); + let _denied = MockHost::install().with_capabilities(&[]); + assert!(matches!(host::project_of("/project/file").unwrap().root(), Err(Error::Denied(_)))); + } + #[test] fn invocations_carry_selected_paths() { let host = MockHost::install(); diff --git a/sdk/rust/openidea-plugin/wit/events.wit b/sdk/rust/openidea-plugin/wit/events.wit index 92175d555..ffb86d39b 100644 --- a/sdk/rust/openidea-plugin/wit/events.wit +++ b/sdk/rust/openidea-plugin/wit/events.wit @@ -36,6 +36,10 @@ interface events { /// and an IDE that doesn't know one fails with `invalid`. /// Requires `events` (`events.input` for `action-performed`). subscribe: func(kind: string) -> result<_, error>; + /// Replaces this kind's subscription with project-relative globs (OR). + /// Empty globs pause delivery; `subscribe` restores unfiltered delivery. + /// Kinds: file-opened, file-saved. Requires `events`. + subscribe-paths: func(kind: string, globs: list) -> result<_, error>; unsubscribe: func(kind: string); } diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index b31964e8b..c764d50e8 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -8,6 +8,10 @@ interface workspace { /// `path` is an absolute directory path on the UI machine. open-project: func(path: string, new-window: bool) -> result<_, error>; + /// The open project containing an absolute document path, or `none`. + /// Reading the returned project still requires `project.read`. + project-of: func(path: string) -> option; + /// What a path inside a project is. enum file-kind { file, diff --git a/wit/events.wit b/wit/events.wit index 92175d555..ffb86d39b 100644 --- a/wit/events.wit +++ b/wit/events.wit @@ -36,6 +36,10 @@ interface events { /// and an IDE that doesn't know one fails with `invalid`. /// Requires `events` (`events.input` for `action-performed`). subscribe: func(kind: string) -> result<_, error>; + /// Replaces this kind's subscription with project-relative globs (OR). + /// Empty globs pause delivery; `subscribe` restores unfiltered delivery. + /// Kinds: file-opened, file-saved. Requires `events`. + subscribe-paths: func(kind: string, globs: list) -> result<_, error>; unsubscribe: func(kind: string); } diff --git a/wit/workspace.wit b/wit/workspace.wit index b31964e8b..c764d50e8 100644 --- a/wit/workspace.wit +++ b/wit/workspace.wit @@ -8,6 +8,10 @@ interface workspace { /// `path` is an absolute directory path on the UI machine. open-project: func(path: string, new-window: bool) -> result<_, error>; + /// The open project containing an absolute document path, or `none`. + /// Reading the returned project still requires `project.read`. + project-of: func(path: string) -> option; + /// What a path inside a project is. enum file-kind { file,