feat(plugins): refresh diagnostics while typing (#2048)

Closes #1952

API 1.3 adds a diagnostics provider for unsaved documents: the host sends versioned text for the declared languages after a 300 ms debounce, cancels superseded calls, drops late or stale answers under the editor-provider budget, and clears results with the document, project or plugin. Ranges are UTF-16. Actor panics are contained, queued work is rejected after a host panic, and diagnostics arriving after plugin cleanup are discarded. Verified on macOS with the diagnostics fixture.

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-08 04:14:27 +08:00
1 джерело fbf57bed23
коміт 1446d5592c
87 files changed
+1858 -104

No files matched your search

+2
Переглянути файл
@@ -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();
}
+245
Переглянути файл
@@ -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<String>,
}
#[derive(Default)]
pub(super) struct State {
closed: bool,
documents: HashMap<DocId, Document>,
generations: HashMap<String, u64>,
}
/// One incarnation of one project's documents, shared by all its editors.
pub(crate) struct Diagnostics {
state: Arc<Mutex<State>>,
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: Weak<Documents>,
root: std::path::PathBuf,
}
impl Diagnostics {
pub fn new(
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: &Arc<Documents>,
root: &Path,
) -> Arc<Self> {
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<Diagnostic>) {
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(&current) {
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<Mutex<State>>, SharedSink);
impl PluginHost {
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
// Clone before taking a project's state lock (edits register in the other order).
let 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(),
});
}
}
}
}
}
}
+71
Переглянути файл
@@ -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<navigation::NavigationIndex>,
endpoints: Mutex<endpoints::Endpoints>,
web_ptys: Mutex<BTreeMap<String, Vec<web::WebPtySession>>>,
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
@@ -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<LoadedPlugin>) {
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"));
+117 -8
Переглянути файл
@@ -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<String> {
let mut out: Vec<String> = 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::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
}
/// One coherent declaration snapshot, preserving the first match per plugin.
fn matching<T: FromIterator<(String, u64)>>(&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<String, u64> {
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<Arc<PluginHost>> {
impl PluginHost {
/// Usable plugins declaring `kind` for `language`.
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
pub(super) fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
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<ide_core::Diagnostic>)> {
self.provide_project_diagnostics(None, doc, cancel)
}
pub(super) fn diagnostic_plugins(
&self,
project: Option<&std::path::Path>,
language: &str,
) -> Vec<Arc<LoadedPlugin>> {
self.provider_plugins(ProviderKind::Diagnostics, language)
.into_iter()
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
.collect()
}
pub(super) fn provide_project_diagnostics(
&self,
project: Option<&std::path::Path>,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
std::thread::scope(|scope| {
let calls: Vec<_> = self
.diagnostic_plugins(project, &doc.language)
.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<PluginHover> {
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"]);
}
}
+9
Переглянути файл
@@ -27,6 +27,7 @@ pub struct Project {
pub languages: Arc<LanguageRegistry>,
pub settings: Arc<SettingsStore>,
pub documents: Arc<Documents>,
pub(crate) plugin_diagnostics: Arc<crate::plugins::diagnostics::Diagnostics>,
pub history: Arc<LocalHistory>,
/// 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,
+354
Переглянути файл
@@ -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<PluginHost> {
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<Mutex<Vec<(String, IdeEvent)>>>;
fn runtime_host(base: &Path, events: Events) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |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<Vec<Diagnostic>> {
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);
}
+81 -13
Переглянути файл
@@ -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<dyn FnOnce(&mut Option<Runtime>) + Send>;
fn catch_call<R>(f: impl FnOnce() -> Result<R, CallError>) -> Result<R, CallError> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|panic| {
let message = panic
.downcast_ref::<String>()
.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<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
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<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
soft: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.call_cancellable_before(cancel, grace, Some(Instant::now() + soft), f)
}
fn call_cancellable_before<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
deadline: Option<Instant>,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
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);
+29
Переглянути файл
@@ -632,6 +632,7 @@ struct Exports {
views: Option<wit_view::Guest>,
semantic: Option<wit_sem::Guest>,
details: Option<wit_detail::Guest>,
diagnostics: Option<crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider::Guest>,
hover: Option<wit_hover::Guest>,
commit_checks: Option<wit_cc::Guest>,
inlay: Option<wit_inlay::Guest>,
@@ -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<Vec<ide_core::Diagnostic>, 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,
+33 -20
Переглянути файл
@@ -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<diagnostics::Diagnostic>, source: &str) -> Vec<ide_core::Diagnostic> {
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()
}
+1
Переглянути файл
@@ -30,6 +30,7 @@ fn components() -> Vec<PathBuf> {
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"),
];
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
2652fb97735511f78576d5a16f685871bd7ab12df94795f14984bc819fe0b593
24726dac5ce264f5d451956def099e0002ec721e4828bc7852ad1dcabcb4bcba
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
41cf712d387210fea3e102f3f590b05c69b56814b742504a40c53f160874e926
833c378fabd0c3ec71e5c372e51049cb4fcd8d58e4992199eaf6090b3c1235d8
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-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"
+24
Переглянути файл
@@ -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"
+6
Переглянути файл
@@ -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.
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
f757407f530839b8c43697e310926c5b141231bd8f2781ac92eee3a9de3f0f30
+23
Переглянути файл
@@ -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"
+169
Переглянути файл
@@ -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<Option<String>, 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<Diagnostic> {
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<openidea_plugin::hover::Hover> {
None
}
}
impl openidea_plugin::inlay_hints::InlayHints for Fixture {
fn inlay_hints(&mut self, _: &Document, _: Range) -> Vec<openidea_plugin::inlay_hints::InlayHint> {
Vec::new()
}
}
impl openidea_plugin::providers::Providers for Fixture {}
impl openidea_plugin::endpoints::EndpointsProvider for Fixture {
fn endpoints(&mut self, _: &Invocation) -> Result<Vec<openidea_plugin::endpoints::Endpoint>, Error> {
Ok(Vec::new())
}
}
impl openidea_plugin::views::Views for Fixture {
fn render(
&mut self,
_: &str,
_: Option<openidea_plugin::views::Viewport>,
_: &Invocation,
) -> Result<openidea_plugin::views::View, Error> {
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<openidea_plugin::syntax::Token> {
Vec::new()
}
}
impl openidea_plugin::semantic_tokens::SemanticTokens for Fixture {
fn semantic_tokens(&mut self, _: &Document) -> Vec<openidea_plugin::semantic_tokens::SemanticToken> {
Vec::new()
}
}
impl openidea_plugin::details::Details for Fixture {
fn detail(&mut self, _: &str, _: &str, _: &Invocation) -> Result<Vec<openidea_plugin::details::Tab>, Error> {
Ok(Vec::new())
}
}
impl openidea_plugin::commit::CommitChecks for Fixture {
fn check(
&mut self,
_: &str,
_: &openidea_plugin::CommitDraft,
_: &Invocation,
) -> Result<Vec<openidea_plugin::commit::CommitProblem>, Error> {
Ok(Vec::new())
}
}
impl openidea_plugin::file_actions::FileActions for Fixture {
fn file_actions(&mut self, _: &Document, _: Range) -> Vec<openidea_plugin::file_actions::FileAction> {
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);
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
cdd58d817f507452ed192e3a19071bd56740efc70c4a965b0d48e5b224fff1d4
99cba883e8030d7679db366a4b18b8c456afccbcbc949e0d7411adc6411f2e00
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
44da806d4e0d4d370534d108bd90406bcc475bac17cf69d0d323d341b79deb9a
afcf583238dddd20d0d709ce7619fada0e60916f9e3c039569d37dec75354471
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6d61b436c76c26b7edf444c89c8e71091ca7c5bccfba9ae79e770f70f8b9013d
a1ce41144630be398bb63cffca26005955de0511a17c71dc286e7762b19e9cab
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b4fedecefcc934cd6ddc49c3ffe948468a7603930a47c3594be85f5f01758fab
bbfc134f2121f301bbbcb0f0d596d7b3cc059455bba5ca78992e8bfa6630ce42
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e593cd53d3757c3a738121ec2fcc3840a9d1c0dcc6ea1e17134c9d53ef695ebc
b7f45420859ce9540e0aae1fd803eee13529ef63d0a2f0b6ba9e43a5958eb457
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3f49e3bb7f523103f70a39087e96500b5cd806ab60de60fceb480ca54ad4aa1d
041aa03873ff1d8133f181e86aab2ad3fda41e46503886754114f3abef3643da
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f3290c6ef1e794c95d3e8993aacecd58566e2ba690f966f52613a2dbdfa21dd5
edcad61ab8e71cff2297edd9b902359d76ef04446bdfc42aae34420ced4d7f26
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7e95ef2102982c48740d664cf050c86d2f7015d438e30376c35c5a97c2fb8c1b
aad3a25c3522fbff6f48c0c1e9de23b6d0d5361ba6658b744ad1590e9fdbe255
+1 -1
Переглянути файл
@@ -1 +1 @@
58bcfb533bf386c0f0179e6775be834aa2d30f1b5e88e7ca00ad662bdd35699e
cfb432ea8943bf9b7e2f3ad3708df35260eebd04b3ac059c7643b378a35cd036
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
12e92a08e3493a282b811592d1f044f293d6296b36b4c8e3b25ef80c5aca022f
82f53a3f36f965c76575a08609439e0bc9cea37f1aef7cc1671e9ec72a17f120
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
eb481d1624b5d96488925f15dfa773a74c38cde490bc0c43b9a8caf6631168ce
a8d40f2224246e81e015d576a5fa70e83779af9fade614d61212b33fbb0961e9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
99e6543b156c2ea1a916f597224416db55f10bdce37af8943e2297a397fad0c9
e60bcf1e179f6485e14de3755907c93344ef3cdf8158f01bea84343858ba7434
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
be318451a7ea7653bfd40a5624aa0951b4fb814c8c39a494ed3066c7ac8ef35e
5cadc40061284bd5573f8323b02fb7ea7be18eb4323978b3f0b39337a9896774
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
9b4b83d13abbfaf88b04dff3ecc1beca7f12805fa651b53f1087b6e4ff2f0e38
5377825f5801850d6ed7ec989f6e954fae477d81cd3ce0d5a2b8baeb9febcf74
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
74acb8ab9079c558a120c145319f01b54294cbf6fa4cbd33907a0d58e1ef2906
e1414e522a45066616475fae229c4dade82ba16eedef49aac8d4c014522a0684
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
bb6e1eba6b9f28ca769bee0c6dad9ceab536d486efcbe0ad56bbfe621e2d7679
5a50246a300a15c8b9e50457094442fe35f0efe1534feb6f5cb407e052404c07
+49 -1
Переглянути файл
@@ -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<Diagnostic> {
// 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<diagnostic>` 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:<plugin-id>` and `plugin:<plugin-id>`, 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
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f5d442002dd3315f14ed7ebe873a7d1c858fa31a2840aebc7876366269d5fd6c
61aaa8563701c6d4c147eaef87363ac0600c79fd9ef2ee8c8c1cc9c7e56794ed
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0bebf24cc46dd2dac9c51fffcd4b9186f7e575c2d552e8d600c02fa5fe8fa9b0
ab0a7b4daf59fda5938b10c42628bb141c94bc0d82464254a6b0157d74e98477
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a4179ed839602e1d1697d738689d8936d22889b0d4e09ff953d6354a6d91f368
5813b46ce5ec12b9ca4bdfe61d49c63ed1af736238398a1c2e03ca7c3633469c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
eb5f3a257f2551a29c3ad62b222b0641b07b7b9422780e1ffecafa7e39b5032d
208cf58ff413f2ef75abfd0a4280d6e38393acf4e2d0a30f9c4d3294aa9ec82f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
74df219fa8bfbb491bc2f3c021e3df2a8daa9bed03606e8d558624918d9d7104
f83c2371ee5dd6c9d8d027ec3515fb7850141ac694d873ef0463a64ec6edcc9d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
19c90c5f90d6a26be8f4762d42cbe2f9281e311acc61467bd104da7ec25e5545
f5baecb3f80efee4244bc096039ae7ac926c8bc73eb08fdfe5ecf81a4e2c38c9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a9bf8c6074ffb5d5335f07d6e5f1af6108faec0ca6e282a85684b991995baefc
e4f62cf323d7186dcdc2972b0330086bc6279dcdc884d1997a84c1a589e739f8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6519aa645fe62c9f963d1baf7cd0a36dd75787b37da46a7d1218f3983f828e79
216af779424458d3fe4de470f2358f21ebc15d62be3d285f59cba19d61e59f3d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a95e6ff3977bf25cf106f6641cd8333124bb8615a8c327baefa068e36dd3d2ee
b2994bcb35737abbd001535308fcd4d3f0bab4cd61e580d8af1312451e760fe3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
674e164012c93a106a0c3977b1066d5a49524f8f8426fb09404e3b7a7340a629
6d5d5b09fd48e9b48891a91b3dd13f9d0d2f1771685a549c683295370233ae17
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f253276316839b418223b8292683e313d311ce41c9701c04c9c3fcc85e0638e5
5e6457d34c922284779cdcc42455ed1df9ed8b9245793134be566dcd3c8aab93
+17
Переглянути файл
@@ -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
+46
Переглянути файл
@@ -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<Diagnostic>;
}
#[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<Diagnostic> {
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());
}
}
+39 -32
Переглянути файл
@@ -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<D> = 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)
}
+40
Переглянути файл
@@ -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/[email protected]": crate::bindings::openidea::plugin::types,
"openidea:plugin/[email protected]": 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<T: Plugin + crate::diagnostics::DiagnosticsProvider + Default>(
d: crate::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider::Document,
) -> Vec<b::diagnostics::Diagnostic> {
let doc = crate::providers::Document { path: d.path, language: d.language, version: d.version, text: d.text };
with::<T, _>(|p| p.diagnostics(&doc)).iter().map(crate::host::__diagnostic_to).collect()
}
pub fn hover<T: Plugin + crate::hover::HoverProvider + Default>(
d: crate::hover_bindings::exports::openidea::plugin::hover_provider::Document,
pos: b::types::Position,
+26
Переглянути файл
@@ -110,6 +110,7 @@ struct State {
answers: VecDeque<Option<Vec<String>>>,
revealed_views: Vec<String>,
diagnostics: BTreeMap<String, Vec<Diagnostic>>,
provider_diagnostics: BTreeMap<String, Vec<Diagnostic>>,
secrets: BTreeMap<String, String>,
net: Option<NetHandler>,
requests: Vec<HttpRequest>,
@@ -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<String, Vec<Diagnostic>> {
self.state.borrow().provider_diagnostics.clone()
}
/// Views the plugin asked to refresh, in order.
pub fn refreshed_views(&self) -> Vec<String> {
self.state.borrow().refreshed.clone()
+45
Переглянути файл
@@ -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);
}
+15
Переглянути файл
@@ -0,0 +1,15 @@
package openidea:[email protected];
/// 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<diagnostic>;
}
+11
Переглянути файл
@@ -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;
}
+9
Переглянути файл
@@ -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.<plugin>.<command> actions", () => {
const cmd = (pluginId: string, id: string, extra: Record<string, unknown> = {}) => ({
pluginId,
+8 -1
Переглянути файл
@@ -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();
+15
Переглянути файл
@@ -0,0 +1,15 @@
package openidea:[email protected];
/// 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<diagnostic>;
}
+11
Переглянути файл
@@ -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;
}