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 <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-07 23:30:28 +08:00
1 джерело 8509ca671f
коміт 1891119f9d
43 files changed
+949 -42

No files matched your search

+18 -9
Переглянути файл
@@ -188,7 +188,7 @@ pub(super) struct Shared {
contexts: Mutex<BTreeMap<ContextKey, String>>,
closed_clients: Mutex<BTreeSet<String>>,
/// plugin → kinds subscribed at run time (kept across idle reclaim).
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
pub subscriptions: Mutex<super::event_paths::Subscriptions>,
/// plugin → files it shows problems for, withdrawn when it stops.
diagnostics: Mutex<HashMap<String, BTreeSet<PathBuf>>>,
pub secrets: RwLock<Arc<dyn SecretStore>>,
@@ -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<String>) -> 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);
}
}
+176
Переглянути файл
@@ -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<String, BTreeSet<EventKind>>,
pub paths: HashMap<String, HashMap<String, PathFilter>>,
}
#[derive(Clone)]
pub(super) struct PathFilter {
pub globs: Vec<String>,
matchers: Vec<GlobMatcher>,
}
impl PathFilter {
pub fn new(kind: &str, globs: Vec<String>) -> Result<Self, HostError> {
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<WorkbenchEvent> {
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());
}
}
+16 -2
Переглянути файл
@@ -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<WorkbenchEvent> =
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<String>, events: Vec<WorkbenchEvent>) -> 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;
}
+98 -1
Переглянути файл
@@ -19,6 +19,7 @@ type Events = Arc<Mutex<Vec<(Option<String>, PluginEvent)>>>;
/// What plugins see of the test project: real `Documents`, as in the IDE.
struct TestWorkspace {
root: PathBuf,
other_roots: Mutex<Vec<PathBuf>>,
documents: Arc<ide_workspace::Documents>,
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<PathBuf> {
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<String> {
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"));
}
+1 -1
Переглянути файл
@@ -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;
+16 -1
Переглянути файл
@@ -110,6 +110,17 @@ pub trait HostApi: Send + Sync {
}
/// Roots of the open projects.
fn projects(&self) -> Vec<std::path::PathBuf>;
/// The most specific open project containing a document (including a new file).
fn project_of(&self, path: &Path) -> Option<std::path::PathBuf> {
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<String>;
/// Version and text of the open document at `path`.
@@ -166,7 +177,11 @@ pub trait HostApi: Send + Sync {
cancel: &CancellationToken,
timeout: Duration,
) -> Result<ProcessOutput, HostError>;
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<String>) -> 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<u32>, message: Option<String>);
+1 -1
Переглянути файл
@@ -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 {
+13 -4
Переглянути файл
@@ -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<Option<Resource<ProjectRes>>> {
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
}
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
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<String>) -> 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(())
}
}
+1 -1
Переглянути файл
@@ -1 +1 @@
f81a6b838d4bee81a058f79436a012f75f55a0c80ad1f10f0af7bd2aba9c32c6
627be971ecc31efd268258eff214ed08c671c2a158677b773de9d5be462b17c4
+1 -1
Переглянути файл
@@ -1 +1 @@
344660862897f17d7b8f7cdb1397f715201abeab3fc8d7ba1b62c82ca0d9e60b
56683bb1e102025801610a2d61ce393f03bde9a3e5b5e52dde2ff272b3f10662
+1 -1
Переглянути файл
@@ -1 +1 @@
47c52d929c61fc852f232c0ba5eb798bbdbc9497797cd7f454b32969779d669b
d6a10bd1a97e7da44338fc9a28f5a64054c9269f553003298f816bb114f42a88
+1 -1
Переглянути файл
@@ -1 +1 @@
c8b3c8b074c765a81f8d1096ffe36cd8b93999096b9b77959a4d469e5f6e25f6
21d7a4cf9348568b7cb2b565c6e26a727165319bd685147fcedd3a8465565990
+1 -1
Переглянути файл
@@ -1 +1 @@
58090f3c530ce4e736b7d182e73d1eaaee7f8db65f76eed0481a00f50828896a
636846f46f27ef901fde5c8767991fdc1060c86d7f90a12b0cb08933a2e60b37
+1 -1
Переглянути файл
@@ -1 +1 @@
56b93b6a70821331e1bc835b64b4273d7a2989c66b6623e6a8d097464375e9b3
a35dfaecf8d76d3ca95c80e810be26781671f58a48b50374e2656cdd6959cdf2
+1 -1
Переглянути файл
@@ -1 +1 @@
ec5a77e097fea4962ffa8227c0bfb247e61d360c5da983c7dabfc84159bf457a
a8b92a0ee9b46d8d414457ff287d6092e85d4536e913d146aa49662814ff1836
+1 -1
Переглянути файл
@@ -1 +1 @@
89291c6203a966a5163358ffd64630874d014fe239d7e9747890ebbd8f686540
2fa3d6146a99eee79d4aec12bf8777369ae5cc76c3f7d6a8c254d5a27d58eeb9
+333
Переглянути файл
@@ -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"
+20
Переглянути файл
@@ -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"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
c14d91010021e3b35f6ae29a2a327bb3ad66087f07cfd744814443862c0050cc
+34
Переглянути файл
@@ -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"
+47
Переглянути файл
@@ -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<Option<String>, 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::<Vec<_>>())?;
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::<usize>().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<Hover> {
Some(Hover::new(host::project_of(&doc.path)?.root().ok()?))
}
}
register_plugin!(Fixture, hover);
+1 -1
Переглянути файл
@@ -1 +1 @@
284efc25ade647bd1944024a04a944d63cbc69d8647cc9eff95eb4add0d8509c
e9b6c10973ce62057eb3c92a747bcb27fdef1cfb6dd1d8681aa0035d3a3b5fa2
+1 -1
Переглянути файл
@@ -1 +1 @@
243ac921ab396b2fc2735bb8552394d90246080264143f1205c59f184db4b504
a873a709240a3d24c45481b220ac283103f10645166952cb5dcc809f7995e085
+1 -1
Переглянути файл
@@ -1 +1 @@
4d753f6d72e25afee529684219a0c69fcadd33c53a377d503343fb6532ac8979
8801a468856e0e4783848f89d51d6d6f45426e6cadf2b2532518500ff23aa6f7
+21
Переглянути файл
@@ -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
+1 -1
Переглянути файл
@@ -1 +1 @@
10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316
d46e07036f0f59c18ca54affbca6446c320476998f3e92199cd93ed43dd725d9
+1 -1
Переглянути файл
@@ -1 +1 @@
9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf
c28b0da5a21fc0fc5dd543d01eaeeed4f7bbd89a5d28b95ce911a1c092209adc
+1 -1
Переглянути файл
@@ -1 +1 @@
2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a
f4258172c7a8278d00f5da9d76420380200d8b0d8bc387ceafab30703cbdac4a
+1 -1
Переглянути файл
@@ -1 +1 @@
c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696
648b030335ecfdf82651561fc5e71edc9f9d4319ae4bfb56d1e6d7e1f029d781
+1 -1
Переглянути файл
@@ -1 +1 @@
65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892
f7bbe6097f18377a79f39ba2369140e24eff65501e78be94e3420c6667362743
+1 -1
Переглянути файл
@@ -1 +1 @@
d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852
936d6e2c74f87cc1d95c52ea674716d934df917c12fb8431989f8dc64989b174
+1 -1
Переглянути файл
@@ -1 +1 @@
d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809
182af35d880a9adfa2566a42b62db6cf69f7d2a5157e0428228b649864e9417c
+1 -1
Переглянути файл
@@ -1 +1 @@
a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a
f750b060f9a4217da81395aaa9875811d61d087e8d7fef1a0d7b46e3d08cd4a8
+1 -1
Переглянути файл
@@ -1 +1 @@
6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268
4edc8aad4feb31205d567e303a18f9d0d4969bc65dc8f3699b46cd05b93d8868
+1 -1
Переглянути файл
@@ -1 +1 @@
15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949
e1e58cd1ee112923dfc1debe86f8feb18c1a4e9f58490b5e371ee0272cad6ced
+1 -1
Переглянути файл
@@ -1 +1 @@
11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb
318e264055acfd1d10491dfea784fdb2a034d3b4da8f7583e81720e21ea1947c
+21
Переглянути файл
@@ -182,6 +182,10 @@ mod imp {
wit::workspace::projects().map_err(error)
}
pub fn project_of(path: &str) -> Option<ProjectRes> {
wit::workspace::project_of(path)
}
pub fn project_root(p: &ProjectRes) -> Result<String, Error> {
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::<Vec<_>>()).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<Vec<Project>, 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<Project> {
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`).
+95 -1
Переглянути файл
@@ -72,6 +72,7 @@ struct State {
context: BTreeMap<String, ContextValue>,
storage: BTreeMap<String, String>,
subscriptions: BTreeSet<EventKind>,
path_subscriptions: BTreeMap<String, Vec<String>>,
tasks: Vec<MockTask>,
vcs: Option<RepositoryStatus>,
remotes: Vec<(String, String)>,
@@ -230,6 +231,10 @@ impl MockHost {
self.state.borrow().storage.clone()
}
pub fn path_subscriptions(&self) -> BTreeMap<String, Vec<String>> {
self.state.borrow().path_subscriptions.clone()
}
pub fn subscriptions(&self) -> BTreeSet<EventKind> {
self.state.borrow().subscriptions.clone()
}
@@ -613,6 +618,27 @@ pub mod current {
})
}
pub fn project_of(path: &str) -> Option<ProjectRes> {
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<String, Error> {
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<Vec<String>, 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();
+4
Переглянути файл
@@ -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<string>) -> result<_, error>;
unsubscribe: func(kind: string);
}
+4
Переглянути файл
@@ -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<project>;
/// What a path inside a project is.
enum file-kind {
file,
+4
Переглянути файл
@@ -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<string>) -> result<_, error>;
unsubscribe: func(kind: string);
}
+4
Переглянути файл
@@ -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<project>;
/// What a path inside a project is.
enum file-kind {
file,