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 000000000..043a98de0 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm differ 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,