дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
fbf57bed23
коміт
1446d5592c
87 files changed
+1858
-104
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(¤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<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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 @@
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
f757407f530839b8c43697e310926c5b141231bd8f2781ac92eee3a9de3f0f30
|
||||
@@ -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"
|
||||
@@ -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 @@
|
||||
cdd58d817f507452ed192e3a19071bd56740efc70c4a965b0d48e5b224fff1d4
|
||||
99cba883e8030d7679db366a4b18b8c456afccbcbc949e0d7411adc6411f2e00
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
44da806d4e0d4d370534d108bd90406bcc475bac17cf69d0d323d341b79deb9a
|
||||
afcf583238dddd20d0d709ce7619fada0e60916f9e3c039569d37dec75354471
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
6d61b436c76c26b7edf444c89c8e71091ca7c5bccfba9ae79e770f70f8b9013d
|
||||
a1ce41144630be398bb63cffca26005955de0511a17c71dc286e7762b19e9cab
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
b4fedecefcc934cd6ddc49c3ffe948468a7603930a47c3594be85f5f01758fab
|
||||
bbfc134f2121f301bbbcb0f0d596d7b3cc059455bba5ca78992e8bfa6630ce42
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e593cd53d3757c3a738121ec2fcc3840a9d1c0dcc6ea1e17134c9d53ef695ebc
|
||||
b7f45420859ce9540e0aae1fd803eee13529ef63d0a2f0b6ba9e43a5958eb457
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3f49e3bb7f523103f70a39087e96500b5cd806ab60de60fceb480ca54ad4aa1d
|
||||
041aa03873ff1d8133f181e86aab2ad3fda41e46503886754114f3abef3643da
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
f3290c6ef1e794c95d3e8993aacecd58566e2ba690f966f52613a2dbdfa21dd5
|
||||
edcad61ab8e71cff2297edd9b902359d76ef04446bdfc42aae34420ced4d7f26
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
7e95ef2102982c48740d664cf050c86d2f7015d438e30376c35c5a97c2fb8c1b
|
||||
aad3a25c3522fbff6f48c0c1e9de23b6d0d5361ba6658b744ad1590e9fdbe255
|
||||
@@ -1 +1 @@
|
||||
58bcfb533bf386c0f0179e6775be834aa2d30f1b5e88e7ca00ad662bdd35699e
|
||||
cfb432ea8943bf9b7e2f3ad3708df35260eebd04b3ac059c7643b378a35cd036
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
12e92a08e3493a282b811592d1f044f293d6296b36b4c8e3b25ef80c5aca022f
|
||||
82f53a3f36f965c76575a08609439e0bc9cea37f1aef7cc1671e9ec72a17f120
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
eb481d1624b5d96488925f15dfa773a74c38cde490bc0c43b9a8caf6631168ce
|
||||
a8d40f2224246e81e015d576a5fa70e83779af9fade614d61212b33fbb0961e9
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
99e6543b156c2ea1a916f597224416db55f10bdce37af8943e2297a397fad0c9
|
||||
e60bcf1e179f6485e14de3755907c93344ef3cdf8158f01bea84343858ba7434
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
be318451a7ea7653bfd40a5624aa0951b4fb814c8c39a494ed3066c7ac8ef35e
|
||||
5cadc40061284bd5573f8323b02fb7ea7be18eb4323978b3f0b39337a9896774
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
9b4b83d13abbfaf88b04dff3ecc1beca7f12805fa651b53f1087b6e4ff2f0e38
|
||||
5377825f5801850d6ed7ec989f6e954fae477d81cd3ce0d5a2b8baeb9febcf74
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
74acb8ab9079c558a120c145319f01b54294cbf6fa4cbd33907a0d58e1ef2906
|
||||
e1414e522a45066616475fae229c4dade82ba16eedef49aac8d4c014522a0684
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
bb6e1eba6b9f28ca769bee0c6dad9ceab536d486efcbe0ad56bbfe621e2d7679
|
||||
5a50246a300a15c8b9e50457094442fe35f0efe1534feb6f5cb407e052404c07
|
||||
@@ -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 @@
|
||||
f5d442002dd3315f14ed7ebe873a7d1c858fa31a2840aebc7876366269d5fd6c
|
||||
61aaa8563701c6d4c147eaef87363ac0600c79fd9ef2ee8c8c1cc9c7e56794ed
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
0bebf24cc46dd2dac9c51fffcd4b9186f7e575c2d552e8d600c02fa5fe8fa9b0
|
||||
ab0a7b4daf59fda5938b10c42628bb141c94bc0d82464254a6b0157d74e98477
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
a4179ed839602e1d1697d738689d8936d22889b0d4e09ff953d6354a6d91f368
|
||||
5813b46ce5ec12b9ca4bdfe61d49c63ed1af736238398a1c2e03ca7c3633469c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
eb5f3a257f2551a29c3ad62b222b0641b07b7b9422780e1ffecafa7e39b5032d
|
||||
208cf58ff413f2ef75abfd0a4280d6e38393acf4e2d0a30f9c4d3294aa9ec82f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
74df219fa8bfbb491bc2f3c021e3df2a8daa9bed03606e8d558624918d9d7104
|
||||
f83c2371ee5dd6c9d8d027ec3515fb7850141ac694d873ef0463a64ec6edcc9d
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
19c90c5f90d6a26be8f4762d42cbe2f9281e311acc61467bd104da7ec25e5545
|
||||
f5baecb3f80efee4244bc096039ae7ac926c8bc73eb08fdfe5ecf81a4e2c38c9
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
a9bf8c6074ffb5d5335f07d6e5f1af6108faec0ca6e282a85684b991995baefc
|
||||
e4f62cf323d7186dcdc2972b0330086bc6279dcdc884d1997a84c1a589e739f8
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6519aa645fe62c9f963d1baf7cd0a36dd75787b37da46a7d1218f3983f828e79
|
||||
216af779424458d3fe4de470f2358f21ebc15d62be3d285f59cba19d61e59f3d
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
a95e6ff3977bf25cf106f6641cd8333124bb8615a8c327baefa068e36dd3d2ee
|
||||
b2994bcb35737abbd001535308fcd4d3f0bab4cd61e580d8af1312451e760fe3
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
674e164012c93a106a0c3977b1066d5a49524f8f8426fb09404e3b7a7340a629
|
||||
6d5d5b09fd48e9b48891a91b3dd13f9d0d2f1771685a549c683295370233ae17
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
f253276316839b418223b8292683e313d311ce41c9701c04c9c3fcc85e0638e5
|
||||
5e6457d34c922284779cdcc42455ed1df9ed8b9245793134be566dcd3c8aab93
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача