diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 56147fcf5..2244cd7a4 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -223,6 +223,7 @@ impl Host { self.cancel_request(request); } let project = self.projects.write().remove(window)?; + project.plugin_diagnostics.close(); if let Some(plugins) = plugins::editor_plugin_host() { plugins.invalidate_client_navigation(&project.root, Some(window), None); plugins.set_active_file(window, None); @@ -373,6 +374,7 @@ impl Host { self.terminals.kill_all(); let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect(); for project in &projects { + project.plugin_diagnostics.close(); if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } diff --git a/crates/ide-host/src/plugins/diagnostics.rs b/crates/ide-host/src/plugins/diagnostics.rs new file mode 100644 index 000000000..14470f56a --- /dev/null +++ b/crates/ide-host/src/plugins/diagnostics.rs @@ -0,0 +1,245 @@ +//! Versioned, project-scoped diagnostics for unsaved editor buffers. +use super::{PluginHost, ProviderKind, editor_plugin_host}; +use ide_core::{CancellationToken, Diagnostic, DocId, IdeEvent, SharedSink}; +use ide_plugin::ProviderDocument; +use ide_text::AppliedChange; +use ide_workspace::documents::DocumentObserver; +use ide_workspace::{DocumentInfo, Documents}; +use parking_lot::Mutex; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, Weak}; +use std::time::Duration; + +const DEBOUNCE: Duration = Duration::from_millis(300); + +struct Document { + snapshot: ProviderDocument, + cancel: CancellationToken, + sources: HashSet, +} + +#[derive(Default)] +pub(super) struct State { + closed: bool, + documents: HashMap, + generations: HashMap, +} + +/// One incarnation of one project's documents, shared by all its editors. +pub(crate) struct Diagnostics { + state: Arc>, + sink: SharedSink, + runtime: tokio::runtime::Handle, + documents: Weak, + root: std::path::PathBuf, +} + +impl Diagnostics { + pub fn new( + sink: SharedSink, + runtime: tokio::runtime::Handle, + documents: &Arc, + root: &Path, + ) -> Arc { + Arc::new(Self { + state: Arc::default(), + sink, + runtime, + documents: Arc::downgrade(documents), + root: root.to_path_buf(), + }) + } + + fn emit(&self, path: &Path, plugin: &str, items: Vec) { + self.sink.emit(IdeEvent::Diagnostics { + path: path.to_path_buf().into(), + source: format!("plugin-provider:{plugin}"), + items, + }); + } + + fn clear_document(&self, doc: &Document) { + doc.cancel.cancel(); + for plugin in &doc.sources { + self.emit(&doc.snapshot.path, plugin, Vec::new()); + } + } + + pub fn close(&self) { + let mut state = self.state.lock(); + state.closed = true; + for (_, doc) in state.documents.drain() { + self.clear_document(&doc); + } + } + + fn schedule(&self, id: DocId, state: &mut State) { + let Some(doc) = state.documents.get_mut(&id) else { return }; + doc.cancel.cancel(); + doc.cancel = CancellationToken::new(); + let Some(host) = editor_plugin_host() else { return }; + if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() { + return; + } + // Register only once per project incarnation; no project is retained by the host. + let weak = Arc::downgrade(&self.state); + { + let mut projects = host.diagnostic_projects.lock(); + projects.retain(|(state, _)| state.strong_count() > 0); + if !projects.iter().any(|(state, _)| state.ptr_eq(&weak)) { + projects.push((weak.clone(), self.sink.clone())); + } + } + let root = self.root.clone(); + let (mut snapshot, cancel, sink, documents) = + (doc.snapshot.clone(), doc.cancel.clone(), self.sink.clone(), self.documents.clone()); + let lifecycle_generations = state.generations.clone(); + self.runtime.spawn(async move { + tokio::time::sleep(DEBOUNCE).await; + if cancel.is_cancelled() || weak.strong_count() == 0 { + return; + } + let _ = tokio::task::spawn_blocking(move || { + let Some(documents) = documents.upgrade() else { return }; + let Ok(current) = documents.snapshot(id) else { return }; + if u64::from(current.version) != snapshot.version + || current.path.0 != snapshot.path + || current.language.as_str() != snapshot.language + || cancel.is_cancelled() + { + return; + } + snapshot.text = current.text; + drop(documents); + + let generations = host.providers.diagnostic_generations(&snapshot.language); + let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES { + host.provide_project_diagnostics(Some(&root), &snapshot, &cancel) + } else { + Vec::new() + }; + let Some(state) = weak.upgrade() else { return }; + let mut state = state.lock(); + if state.closed || cancel.is_cancelled() { + return; + } + let matching: HashSet<_> = host + .diagnostic_plugins(Some(&root), &snapshot.language) + .into_iter() + .map(|p| p.id().to_owned()) + .filter(|p| lifecycle_generations.get(p) == state.generations.get(p)) + .collect(); + let latest = host.providers.diagnostic_generations(&snapshot.language); + let Some(doc) = state.documents.get_mut(&id) else { return }; + if doc.snapshot.version != snapshot.version || doc.snapshot.path != snapshot.path { + return; + } + let mut current = HashSet::new(); + for (plugin, items) in results { + if !matching.contains(&plugin) + || !generations.get(&plugin).is_some_and(|seq| latest.get(&plugin) == Some(seq)) + { + continue; + } + current.insert(plugin.clone()); + sink.emit(IdeEvent::Diagnostics { + path: snapshot.path.clone().into(), + source: format!("plugin-provider:{plugin}"), + items, + }); + } + for plugin in doc.sources.difference(¤t) { + sink.emit(IdeEvent::Diagnostics { + path: snapshot.path.clone().into(), + source: format!("plugin-provider:{plugin}"), + items: Vec::new(), + }); + } + doc.sources = current; + }) + .await; + }); + } +} + +impl DocumentObserver for Diagnostics { + fn opened(&self, info: &DocumentInfo) { + let mut state = self.state.lock(); + if state.closed { + return; + } + if let Some(old) = state.documents.remove(&info.id) { + self.clear_document(&old); + } + if info.large || info.uri.is_some() { + return; + } + state.documents.insert( + info.id, + Document { + snapshot: ProviderDocument { + path: info.path.0.clone(), + language: info.language.to_string(), + version: u64::from(info.version), + text: String::new(), + }, + cancel: CancellationToken::new(), + sources: HashSet::new(), + }, + ); + self.schedule(info.id, &mut state); + } + + fn changed(&self, id: DocId, path: &Path, version: u32, _: &[AppliedChange], _text: &dyn Fn() -> String) { + let mut state = self.state.lock(); + if state.closed { + return; + } + let Some(doc) = state.documents.get_mut(&id) else { return }; + if doc.snapshot.path != path { + self.clear_document(doc); + doc.sources.clear(); + } + doc.snapshot.path = path.to_path_buf(); + doc.snapshot.version = u64::from(version); + self.schedule(id, &mut state); + } + + fn closed(&self, id: DocId, _: &Path) { + if let Some(doc) = self.state.lock().documents.remove(&id) { + self.clear_document(&doc); + } + } +} + +impl Drop for Diagnostics { + fn drop(&mut self) { + self.close(); + } +} + +pub(super) type ProjectState = (Weak>, SharedSink); + +impl PluginHost { + pub(super) fn clear_provider_diagnostics(&self, plugin: &str) { + // Clone before taking a project's state lock (edits register in the other order). + let projects = self.diagnostic_projects.lock().clone(); + for (state, sink) in projects { + if let Some(state) = state.upgrade() { + let mut state = state.lock(); + // A completed answer can still be waiting for another provider. + *state.generations.entry(plugin.to_owned()).or_default() += 1; + for doc in state.documents.values_mut() { + if doc.sources.remove(plugin) { + sink.emit(IdeEvent::Diagnostics { + path: doc.snapshot.path.clone().into(), + source: format!("plugin-provider:{plugin}"), + items: Vec::new(), + }); + } + } + } + } + } +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index e83aadb03..ad113fd45 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -17,6 +17,7 @@ mod api; pub mod commit; +pub(crate) mod diagnostics; pub mod endpoints; mod event_paths; mod hover; @@ -327,6 +328,7 @@ pub struct PluginHost { navigation: Mutex, endpoints: Mutex, web_ptys: Mutex>>, + diagnostic_projects: Mutex>, /// Validated UI declarations of installed packages (start-up reuses them). index: Arc, ledger: Mutex, @@ -419,6 +421,7 @@ impl PluginHost { navigation: Mutex::default(), endpoints: Mutex::default(), web_ptys: Mutex::default(), + diagnostic_projects: Mutex::default(), index, plugins: RwLock::default(), state: Mutex::new(state), @@ -1942,6 +1945,7 @@ impl PluginHost { /// whose activation event already fired. fn start(&self, plugin: &Arc) { self.registry.dispose_plugin(plugin.id()); + self.clear_provider_diagnostics(plugin.id()); if plugin.source == PluginSource::Installed && let Some(reason) = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir()) { @@ -2124,6 +2128,7 @@ impl PluginHost { /// A trap dropped the plugin's instance: restart it on next use after a /// backoff, or stop it for the session after too many crashes in a row. fn crashed(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { + self.clear_provider_diagnostics(plugin.id()); let failures = { let mut meta = plugin.meta.lock(); meta.failures += 1; @@ -2191,6 +2196,7 @@ impl PluginHost { actor.stop(true); } self.registry.dispose_plugin(plugin.id()); + self.clear_provider_diagnostics(plugin.id()); self.shared.clear_plugin(plugin.id(), true); self.queues.lock().retain(|(id, _), _| id != plugin.id()); } @@ -2200,6 +2206,7 @@ impl PluginHost { self.clear_plugin_endpoints(plugin.id()); tracing::warn!(plugin = %plugin.id(), "{error}"); self.registry.dispose_plugin(plugin.id()); + self.clear_provider_diagnostics(plugin.id()); let mut meta = plugin.meta.lock(); meta.status = PluginStatus::Failed; meta.error = Some(error); @@ -2568,6 +2575,70 @@ impl ide_language::ExternalSyntax for PluginSyntax { mod tests { use super::*; + #[test] + fn host_panics_stop_actor_and_reply_on_every_call_path() { + let tmp = tempfile::tempdir().unwrap(); + let host = + PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics"); + host.install_from_dir(&fixture, &Consent::all()).unwrap(); + let plugin = host.get("test.diagnostics").unwrap(); + for mode in 0..5 { + let (actor, _) = host.ensure_active(&plugin, None).unwrap(); + let cancel = ide_core::CancellationToken::new(); + let next_actor = actor.clone(); + let (queued_tx, queued_rx) = std::sync::mpsc::channel(); + let panic = move |_: &mut Runtime| -> Result<(), CallError> { + let pending = next_actor.submit(None, |_| Ok("must not run")).unwrap(); + queued_tx.send(pending).unwrap(); + panic!("host call panic") + }; + let result = match mode { + 0 => actor.call(None, panic), + 1 => actor.call_cancellable(&cancel, Duration::ZERO, panic), + 2 => actor.call_edit_cancellable(&cancel, Duration::ZERO, Duration::from_secs(5), panic), + _ => { + let (tx, rx) = std::sync::mpsc::channel(); + if mode == 3 { + actor.post(panic, move |result| { + let _ = tx.send(result); + }); + } else { + actor.post( + |_| Ok(()), + move |result| { + let _ = tx.send(result); + panic!("completion callback panic"); + }, + ); + } + rx.recv_timeout(Duration::from_secs(5)).expect("actor must reply") + } + }; + if mode < 4 { + let queued = queued_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(matches!(queued.wait(), Err(ActorError::Stopped)), "poisoned runtime must not run queued work"); + } + actor.stop(true); + if mode < 4 { + assert!( + matches!(result, Err(ActorError::Call(CallError::Trap(ref message))) if message.contains("host call panic")), + "{result:?}" + ); + } + assert!(!actor.is_alive()); + assert!(!actor.is_busy()); + assert!(matches!(actor.call(None, |_| Ok(())), Err(ActorError::Stopped))); + let crash = actor.take_crash().expect("supervisor must see the panic"); + assert!(crash.contains(if mode == 4 { "completion callback panic" } else { "host call panic" })); + assert!(actor.take_crash().is_none()); + cancel.cancel(); + // Re-enable to reset the supervisor before testing the next call path. + host.set_enabled("test.diagnostics", false).unwrap(); + host.set_enabled("test.diagnostics", true).unwrap(); + } + } + #[test] fn globs() { assert!(glob_match("**/*Mapper.xml", "src/main/UserMapper.xml")); diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs index ea7ceacd3..ad1d590f9 100644 --- a/crates/ide-host/src/plugins/providers.rs +++ b/crates/ide-host/src/plugins/providers.rs @@ -22,6 +22,7 @@ use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id}; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -40,6 +41,7 @@ pub enum ProviderKind { SemanticTokens, Hover, InlayHints, + Diagnostics, } #[derive(Clone, Debug, Deserialize)] @@ -73,13 +75,26 @@ impl Providers { /// Plugins providing `kind` for `language`, in declaration order. pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec { - let mut out: Vec = Vec::new(); - for (_, plugin, kinds, languages) in self.items.read().iter() { - if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) { - out.push(plugin.clone()); - } - } - out + self.matching::>(kind, language).into_iter().map(|(plugin, _)| plugin).collect() + } + + /// One coherent declaration snapshot, preserving the first match per plugin. + fn matching>(&self, kind: ProviderKind, language: &str) -> T { + let items = self.items.read(); + let mut seen = HashSet::new(); + items + .iter() + .filter(|(_, plugin, kinds, languages)| { + kinds.contains(&kind) + && languages.iter().any(|l| l == "*" || l == language) + && seen.insert(plugin.as_str()) + }) + .map(|(seq, plugin, ..)| (plugin.clone(), *seq)) + .collect() + } + + pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap { + self.matching(ProviderKind::Diagnostics, language) } /// Whether any plugin provides `kind` (for any language). @@ -118,6 +133,9 @@ impl ExtensionPoint for ProvidersPoint { if d.kinds.contains(&ProviderKind::SemanticTokens) { pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?; } + if d.kinds.contains(&ProviderKind::Diagnostics) { + pkg.manifest.needs_api("contributes.providers: diagnostics", 3)?; + } if d.kinds.contains(&ProviderKind::Hover) { pkg.manifest.needs_api("contributes.providers: hover", 3)?; } @@ -202,7 +220,7 @@ pub(crate) fn editor_plugin_host() -> Option> { impl PluginHost { /// Usable plugins declaring `kind` for `language`. - fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec> { + pub(super) fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec> { self.providers .plugins_for(kind, language) .into_iter() @@ -360,6 +378,59 @@ impl PluginHost { .collect() } + pub fn provide_diagnostics( + &self, + doc: &ProviderDocument, + cancel: &CancellationToken, + ) -> Vec<(String, Vec)> { + self.provide_project_diagnostics(None, doc, cancel) + } + + pub(super) fn diagnostic_plugins( + &self, + project: Option<&std::path::Path>, + language: &str, + ) -> Vec> { + self.provider_plugins(ProviderKind::Diagnostics, language) + .into_iter() + .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) + .collect() + } + + pub(super) fn provide_project_diagnostics( + &self, + project: Option<&std::path::Path>, + doc: &ProviderDocument, + cancel: &CancellationToken, + ) -> Vec<(String, Vec)> { + std::thread::scope(|scope| { + let calls: Vec<_> = self + .diagnostic_plugins(project, &doc.language) + .into_iter() + .map(|p| { + scope.spawn(move || { + if cancel.is_cancelled() { + return None; + } + let (actor, activated) = self.ensure_active(&p, None).ok()?; + if activated { + self.changed(); + } + let (d, timeout) = (doc.clone(), self.limits.edit_hard); + let started = Instant::now(); + let result = + actor.call_edit_cancellable(cancel, CANCEL_GRACE, self.limits.edit_soft, move |rt| { + rt.provide_diagnostics(&d, timeout) + }); + let items = self.finish_provider_call(&p, &actor, started, result)?; + (!cancel.is_cancelled()).then(|| (p.id().to_owned(), items)) + }) + }) + .collect(); + calls.into_iter().filter_map(|call| call.join().ok().flatten()).collect() + }) + } + pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec { let d = Arc::new(doc.clone()); self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout)) @@ -406,3 +477,41 @@ impl PluginHost { None } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_snapshots_preserve_order_first_generation_and_language_matching() { + let providers = Arc::new(Providers::default()); + let declare = |plugin: &str, kind, language: &str| { + providers.add(plugin.into(), Decl { kinds: vec![kind], languages: vec![language.into()] }) + }; + let mut declarations = Vec::new(); + declarations.push(declare("unrelated", ProviderKind::Hover, "*")); + declarations.push(declare("other-language", ProviderKind::Diagnostics, "rust")); + for i in 0..256 { + let plugin = format!("plugin-{i}"); + declarations.push(declare(&plugin, ProviderKind::Diagnostics, "text")); + declarations.push(declare(&plugin, ProviderKind::Diagnostics, "*")); + } + let expected: Vec<_> = (0..256).map(|i| format!("plugin-{i}")).collect(); + assert_eq!(providers.plugins_for(ProviderKind::Diagnostics, "text"), expected); + let before = providers.diagnostic_generations("text"); + assert_eq!(before.len(), 256); + assert_eq!(before["plugin-0"], 2); + assert_eq!(providers.diagnostic_generations("rust").len(), 257); + assert_eq!(providers.plugins_for(ProviderKind::Hover, "text"), ["unrelated"]); + // Removing the first matching declaration must invalidate its generation, + // even while a second declaration still provides the same language. + drop(declarations.remove(2)); + let after = providers.diagnostic_generations("text"); + assert_eq!(after["plugin-0"], 3); + assert_ne!(before["plugin-0"], after["plugin-0"]); + drop(declarations); + assert!(providers.diagnostic_generations("text").is_empty()); + let _reloaded = declare("plugin-0", ProviderKind::Diagnostics, "text"); + assert_ne!(providers.diagnostic_generations("text")["plugin-0"], after["plugin-0"]); + } +} diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 92e1485e2..f99b4ff5e 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -27,6 +27,7 @@ pub struct Project { pub languages: Arc, pub settings: Arc, pub documents: Arc, + pub(crate) plugin_diagnostics: Arc, pub history: Arc, /// Undo stack of file move / rename refactorings. pub moves: FileMoves, @@ -519,7 +520,11 @@ impl Host { sink.clone(), ); ide_agent::registry::maintain(crate::ops::agent::registry_paths(self)); + let plugin_diagnostics = + crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &root); + documents.add_observer(plugin_diagnostics.clone()); let project = Arc::new(Project { + plugin_diagnostics, trusted, root, sink, @@ -665,7 +670,11 @@ impl Host { self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), sink.clone(), ); + let plugin_diagnostics = + crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &dir); + documents.add_observer(plugin_diagnostics.clone()); let project = Arc::new(Project { + plugin_diagnostics, trusted: Arc::new(AtomicBool::new(false)), sink, languages, diff --git a/crates/ide-host/tests/plugin_diagnostics.rs b/crates/ide-host/tests/plugin_diagnostics.rs new file mode 100644 index 000000000..379296785 --- /dev/null +++ b/crates/ide-host/tests/plugin_diagnostics.rs @@ -0,0 +1,354 @@ +//! Real Wasm provider dispatch and the document-edit path used locally and over RPC. +use ide_core::{CancellationToken, ChangeSet, Diagnostic, IdeEvent, Pos, Range, TextChange}; +use ide_host::plugins::{Consent, HostWorkspace, PluginHost, set_editor_providers}; +use ide_host::{Host, HostConfig, ops}; +use ide_plugin::{Invocation, Limits, ProviderDocument}; +use parking_lot::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const ID: &str = "test.diagnostics"; +const SOURCE: &str = "plugin-provider:test.diagnostics"; + +fn fixture() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics") +} + +fn plugins(dir: &Path, limits: Limits) -> Arc { + let host = Arc::new(PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()); + host.install_from_dir(&fixture(), &Consent::all()).unwrap(); + host +} + +fn generous() -> Limits { + Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() } +} + +fn doc(root: &Path, language: &str, text: &str) -> ProviderDocument { + ProviderDocument { path: root.join("a.txt"), language: language.into(), version: 42, text: text.into() } +} + +#[test] +fn declared_languages_receive_versioned_text_and_utf16_ranges() { + let tmp = tempfile::tempdir().unwrap(); + let host = plugins(tmp.path(), generous()); + let result = host.provide_diagnostics(&doc(tmp.path(), "text", "😀BAD\nBAD"), &CancellationToken::new()); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, ID); + let items = &result[0].1; + assert_eq!(items.len(), 2); + assert_eq!(items[0].range, Range::new(Pos::new(0, 2), Pos::new(0, 5))); + assert_eq!(items[1].range, Range::new(Pos::new(1, 0), Pos::new(1, 3))); + assert_eq!(items[0].message, "BAD in text at version 42"); + assert_eq!(items[0].code.as_deref(), Some("bad-word")); + assert_eq!(items[0].server.as_deref(), Some(ID)); + assert!(host.provide_diagnostics(&doc(tmp.path(), "rust", "BAD"), &CancellationToken::new()).is_empty()); +} + +#[test] +fn provider_diagnostics_have_the_same_bounds_and_tags_as_manual_publish() { + let tmp = tempfile::tempdir().unwrap(); + let host = plugins(tmp.path(), generous()); + let result = host.provide_diagnostics(&doc(tmp.path(), "text", "limits"), &CancellationToken::new()); + let items = &result[0].1; + assert_eq!(items.len(), 5000); + assert_eq!(items[0].message.len(), 4096); + assert_eq!(items[0].source.as_ref().unwrap().len(), 128); + assert_eq!(items[0].code.as_ref().unwrap().len(), 128); + assert!(items[0].unnecessary && items[0].deprecated); +} + +#[test] +fn the_export_does_not_need_project_read_permission() { + let tmp = tempfile::tempdir().unwrap(); + let package = tmp.path().join("no-capability"); + std::fs::create_dir_all(package.join("dist")).unwrap(); + std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap(); + let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap(); + std::fs::write(package.join("plugin.toml"), manifest.replace("[capabilities]\nrequired = [\"project.read\"]", "")) + .unwrap(); + let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap(); + host.install_from_dir(&package, &Consent::default()).unwrap(); + assert_eq!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).len(), 1); + assert!(host.run_command(ID, "publish", Some("/project/a.txt"), Invocation::default()).is_err()); +} + +#[test] +fn diagnostics_is_an_api_1_3_addition() { + let tmp = tempfile::tempdir().unwrap(); + let package = tmp.path().join("old"); + std::fs::create_dir_all(package.join("dist")).unwrap(); + std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap(); + let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap(); + std::fs::write(package.join("plugin.toml"), manifest.replace("^1.3", "^1.0")).unwrap(); + let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap(); + assert!(host.install_from_dir(&package, &Consent::all()).unwrap_err().message.contains("new in plugin API 1.3")); +} + +#[test] +fn late_and_cancelled_answers_are_dropped_without_disabling_the_plugin() { + let tmp = tempfile::tempdir().unwrap(); + let host = plugins(tmp.path(), Limits { edit_soft: Duration::from_millis(10), ..generous() }); + assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "slow BAD"), &CancellationToken::new()).is_empty()); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty()); + assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("1")); +} + +#[test] +fn cancelling_a_queued_provider_does_not_interrupt_another_windows_command() { + let tmp = tempfile::tempdir().unwrap(); + let host = plugins(tmp.path(), generous()); + host.run_command(ID, "calls", None, Invocation::default()).unwrap(); + let waiter = { + let host = host.clone(); + std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default())) + }; + std::thread::sleep(Duration::from_millis(50)); + let cancel = CancellationToken::new(); + let canceller = { + let cancel = cancel.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(30)); + cancel.cancel(); + }) + }; + assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty()); + canceller.join().unwrap(); + assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished")); + assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0")); +} + +#[test] +fn editor_requests_expire_in_the_queue_without_running_after_a_command() { + let tmp = tempfile::tempdir().unwrap(); + let host = plugins(tmp.path(), Limits { edit_soft: Duration::from_millis(10), ..generous() }); + host.run_command(ID, "calls", None, Invocation::default()).unwrap(); + let waiter = { + let host = host.clone(); + std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default())) + }; + std::thread::sleep(Duration::from_millis(50)); + assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).is_empty()); + assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished")); + assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0")); + assert_eq!(host.info_of(ID).unwrap().stats.slow_calls, 1); +} + +#[test] +fn typing_on_cancels_a_running_provider_before_its_hard_deadline() { + let tmp = tempfile::tempdir().unwrap(); + let host = plugins(tmp.path(), Limits { fuel: u64::MAX, ..generous() }); + host.run_command(ID, "calls", None, Invocation::default()).unwrap(); + let cancel = CancellationToken::new(); + let canceller = { + let cancel = cancel.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(30)); + cancel.cancel(); + }) + }; + let started = Instant::now(); + assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "spin BAD"), &cancel).is_empty()); + assert!(started.elapsed() < Duration::from_secs(2)); + canceller.join().unwrap(); +} + +// This executable has one edit-path test because editor providers are installed once per process. +type Events = Arc>>; + +fn runtime_host(base: &Path, events: Events) -> Arc { + Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |window| { + let (events, window) = (events.clone(), window.to_owned()); + Arc::new(move |event: IdeEvent| events.lock().push((window.clone(), event))) + }), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })) +} + +fn latest(events: &Events, window: &str, source: &str) -> Option> { + events.lock().iter().rev().find_map(|(w, e)| match e { + IdeEvent::Diagnostics { source: s, items, .. } if w == window && s == source => Some(items.clone()), + _ => None, + }) +} + +async fn wait_for(events: &Events, window: &str, predicate: impl Fn(&[Diagnostic]) -> bool) { + wait_for_source(events, window, SOURCE, predicate).await; +} + +async fn wait_for_source(events: &Events, window: &str, source: &str, predicate: impl Fn(&[Diagnostic]) -> bool) { + let start = Instant::now(); + loop { + if latest(events, window, source).is_some_and(|items| predicate(&items)) { + return; + } + assert!(start.elapsed() < Duration::from_secs(5), "missing diagnostics for {window}"); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +fn replace(host: &Host, window: &str, info: &ide_workspace::DocumentInfo, text: &str) -> u32 { + let project = host.project(window).unwrap(); + let snapshot = project.documents.snapshot(info.id).unwrap(); + let len = snapshot.text.encode_utf16().count() as u32; + ops::doc::edit( + host, + window, + info.id, + snapshot.version, + ChangeSet { changes: vec![TextChange { from: 0, to: len, insert: text.into() }] }, + ) + .unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window_lifetimes() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + let path = root.join("a.txt"); + std::fs::write(&path, "clean").unwrap(); + let events: Events = Arc::default(); + let host = runtime_host(&root.join("app"), events.clone()); + let plugins = plugins(&root, generous()); + plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host))))); + set_editor_providers(Some(&plugins)); + let left = host.open_light("left", &path).unwrap(); + host.open_light("right", &path).unwrap(); + let a = ops::doc::open(&host, "left", path.clone()).await.unwrap(); + let b = ops::doc::open(&host, "right", path.clone()).await.unwrap(); + let second_tab = ops::doc::open(&host, "right", path.clone()).await.unwrap(); + assert_eq!(b.id, second_tab.id); + wait_for(&events, "left", |items| items.is_empty()).await; + wait_for(&events, "right", |items| items.is_empty()).await; + + let calls: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap(); + events.lock().clear(); + replace(&host, "left", &a, "BAD"); + tokio::time::sleep(Duration::from_millis(100)).await; + let version = replace(&host, "left", &a, "😀BAD"); + tokio::time::sleep(Duration::from_millis(150)).await; + assert!(latest(&events, "left", SOURCE).is_none(), "300ms after the latest edit"); + wait_for(&events, "left", |items| !items.is_empty()).await; + assert_eq!( + latest(&events, "left", SOURCE).unwrap()[0].message, + format!("BAD in {} at version {version}", a.language) + ); + assert!(latest(&events, "right", SOURCE).is_none(), "other window has its own buffer"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "clean", "no save required"); + let after: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap(); + assert_eq!(after, calls + 1, "the edit burst was coalesced"); + + plugins.run_command(ID, "publish", path.to_str(), Invocation::default()).unwrap(); + let manual_window = events.lock().iter().find_map(|(window, event)| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "plugin:test.diagnostics" && !items.is_empty()).then(|| window.clone())).unwrap(); + assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem"); + replace(&host, "left", &a, "clean again"); + wait_for(&events, "left", |items| items.is_empty()).await; + assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem"); + + events.lock().clear(); + replace(&host, "left", &a, "slow BAD"); + tokio::time::sleep(Duration::from_millis(350)).await; + replace(&host, "left", &a, "latest clean"); + wait_for(&events, "left", |items| items.is_empty()).await; + assert!( + !events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())), + "superseded in-flight answers never publish" + ); + + replace(&host, "right", &b, "BAD right"); + wait_for(&events, "right", |items| !items.is_empty()).await; + ops::doc::close(&host, "right", b.id).unwrap(); + assert!(!latest(&events, "right", SOURCE).unwrap().is_empty(), "another pane still owns this document"); + ops::doc::close(&host, "right", second_tab.id).unwrap(); + wait_for(&events, "right", |items| items.is_empty()).await; + replace(&host, "left", &a, "BAD left"); + wait_for(&events, "left", |items| !items.is_empty()).await; + plugins.set_enabled(ID, false).unwrap(); + wait_for(&events, "left", |items| items.is_empty()).await; + plugins.set_enabled(ID, true).unwrap(); + + events.lock().clear(); + replace(&host, "left", &a, "slow BAD closing"); + tokio::time::sleep(Duration::from_millis(350)).await; + host.close_project("left"); + // Keep the old Project alive deliberately: window reuse must still cancel it. + assert_eq!(left.documents.snapshot(a.id).unwrap().text, "slow BAD closing"); + host.open_light("left", &path).unwrap(); + let reopened = ops::doc::open(&host, "left", path.clone()).await.unwrap(); + wait_for(&events, "left", |items| items.is_empty()).await; + assert!(!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty()))); + // Project plugins receive only snapshots from the project that owns their declaration. + let project_package = root.join(ide_host::plugins::DEV_PLUGINS_DIR).join("diagnostics"); + std::fs::create_dir_all(project_package.join("dist")).unwrap(); + std::fs::copy(fixture().join("dist/plugin.wasm"), project_package.join("dist/plugin.wasm")).unwrap(); + let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap(); + std::fs::write(project_package.join("plugin.toml"), manifest.replace(ID, "project.diagnostics")).unwrap(); + plugins.set_project_policy(Arc::new(|_| true)); + plugins.sync_project(&root); + plugins.grant("project.diagnostics", &Consent::all()).unwrap(); + replace(&host, "left", &reopened, "BAD in project"); + wait_for(&events, "left", |items| !items.is_empty()).await; + let project_source = "plugin-provider:project.diagnostics"; + wait_for_source(&events, "left", project_source, |items| !items.is_empty()).await; + let elsewhere = root.join("other"); + std::fs::create_dir_all(&elsewhere).unwrap(); + let other_path = elsewhere.join("other.txt"); + std::fs::write(&other_path, "BAD outside").unwrap(); + host.open_light("elsewhere", &other_path).unwrap(); + ops::doc::open(&host, "elsewhere", other_path).await.unwrap(); + wait_for(&events, "elsewhere", |items| !items.is_empty()).await; + assert!(latest(&events, "elsewhere", project_source).is_none()); + plugins.restrict_project(&root); + assert!(latest(&events, "left", project_source).unwrap().is_empty()); + // A completed answer may still be waiting for a sibling when its plugin crashes. + let sibling = root.join("slow-sibling"); + std::fs::create_dir_all(sibling.join("dist")).unwrap(); + std::fs::copy(fixture().join("dist/plugin.wasm"), sibling.join("dist/plugin.wasm")).unwrap(); + let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap(); + std::fs::write( + sibling.join("plugin.toml"), + manifest.replace(ID, "test.slow").replace("[\"text\", \"plaintext\"]", "[\"plaintext\"]"), + ) + .unwrap(); + plugins.install_from_dir(&sibling, &Consent::all()).unwrap(); + plugins.run_command("test.slow", "calls", None, Invocation::default()).unwrap(); + let calls = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap(); + events.lock().clear(); + replace(&host, "left", &reopened, "BAD completed before crash"); + tokio::time::sleep(Duration::from_millis(200)).await; + let waiter = { + let plugins = plugins.clone(); + std::thread::spawn(move || plugins.run_command("test.slow", "wait", None, Invocation::default())) + }; + let started = Instant::now(); + while plugins.run_command(ID, "calls", None, Invocation::default()).unwrap() == calls { + assert!(started.elapsed() < Duration::from_secs(5)); + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!( + latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()), + "the sibling still holds the result: {:?}", + latest(&events, "left", SOURCE) + ); + assert!(plugins.provide_diagnostics(&doc(&root, "text", "panic"), &CancellationToken::new()).is_empty()); + waiter.join().unwrap().unwrap(); + wait_for_source(&events, "left", "plugin-provider:test.slow", |items| !items.is_empty()).await; + assert!( + latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()), + "a crash invalidates completed answers" + ); + ops::doc::close(&host, "left", reopened.id).unwrap(); + host.shutdown().await; + set_editor_providers(None); +} diff --git a/crates/ide-plugin/src/actor.rs b/crates/ide-plugin/src/actor.rs index e95a7e0bb..fc678f125 100644 --- a/crates/ide-plugin/src/actor.rs +++ b/crates/ide-plugin/src/actor.rs @@ -13,6 +13,7 @@ //! idle reclaim ends it. use crate::runtime::{CallCancel, CallError, Runtime}; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::thread::JoinHandle; @@ -20,6 +21,17 @@ use std::time::{Duration, Instant}; type Job = Box) + Send>; +fn catch_call(f: impl FnOnce() -> Result) -> Result { + catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|panic| { + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or("unknown panic"); + Err(CallError::Trap(format!("plugin actor panicked: {message}"))) + }) +} + /// Why an [`Actor::call`] produced no result. #[derive(Debug)] pub enum ActorError { @@ -85,15 +97,24 @@ impl Actor { let cancel = runtime.cancel_handle(); let alive = Arc::new(AtomicBool::new(true)); let busy = Arc::new(AtomicBool::new(false)); + let crash = Arc::new(Mutex::new(None)); let last_used = Arc::new(AtomicU64::new(0)); let epoch = Instant::now(); let thread = { - let (alive, busy, last_used) = (alive.clone(), busy.clone(), last_used.clone()); + let (alive, busy, last_used, crash) = (alive.clone(), busy.clone(), last_used.clone(), crash.clone()); std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || { let mut slot = Some(runtime); while let Ok(job) = rx.recv() { busy.store(true, Ordering::Release); - job(&mut slot); + // A completion callback may panic too; never leave a dead thread marked alive. + if let Err(CallError::Trap(error)) = catch_call(|| { + job(&mut slot); + Ok(()) + }) { + *crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(error); + alive.store(false, Ordering::Release); + slot = None; + } last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release); busy.store(false, Ordering::Release); if slot.is_none() { @@ -113,7 +134,7 @@ impl Actor { thread: Mutex::new(Some(thread)), cancel, alive, - crash: Arc::default(), + crash, busy, last_used, epoch, @@ -148,7 +169,7 @@ impl Actor { return; } let Some(rt) = slot.as_mut() else { return }; - let outcome = f(rt); + let outcome = catch_call(|| f(rt)); if let Err(CallError::Trap(e)) = &outcome { *crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone()); // Before the reply: the caller sees the actor dead. @@ -174,21 +195,59 @@ impl Actor { cancel: &ide_core::CancellationToken, grace: Duration, f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + ) -> Result { + self.call_cancellable_before(cancel, grace, None, f) + } + + /// A cancellable editor call: queued work expires after `soft`, and a + /// successful answer produced later is dropped. Running work still has + /// its runtime hard deadline and can be cancelled by further typing. + pub fn call_edit_cancellable( + &self, + cancel: &ide_core::CancellationToken, + grace: Duration, + soft: Duration, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + ) -> Result { + self.call_cancellable_before(cancel, grace, Some(Instant::now() + soft), f) + } + + fn call_cancellable_before( + &self, + cancel: &ide_core::CancellationToken, + grace: Duration, + deadline: Option, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, ) -> Result { let (reply, result) = mpsc::sync_channel(1); let (crash, alive) = (self.crash.clone(), self.alive.clone()); let skip = cancel.clone(); + let running = Arc::new(Mutex::new(false)); + let in_job = running.clone(); let job: Job = Box::new(move |slot| { - if skip.is_cancelled() { - return; - } let Some(rt) = slot.as_mut() else { return }; - let outcome = f(rt); + { + let mut running = in_job.lock().unwrap_or_else(|e| e.into_inner()); + if skip.is_cancelled() { + return; + } + if deadline.is_some_and(|t| Instant::now() >= t) { + let _ = reply.send(Err(ActorError::SoftTimeout)); + return; + } + *running = true; + } + let outcome = catch_call(|| f(rt)); + *in_job.lock().unwrap_or_else(|e| e.into_inner()) = false; if let Err(CallError::Trap(e)) = &outcome { *crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone()); alive.store(false, Ordering::Release); *slot = None; } + let outcome = match outcome { + Ok(_) if deadline.is_some_and(|t| Instant::now() > t) => Err(ActorError::SoftTimeout), + other => other.map_err(ActorError::Call), + }; let _ = reply.send(outcome); }); { @@ -203,25 +262,34 @@ impl Actor { drop(outcome); return Err(ActorError::Cancelled); } - Ok(outcome) => return outcome.map_err(ActorError::Call), + Ok(outcome) => return outcome, Err(mpsc::RecvTimeoutError::Disconnected) => { return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped }); } Err(mpsc::RecvTimeoutError::Timeout) => {} } if !cancel.is_cancelled() { + if deadline.is_some_and(|t| Instant::now() >= t) && !*running.lock().unwrap_or_else(|e| e.into_inner()) + { + return Err(ActorError::SoftTimeout); + } continue; } match cancelled_at { None => { cancelled_at = Some(Instant::now()); - if self.is_busy() { - self.cancel.cancel_soft(); + let running = running.lock().unwrap_or_else(|e| e.into_inner()); + if !*running { + return Err(ActorError::Cancelled); } + self.cancel.cancel_soft(); } Some(at) if at.elapsed() >= grace => { // Did not return in time: interrupt it (the instance is rebuilt). - self.cancel(); + let running = running.lock().unwrap_or_else(|e| e.into_inner()); + if *running { + self.cancel(); + } return Err(ActorError::Cancelled); } Some(_) => {} @@ -239,7 +307,7 @@ impl Actor { let (crash, alive) = (self.crash.clone(), self.alive.clone()); let job: Job = Box::new(move |slot| { let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) }; - let outcome = f(rt); + let outcome = catch_call(|| f(rt)); if let Err(CallError::Trap(e)) = &outcome { *crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone()); alive.store(false, Ordering::Release); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 8c075a7c8..763913116 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -632,6 +632,7 @@ struct Exports { views: Option, semantic: Option, details: Option, + diagnostics: Option, hover: Option, commit_checks: Option, inlay: Option, @@ -719,6 +720,8 @@ impl Runtime { let view_indices = wit_view::GuestIndices::new(&pre).ok(); let semantic_indices = wit_sem::GuestIndices::new(&pre).ok(); let detail_indices = wit_detail::GuestIndices::new(&pre).ok(); + use crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider as wit_diag; + let diagnostics_indices = wit_diag::GuestIndices::new(&pre).ok(); let hover_indices = wit_hover::GuestIndices::new(&pre).ok(); let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok(); let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok(); @@ -753,6 +756,10 @@ impl Runtime { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), None => None, }; + let diagnostics = match diagnostics_indices { + Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; let hover = match hover_indices { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), None => None, @@ -779,6 +786,7 @@ impl Runtime { indexer, semantic, details, + diagnostics, hover, commit_checks, inlay, @@ -1075,6 +1083,27 @@ impl Runtime { .collect()) } + pub fn provide_diagnostics( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + use crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider as w; + if self.exports.diagnostics.is_none() { + return Err(CallError::missing_export("diagnostics-provider")); + } + let d = w::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let items = self.call(Invocation::default(), timeout, |e, store| { + e.diagnostics.as_ref().expect("checked").call_diagnostics(store, &d) + })?; + Ok(crate::wit_host::diagnostics_from_wit(items, &self.store.data().plugin_id)) + } + pub fn provide_hover( &mut self, doc: &ProviderDocument, diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index a0f080273..10370a481 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -922,26 +922,7 @@ impl diagnostics::Host for StoreData { if let Err(e) = self.require(Capability::ProjectRead) { return Ok(Err(e)); } - let source = self.plugin_id.clone(); - let items = items - .into_iter() - .take(MAX_DIAGNOSTICS) - .map(|d| ide_core::Diagnostic { - range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)), - severity: match d.severity { - diagnostics::Severity::Error => ide_core::Severity::Error, - diagnostics::Severity::Warning => ide_core::Severity::Warning, - diagnostics::Severity::Info => ide_core::Severity::Info, - diagnostics::Severity::Hint => ide_core::Severity::Hint, - }, - message: clip(d.message, 4096), - source: Some(d.source.map_or_else(|| source.clone(), |s| clip(s, 128))), - code: d.code.map(|c| clip(c, 128)), - unnecessary: d.tags.iter().any(|t| t == "unnecessary"), - deprecated: d.tags.iter().any(|t| t == "deprecated"), - server: Some(source.clone()), - }) - .collect(); + let items = diagnostics_from_wit(items, &self.plugin_id); Ok(self.host.publish_diagnostics(Path::new(&path), items).map_err(to_wit)) } } @@ -1397,3 +1378,35 @@ mod project_ui_tests { assert!(validate_ui_path(&format!("/{}", "x".repeat(8192))).is_err()); } } + +pub(crate) mod diagnostics_bindings { + wasmtime::component::bindgen!({ + world: "editor-diagnostics", path: "../../wit", + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, + }, + }); +} + +pub(crate) fn diagnostics_from_wit(items: Vec, source: &str) -> Vec { + items + .into_iter() + .take(MAX_DIAGNOSTICS) + .map(|d| ide_core::Diagnostic { + range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)), + severity: match d.severity { + diagnostics::Severity::Error => ide_core::Severity::Error, + diagnostics::Severity::Warning => ide_core::Severity::Warning, + diagnostics::Severity::Info => ide_core::Severity::Info, + diagnostics::Severity::Hint => ide_core::Severity::Hint, + }, + message: clip(d.message, 4096), + source: Some(d.source.map_or_else(|| source.to_owned(), |s| clip(s, 128))), + code: d.code.map(|c| clip(c, 128)), + unnecessary: d.tags.iter().any(|t| t == "unnecessary"), + deprecated: d.tags.iter().any(|t| t == "deprecated"), + server: Some(source.to_owned()), + }) + .collect() +} diff --git a/crates/ide-plugin/tests/api_compat.rs b/crates/ide-plugin/tests/api_compat.rs index 9f27c96a1..7926dae3f 100644 --- a/crates/ide-plugin/tests/api_compat.rs +++ b/crates/ide-plugin/tests/api_compat.rs @@ -30,6 +30,7 @@ fn components() -> Vec { repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"), repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"), repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"), + repo().join("crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm"), repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"), repo().join("crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm"), ]; diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index 5b8ede3d6..bc98cc38d 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ 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 6ec88cb35..635baa55c 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 @@ -2652fb97735511f78576d5a16f685871bd7ab12df94795f14984bc819fe0b593 +24726dac5ce264f5d451956def099e0002ec721e4828bc7852ad1dcabcb4bcba diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm index 20743313d..cab230a16 100644 Binary files a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources index dc9bf4212..81b1b8dba 100644 --- a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources @@ -1 +1 @@ -41cf712d387210fea3e102f3f590b05c69b56814b742504a40c53f160874e926 +833c378fabd0c3ec71e5c372e51049cb4fcd8d58e4992199eaf6090b3c1235d8 diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/Cargo.lock b/crates/ide-plugin/tests/fixtures/diagnostics/Cargo.lock new file mode 100644 index 000000000..0e18d38a4 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/diagnostics/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-diagnostics" +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/diagnostics/Cargo.toml b/crates/ide-plugin/tests/fixtures/diagnostics/Cargo.toml new file mode 100644 index 000000000..3a1db6261 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/diagnostics/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "openidea-test-diagnostics" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +diagnostics-first = [] +diagnostics-last = [] + +[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/diagnostics/README.md b/crates/ide-plugin/tests/fixtures/diagnostics/README.md new file mode 100644 index 000000000..0b0214013 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/diagnostics/README.md @@ -0,0 +1,6 @@ +# Diagnostics provider fixture + +Build with `plugins/build.sh crates/ide-plugin/tests/fixtures/diagnostics`. +Used by `cargo test -p ide-host --test plugin_diagnostics` for real Wasm dispatch, +UTF-16 ranges, debouncing, cancellation, window/project lifecycle and coexistence +with manually published diagnostics. Not a marketplace plugin. diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm new file mode 100644 index 000000000..7473062ba Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources new file mode 100644 index 000000000..703a871f7 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources @@ -0,0 +1 @@ +f757407f530839b8c43697e310926c5b141231bd8f2781ac92eee3a9de3f0f30 diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/plugin.toml b/crates/ide-plugin/tests/fixtures/diagnostics/plugin.toml new file mode 100644 index 000000000..e2c6cb522 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/diagnostics/plugin.toml @@ -0,0 +1,23 @@ +id = "test.diagnostics" +name = "Diagnostics fixture" +version = "0.1.0" +engines.openidea = "^1.3" +wasm = "dist/plugin.wasm" +[capabilities] +required = ["project.read"] + +[[contributes.providers]] +kinds = ["diagnostics"] +languages = ["text", "plaintext"] + +[[contributes.commands]] +id = "publish" +title = "Publish fixture diagnostic" + +[[contributes.commands]] +id = "calls" +title = "Diagnostics fixture call count" + +[[contributes.commands]] +id = "wait" +title = "Wait for diagnostics fixture" diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/src/lib.rs b/crates/ide-plugin/tests/fixtures/diagnostics/src/lib.rs new file mode 100644 index 000000000..98cba1505 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/diagnostics/src/lib.rs @@ -0,0 +1,169 @@ +use openidea_plugin::diagnostics::{DiagnosticsProvider, Document}; +use openidea_plugin::{Diagnostic, Error, Invocation, Plugin, Range, Severity, host, register_plugin, text}; +use std::time::Duration; + +#[derive(Default)] +struct Fixture { + calls: u64, +} + +impl Plugin for Fixture { + fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result, Error> { + match id { + "wait" => { + std::thread::sleep(Duration::from_millis(400)); + Ok(Some("finished".into())) + } + "calls" => Ok(Some(self.calls.to_string())), + "publish" => host::publish_diagnostics( + arg.unwrap_or_default(), + &[Diagnostic::new(Range::default(), Severity::Info, "Manual problem")], + ) + .map(|()| None), + _ => Err(Error::NotFound(id.into())), + } + } +} + +impl DiagnosticsProvider for Fixture { + fn diagnostics(&mut self, doc: &Document) -> Vec { + self.calls += 1; + if doc.text == "limits" { + let mut diagnostic = Diagnostic::new(Range::default(), Severity::Hint, "m".repeat(5000)); + diagnostic.source = Some("s".repeat(200)); + diagnostic.code = Some("c".repeat(200)); + diagnostic.unnecessary = true; + diagnostic.deprecated = true; + return vec![diagnostic; 5001]; + } + if doc.text.starts_with("slow") { + std::thread::sleep(Duration::from_millis(200)); + } + if doc.text.starts_with("spin") { + loop { + std::hint::spin_loop(); + } + } + if doc.text.starts_with("panic") { + panic!("diagnostics fixture failed"); + } + doc.text + .match_indices("BAD") + .map(|(offset, _)| { + Diagnostic::new( + Range { start: text::position(&doc.text, offset), end: text::position(&doc.text, offset + 3) }, + Severity::Warning, + format!("BAD in {} at version {}", doc.language, doc.version), + ) + .code("bad-word") + }) + .collect() + } +} + +impl openidea_plugin::hover::HoverProvider for Fixture { + fn hover(&mut self, _: &Document, _: openidea_plugin::Position) -> Option { + None + } +} + +impl openidea_plugin::inlay_hints::InlayHints for Fixture { + fn inlay_hints(&mut self, _: &Document, _: Range) -> Vec { + Vec::new() + } +} + +impl openidea_plugin::providers::Providers for Fixture {} + +impl openidea_plugin::endpoints::EndpointsProvider for Fixture { + fn endpoints(&mut self, _: &Invocation) -> Result, Error> { + Ok(Vec::new()) + } +} + +impl openidea_plugin::views::Views for Fixture { + fn render( + &mut self, + _: &str, + _: Option, + _: &Invocation, + ) -> Result { + Ok(openidea_plugin::views::View::message("")) + } +} + +impl openidea_plugin::indexing::DocumentIndexer for Fixture { + fn index(&mut self, _: &openidea_plugin::indexing::Document) -> openidea_plugin::indexing::Edges { + openidea_plugin::indexing::Edges::default() + } +} + +impl openidea_plugin::syntax::SyntaxProvider for Fixture { + fn highlights(&mut self, _: &str, _: &str) -> Vec { + Vec::new() + } +} + +impl openidea_plugin::semantic_tokens::SemanticTokens for Fixture { + fn semantic_tokens(&mut self, _: &Document) -> Vec { + Vec::new() + } +} + +impl openidea_plugin::details::Details for Fixture { + fn detail(&mut self, _: &str, _: &str, _: &Invocation) -> Result, Error> { + Ok(Vec::new()) + } +} + +impl openidea_plugin::commit::CommitChecks for Fixture { + fn check( + &mut self, + _: &str, + _: &openidea_plugin::CommitDraft, + _: &Invocation, + ) -> Result, Error> { + Ok(Vec::new()) + } +} + +impl openidea_plugin::file_actions::FileActions for Fixture { + fn file_actions(&mut self, _: &Document, _: Range) -> Vec { + Vec::new() + } +} + +#[cfg(feature = "diagnostics-first")] +register_plugin!( + Fixture, + diagnostics, + endpoints, + views, + indexing, + syntax, + semantic_tokens, + hover, + providers, + inlay_hints, + details, + commit_checks, + file_actions +); +#[cfg(all(feature = "diagnostics-last", not(feature = "diagnostics-first")))] +register_plugin!( + Fixture, + file_actions, + commit_checks, + details, + inlay_hints, + providers, + hover, + semantic_tokens, + syntax, + indexing, + views, + endpoints, + diagnostics +); +#[cfg(not(any(feature = "diagnostics-first", feature = "diagnostics-last")))] +register_plugin!(Fixture, diagnostics); diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm index 752d3d658..36143132c 100644 Binary files a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ 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 52a837bcc..607ce0ede 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 @@ -cdd58d817f507452ed192e3a19071bd56740efc70c4a965b0d48e5b224fff1d4 +99cba883e8030d7679db366a4b18b8c456afccbcbc949e0d7411adc6411f2e00 diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm index 4e214ba73..7ab317e3e 100644 Binary files a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources index 6d3cc157b..ffe9e0945 100644 --- a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources @@ -1 +1 @@ -44da806d4e0d4d370534d108bd90406bcc475bac17cf69d0d323d341b79deb9a +afcf583238dddd20d0d709ce7619fada0e60916f9e3c039569d37dec75354471 diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm index 5db3f2dc7..aeb1692c6 100644 Binary files a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources index fedec7ba4..1944ef030 100644 --- a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources @@ -1 +1 @@ -6d61b436c76c26b7edf444c89c8e71091ca7c5bccfba9ae79e770f70f8b9013d +a1ce41144630be398bb63cffca26005955de0511a17c71dc286e7762b19e9cab diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 046be4056..515fdea7d 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ 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 2d8db8996..c4aef7756 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 @@ -b4fedecefcc934cd6ddc49c3ffe948468a7603930a47c3594be85f5f01758fab +bbfc134f2121f301bbbcb0f0d596d7b3cc059455bba5ca78992e8bfa6630ce42 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index 70d531824..46b0d9891 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ 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 242254d86..dab679e29 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 @@ -e593cd53d3757c3a738121ec2fcc3840a9d1c0dcc6ea1e17134c9d53ef695ebc +b7f45420859ce9540e0aae1fd803eee13529ef63d0a2f0b6ba9e43a5958eb457 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index f4c49c9cb..e4b6a360d 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ 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 4165bb01d..c1d45840b 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 @@ -3f49e3bb7f523103f70a39087e96500b5cd806ab60de60fceb480ca54ad4aa1d +041aa03873ff1d8133f181e86aab2ad3fda41e46503886754114f3abef3643da diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 4b3f14ace..ecce5ea24 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ 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 8c1c7456b..d29297306 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 @@ -f3290c6ef1e794c95d3e8993aacecd58566e2ba690f966f52613a2dbdfa21dd5 +edcad61ab8e71cff2297edd9b902359d76ef04446bdfc42aae34420ced4d7f26 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 2400a6300..db5abfe11 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ 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 ef1c5a1f9..20511645d 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 @@ -7e95ef2102982c48740d664cf050c86d2f7015d438e30376c35c5a97c2fb8c1b +aad3a25c3522fbff6f48c0c1e9de23b6d0d5361ba6658b744ad1590e9fdbe255 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 2c433f90d..8f1030378 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 @@ -58bcfb533bf386c0f0179e6775be834aa2d30f1b5e88e7ca00ad662bdd35699e +cfb432ea8943bf9b7e2f3ad3708df35260eebd04b3ac059c7643b378a35cd036 diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm index 85c41c477..bedbb1f37 100644 Binary files a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm 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 index 3af778b65..ab0768e5b 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -12e92a08e3493a282b811592d1f044f293d6296b36b4c8e3b25ef80c5aca022f +82f53a3f36f965c76575a08609439e0bc9cea37f1aef7cc1671e9ec72a17f120 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index 126b88723..45c9f6afa 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ 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 2b0fb51d6..d6ad006c7 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 @@ -eb481d1624b5d96488925f15dfa773a74c38cde490bc0c43b9a8caf6631168ce +a8d40f2224246e81e015d576a5fa70e83779af9fade614d61212b33fbb0961e9 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index acb7a86ff..88b217dd1 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ 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 05e8a7da9..7a222a646 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 @@ -99e6543b156c2ea1a916f597224416db55f10bdce37af8943e2297a397fad0c9 +e60bcf1e179f6485e14de3755907c93344ef3cdf8158f01bea84343858ba7434 diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm index f1ad28ffa..0ee2946c0 100644 Binary files a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources index 4173f7d66..36454c216 100644 --- a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources @@ -1 +1 @@ -be318451a7ea7653bfd40a5624aa0951b4fb814c8c39a494ed3066c7ac8ef35e +5cadc40061284bd5573f8323b02fb7ea7be18eb4323978b3f0b39337a9896774 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index d54eaa4eb..4f1227e4b 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ 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 1ee2e8c2d..ca8391d43 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 @@ -9b4b83d13abbfaf88b04dff3ecc1beca7f12805fa651b53f1087b6e4ff2f0e38 +5377825f5801850d6ed7ec989f6e954fae477d81cd3ce0d5a2b8baeb9febcf74 diff --git a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm index dba2d159c..b1bf544b1 100644 Binary files a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources index 7276e0e76..4fc2bc467 100644 --- a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources @@ -1 +1 @@ -74acb8ab9079c558a120c145319f01b54294cbf6fa4cbd33907a0d58e1ef2906 +e1414e522a45066616475fae229c4dade82ba16eedef49aac8d4c014522a0684 diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm index 2255d5579..56833ae13 100644 Binary files a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources index c1cf5b704..2ac9c8016 100644 --- a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources @@ -1 +1 @@ -bb6e1eba6b9f28ca769bee0c6dad9ceab536d486efcbe0ad56bbfe621e2d7679 +5a50246a300a15c8b9e50457094442fe35f0efe1534feb6f5cb407e052404c07 diff --git a/plugins/README.md b/plugins/README.md index c9280906b..9f01c55f7 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -566,7 +566,7 @@ declaration matches the file's language are called (and loaded): ```toml [[contributes.providers]] -kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens", "hover", "inlay-hints" +kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens", "hover", "inlay-hints", "diagnostics" languages = ["java", "xml"] # editor language ids; "*" for any ``` @@ -676,6 +676,54 @@ edit-path budget, and shows the server's hover first, then each plugin's below a rule. `range`, when given, is the text the hover describes (used when the server names none). At most 64 KiB of Markdown per plugin. +#### Diagnostics while typing + +The `diagnostics` kind (API 1.3) checks unsaved documents in its declared +`languages` (`"*"` matches any language). Implement +`openidea_plugin::diagnostics::DiagnosticsProvider` and register with +`register_plugin!(MyPlugin, diagnostics)`: + +```rust +impl DiagnosticsProvider for MyPlugin { + fn diagnostics(&mut self, doc: &Document) -> Vec { + // doc.path is absolute; doc.language is the editor language id. + // doc.version identifies this snapshot; doc.text includes unsaved edits. + check(&doc.text) + } +} +``` + +The optional WIT export is `diagnostics-provider.diagnostics(document) -> +list` in worlds `plugin-diagnostics` / `editor-diagnostics`. It uses the +same diagnostic record as `diagnostics.publish`, including 0-based UTF-16 +ranges. The export-only world uses `editor-diagnostics` because `diagnostics` +already names the released host interface. The provider declaration supplies the current document without an +additional capability; host imports keep their usual capability checks +(`publish` still requires `project.read`). Providers run without an interactive +invocation, so they cannot open prompts or write to the active editor. + +The host requests diagnostics about 300 ms after opening a document or after +its latest edit, under the editor-provider budget (normally 50 ms soft / +500 ms hard). It snapshots the buffer in the background, cancels superseded +requests, and drops older document versions. Documents over 2 MiB and virtual +library documents are excluded. Return an empty list to remove provider +problems. At most 5,000 diagnostics are retained per provider and document; +messages are limited to 4 KiB, source/code to 128 bytes. + +Provider problems and manually published problems use separate producers, +`plugin-provider:` and `plugin:`, and both appear alongside +language-server diagnostics. Each producer replaces only its own results. +Closing the document/project or disabling, uninstalling, reloading or failing +the plugin clears provider results. A reclaimed idle instance keeps its results +until the next request. Requests and results stay with the originating project +and window, including when two windows edit the same path differently. Project +plugins receive documents only from the project where they were granted. + +Native plugin tests can use `MockHost::refresh_diagnostics(&mut provider)` to +check the current versioned mock editor and `provider_diagnostics()` to inspect +its answers separately from `diagnostics()` (manual publishing). The mock call +is explicit; debounce and cancellation are exercised by the real Wasm host tests. + #### Inlay hints The `inlay-hints` kind (API 1.3) shows short labels inside the code of diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index 867eac614..9c757fa70 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index b33b8927d..45a3177f5 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -f5d442002dd3315f14ed7ebe873a7d1c858fa31a2840aebc7876366269d5fd6c +61aaa8563701c6d4c147eaef87363ac0600c79fd9ef2ee8c8c1cc9c7e56794ed diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index bbd70c1ae..7ee505e91 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index cb4c66f56..1d3b7f8d1 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -0bebf24cc46dd2dac9c51fffcd4b9186f7e575c2d552e8d600c02fa5fe8fa9b0 +ab0a7b4daf59fda5938b10c42628bb141c94bc0d82464254a6b0157d74e98477 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 0212da992..73d269c44 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index b4b5ddbaf..a614ca79b 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -a4179ed839602e1d1697d738689d8936d22889b0d4e09ff953d6354a6d91f368 +5813b46ce5ec12b9ca4bdfe61d49c63ed1af736238398a1c2e03ca7c3633469c diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 74356cd81..0c36535b3 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index cdb84bef7..20f4d1921 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -eb5f3a257f2551a29c3ad62b222b0641b07b7b9422780e1ffecafa7e39b5032d +208cf58ff413f2ef75abfd0a4280d6e38393acf4e2d0a30f9c4d3294aa9ec82f diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index b2bbb9817..7a4b88b6a 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index a75fb7800..e446da7d9 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -74df219fa8bfbb491bc2f3c021e3df2a8daa9bed03606e8d558624918d9d7104 +f83c2371ee5dd6c9d8d027ec3515fb7850141ac694d873ef0463a64ec6edcc9d diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 9baa571db..e54ae18c3 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index f1891d7a6..d1dfe3ad8 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -19c90c5f90d6a26be8f4762d42cbe2f9281e311acc61467bd104da7ec25e5545 +f5baecb3f80efee4244bc096039ae7ac926c8bc73eb08fdfe5ecf81a4e2c38c9 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 3a78025b9..fa235c304 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 6b9d7a3ea..e10cba55a 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -a9bf8c6074ffb5d5335f07d6e5f1af6108faec0ca6e282a85684b991995baefc +e4f62cf323d7186dcdc2972b0330086bc6279dcdc884d1997a84c1a589e739f8 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index b17ee3ee3..2141ea7bb 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 9fb0a4cd6..ab575d7fa 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -6519aa645fe62c9f963d1baf7cd0a36dd75787b37da46a7d1218f3983f828e79 +216af779424458d3fe4de470f2358f21ebc15d62be3d285f59cba19d61e59f3d diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 276daf1e8..49293a0f5 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index d5ee8b74c..004411fe4 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -a95e6ff3977bf25cf106f6641cd8333124bb8615a8c327baefa068e36dd3d2ee +b2994bcb35737abbd001535308fcd4d3f0bab4cd61e580d8af1312451e760fe3 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index a9fc62c52..a0e8bdf89 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 956a79eb4..11794ff4d 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -674e164012c93a106a0c3977b1066d5a49524f8f8426fb09404e3b7a7340a629 +6d5d5b09fd48e9b48891a91b3dd13f9d0d2f1771685a549c683295370233ae17 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 647a81967..6ec0dd65a 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 45439d0f3..21885e1c9 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -f253276316839b418223b8292683e313d311ce41c9701c04c9c3fcc85e0638e5 +5e6457d34c922284779cdcc42455ed1df9ed8b9245793134be566dcd3c8aab93 diff --git a/sdk/rust/openidea-plugin/README.md b/sdk/rust/openidea-plugin/README.md index 9cde23a80..67811d0f0 100644 --- a/sdk/rust/openidea-plugin/README.md +++ b/sdk/rust/openidea-plugin/README.md @@ -14,6 +14,23 @@ and the [minimal fixture](../../../crates/ide-plugin/tests/fixtures/web-pty). The frame cannot access IPC directly. Its sessions belong to that frame and end with its view or plugin. There is no new WIT import for this API. +API 1.3 also adds diagnostics for unsaved documents. Declare +`kinds = ["diagnostics"]` and the supported `languages` under +`[[contributes.providers]]`, implement +`openidea_plugin::diagnostics::DiagnosticsProvider`, and export it with +`register_plugin!(MyPlugin, diagnostics)`. The host supplies versioned text +after a 300 ms debounce, cancels superseded calls, and drops stale answers +under the editor-provider budget. Ranges use 0-based UTF-16 positions. + +The provider declaration supplies the document; host imports retain their +usual capability checks. Provider results coexist with manually published +diagnostics and are cleared with the document, project or plugin. Native +tests can call `MockHost::refresh_diagnostics` and inspect +`MockHost::provider_diagnostics`. + +See [the diagnostics contract and limits](../../../plugins/README.md#diagnostics-while-typing) +and the [real Wasm fixture](../../../crates/ide-plugin/tests/fixtures/diagnostics). + API 1.3 also adds `open-log-tab` to the web bridge: a generic read-only editor tab streams output from a granted process, with plugin-defined argument toggles. It uses the same backend authorization and bounded PTY transport. See the diff --git a/sdk/rust/openidea-plugin/src/diagnostics.rs b/sdk/rust/openidea-plugin/src/diagnostics.rs new file mode 100644 index 000000000..06b9a6e10 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/diagnostics.rs @@ -0,0 +1,46 @@ +//! Diagnostics for unsaved documents. Declare `kinds = ["diagnostics"]` and +//! register with `register_plugin!(MyPlugin, diagnostics)` (API 1.3). +pub use crate::providers::Document; +pub use crate::types::Diagnostic; + +pub trait DiagnosticsProvider { + fn diagnostics(&mut self, doc: &Document) -> Vec; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Range, Severity, host, mock::MockHost}; + + struct Provider; + impl DiagnosticsProvider for Provider { + fn diagnostics(&mut self, doc: &Document) -> Vec { + vec![Diagnostic::new( + Range::default(), + Severity::Warning, + format!("{}:{}:{}", doc.language, doc.version, doc.text), + )] + } + } + + #[test] + fn the_mock_uses_unsaved_versioned_text_and_keeps_manual_problems() { + let mock = MockHost::install(); + mock.open_editor("/project/a.txt", "text", "a", &[]); + host::publish_diagnostics("/project/a.txt", &[Diagnostic::new(Range::default(), Severity::Info, "manual")]) + .unwrap(); + mock.type_in_editor("😀"); + mock.refresh_diagnostics(&mut Provider); + assert_eq!(mock.provider_diagnostics()["/project/a.txt"][0].message, "text:2:a😀"); + assert_eq!(mock.diagnostics()["/project/a.txt"][0].message, "manual"); + } + + #[test] + fn a_provider_needs_no_extra_capability_but_publishing_still_does() { + let mock = MockHost::install().with_capabilities(&[]); + mock.open_editor("/project/a.txt", "text", "unsaved", &[]); + mock.refresh_diagnostics(&mut Provider); + assert_eq!(mock.provider_diagnostics()["/project/a.txt"][0].message, "text:1:unsaved"); + assert!(host::publish_diagnostics("/project/a.txt", &[]).is_err()); + } +} diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 8f84d945a..3e5f06ec3 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -377,39 +377,40 @@ mod imp { wit::fs::write_bytes(path, bytes).map_err(error) } - pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { + pub fn diagnostic_to(d: &Diagnostic) -> wit::diagnostics::Diagnostic { use wit::diagnostics::{Diagnostic as D, RelatedInformation as R, Severity as S}; - let items: Vec = items - .iter() - .map(|d| D { - range: range_out(d.range), - severity: match d.severity { - Severity::Error => S::Error, - Severity::Warning => S::Warning, - Severity::Info => S::Info, - Severity::Hint => S::Hint, - }, - message: d.message.clone(), - code: d.code.clone(), - source: d.source.clone(), - tags: [(d.unnecessary, "unnecessary"), (d.deprecated, "deprecated")] - .into_iter() - .filter(|(on, _)| *on) - .map(|(_, tag)| tag.to_owned()) - .collect(), - related: d - .related - .iter() - .map(|r| R { - location: wit::types::Location { - path: r.location.path.clone(), - range: range_out(r.location.range), - }, - message: r.message.clone(), - }) - .collect(), - }) - .collect(); + D { + range: range_out(d.range), + severity: match d.severity { + Severity::Error => S::Error, + Severity::Warning => S::Warning, + Severity::Info => S::Info, + Severity::Hint => S::Hint, + }, + message: d.message.clone(), + code: d.code.clone(), + source: d.source.clone(), + tags: [(d.unnecessary, "unnecessary"), (d.deprecated, "deprecated")] + .into_iter() + .filter(|(on, _)| *on) + .map(|(_, tag)| tag.to_owned()) + .collect(), + related: d + .related + .iter() + .map(|r| R { + location: wit::types::Location { + path: r.location.path.clone(), + range: range_out(r.location.range), + }, + message: r.message.clone(), + }) + .collect(), + } + } + + pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { + let items: Vec<_> = items.iter().map(diagnostic_to).collect(); wit::diagnostics::publish(path, &items).map_err(error) } @@ -1141,3 +1142,9 @@ pub fn notify_with_actions( ) -> Result<(), Error> { imp::notify_with_actions(level, title, body, actions) } + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub fn __diagnostic_to(d: &Diagnostic) -> crate::bindings::openidea::plugin::diagnostics::Diagnostic { + imp::diagnostic_to(d) +} diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 03c8e24a6..8518a3a4c 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -41,6 +41,7 @@ pub mod commit; pub mod details; +pub mod diagnostics; pub mod endpoints; pub mod file_actions; pub mod host; @@ -160,6 +161,20 @@ pub mod semantic_bindings { }); } +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod diagnostics_bindings { + wit_bindgen::generate!({ + world: "editor-diagnostics", path: "wit", pub_export_macro: true, + export_macro_name: "__export_diagnostics_world", + default_bindings_module: "openidea_plugin::diagnostics_bindings", + with: { + "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/diagnostics@1.3.0": crate::bindings::openidea::plugin::diagnostics, + }, + }); +} + #[cfg(target_arch = "wasm32")] #[doc(hidden)] pub mod hover_bindings { @@ -292,6 +307,9 @@ macro_rules! __optional_exports { (details, $plugin:ty, $component:ident) => { $crate::__details_exports!($plugin, $component); }; + (diagnostics, $plugin:ty, $component:ident) => { + $crate::__diagnostics_exports!($plugin, $component); + }; (hover, $plugin:ty, $component:ident) => { $crate::__hover_exports!($plugin, $component); }; @@ -533,6 +551,21 @@ macro_rules! __semantic_tokens_exports { }; } +#[doc(hidden)] +#[macro_export] +macro_rules! __diagnostics_exports { + ($plugin:ty, $component:ident) => { + impl $crate::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider::Guest for $component { + fn diagnostics( + doc: $crate::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider::Document, + ) -> ::std::vec::Vec<$crate::bindings::openidea::plugin::diagnostics::Diagnostic> { + $crate::__rt::diagnostics::<$plugin>(doc) + } + } + $crate::diagnostics_bindings::__export_diagnostics_world!($component with_types_in $crate::diagnostics_bindings); + }; +} + #[doc(hidden)] #[macro_export] macro_rules! __hover_exports { @@ -820,6 +853,13 @@ pub mod __rt { .collect() } + pub fn diagnostics( + d: crate::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider::Document, + ) -> Vec { + let doc = crate::providers::Document { path: d.path, language: d.language, version: d.version, text: d.text }; + with::(|p| p.diagnostics(&doc)).iter().map(crate::host::__diagnostic_to).collect() + } + pub fn hover( d: crate::hover_bindings::exports::openidea::plugin::hover_provider::Document, pos: b::types::Position, diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index 5b7967c7e..7d2d1b50e 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -110,6 +110,7 @@ struct State { answers: VecDeque>>, revealed_views: Vec, diagnostics: BTreeMap>, + provider_diagnostics: BTreeMap>, secrets: BTreeMap, net: Option, requests: Vec, @@ -362,6 +363,31 @@ impl MockHost { self.state.borrow().diagnostics.clone() } + /// Runs a diagnostics provider against the current mock editor snapshot. + /// This explicit call replaces the real host's debounce; it needs no capability. + pub fn refresh_diagnostics(&self, provider: &mut impl crate::diagnostics::DiagnosticsProvider) { + let document = self.state.borrow().editor.as_ref().map(|e| crate::diagnostics::Document { + path: e.path.clone(), + language: e.language.clone(), + version: e.version, + text: e.text.clone(), + }); + if let Some(document) = document { + let items = provider.diagnostics(&document); + let mut state = self.state.borrow_mut(); + if items.is_empty() { + state.provider_diagnostics.remove(&document.path); + } else { + state.provider_diagnostics.insert(document.path, items); + } + } + } + + /// Diagnostics returned by the provider, separate from manual publishing. + pub fn provider_diagnostics(&self) -> BTreeMap> { + self.state.borrow().provider_diagnostics.clone() + } + /// Views the plugin asked to refresh, in order. pub fn refreshed_views(&self) -> Vec { self.state.borrow().refreshed.clone() diff --git a/sdk/rust/openidea-plugin/tests/diagnostics_exports.rs b/sdk/rust/openidea-plugin/tests/diagnostics_exports.rs new file mode 100644 index 000000000..869e869d7 --- /dev/null +++ b/sdk/rust/openidea-plugin/tests/diagnostics_exports.rs @@ -0,0 +1,45 @@ +//! Inspect real components, including the two full export-order fixture features. +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use wit_parser::WorldItem; + +#[test] +fn diagnostics_registration_keeps_the_requested_interfaces() { + let path = std::env::var_os("OPENIDEA_DIAGNOSTICS_COMPONENT").map(PathBuf::from).unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm") + }); + let wit_parser::decoding::DecodedWasm::Component(resolve, world) = + wit_parser::decoding::decode(&std::fs::read(&path).unwrap()).unwrap() + else { + panic!("expected a component") + }; + let actual: BTreeSet<_> = resolve.worlds[world] + .exports + .values() + .filter_map(|item| { + let WorldItem::Interface { id, .. } = item else { return None }; + let interface = &resolve.interfaces[*id]; + let package = &resolve.packages[interface.package?]; + (package.name.namespace == "openidea").then_some(interface.name.as_deref()).flatten() + }) + .collect(); + let mut expected = BTreeSet::from(["lifecycle", "commands", "event-handler", "diagnostics-provider"]); + if std::env::var_os("OPENIDEA_DIAGNOSTICS_COMBINED").is_some() { + expected.extend([ + "endpoints-provider", + "view-provider", + "document-indexer", + "syntax-provider", + "semantic-tokens-provider", + "hover-provider", + "providers", + "inlay-hints-provider", + "detail-provider", + "commit-checker", + "file-actions", + ]); + } + println!("{} exports {actual:?}", path.display()); + assert_eq!(actual, expected); +} diff --git a/sdk/rust/openidea-plugin/wit/diagnostics.wit b/sdk/rust/openidea-plugin/wit/diagnostics.wit new file mode 100644 index 000000000..c3bf18130 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/diagnostics.wit @@ -0,0 +1,15 @@ +package openidea:plugin@1.3.0; + +/// Optional diagnostics for the current, possibly unsaved document (API 1.3). +interface diagnostics-provider { + use diagnostics.{diagnostic}; + + record document { + path: string, + language: string, + version: u64, + text: string, + } + + diagnostics: func(doc: document) -> list; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index 2f8afeb49..d619cba19 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -152,3 +152,14 @@ world plugin-endpoints { world endpoints { export endpoints-provider; } + +/// A plugin with diagnostics under `[[contributes.providers]]`. +world plugin-diagnostics { + include plugin; + export diagnostics-provider; +} + +/// `diagnostics` is already the released host interface name. +world editor-diagnostics { + export diagnostics-provider; +} diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index e00827d27..f8f2ed50d 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -135,6 +135,15 @@ afterEach(() => { }); describe("plugin actions", () => { + it("names manual and provider inspection producers after their plugin", async () => { + const { useProducerNames } = await import("@/platform/inspections"); + usePlugins.setState({ plugins: [plugin({ id: "a", name: "Alpha" })] }); + const stop = syncProjection(); + expect(useProducerNames.getState().names.get("plugin:a")).toBe("Alpha"); + expect(useProducerNames.getState().names.get("plugin-provider:a")).toBe("Alpha"); + stop(); + }); + it("projects command contributions to plugin.. actions", () => { const cmd = (pluginId: string, id: string, extra: Record = {}) => ({ pluginId, diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index 0a81aeb9a..a0fd057ce 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -53,7 +53,14 @@ export function syncProjection(): () => void { const s = usePlugins.getState(); syncUiContributions(localizeContributions(s.ui, usePluginCatalogs.getState().catalogs), s.plugins); // Their problems are listed by name in Settings › Inspections. - useProducerNames.setState({ names: new Map(s.plugins.map((p) => [`plugin:${p.id}`, p.name])) }); + useProducerNames.setState({ + names: new Map( + s.plugins.flatMap((p): [string, string][] => [ + [`plugin:${p.id}`, p.name], + [`plugin-provider:${p.id}`, p.name], + ]), + ), + }); }; const loadCatalogs = () => void loadPluginCatalogs(usePlugins.getState().ui, useI18n.getState().locale); apply(); diff --git a/wit/diagnostics.wit b/wit/diagnostics.wit new file mode 100644 index 000000000..c3bf18130 --- /dev/null +++ b/wit/diagnostics.wit @@ -0,0 +1,15 @@ +package openidea:plugin@1.3.0; + +/// Optional diagnostics for the current, possibly unsaved document (API 1.3). +interface diagnostics-provider { + use diagnostics.{diagnostic}; + + record document { + path: string, + language: string, + version: u64, + text: string, + } + + diagnostics: func(doc: document) -> list; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index 2f8afeb49..d619cba19 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -152,3 +152,14 @@ world plugin-endpoints { world endpoints { export endpoints-provider; } + +/// A plugin with diagnostics under `[[contributes.providers]]`. +world plugin-diagnostics { + include plugin; + export diagnostics-provider; +} + +/// `diagnostics` is already the released host interface name. +world editor-diagnostics { + export diagnostics-provider; +}