feat(plugins): let plugins add to the hover of any language (#1931)

Plugin API 1.3 gains an optional hover-provider export (worlds plugin-hover
and hover): hover(doc, at) returns Markdown and the range it describes. A
plugin declares the hover kind under [[contributes.providers]] for any
language; the host asks it with the language server, under the edit-path
budget, and shows the server's hover first and each plugin's below a rule.
Declaring the kind needs engines.openidea ^1.3. The Rust SDK gains
openidea_plugin::hover and register_plugin!(T, hover); every shipped
component is rebuilt for the WIT change.
This commit is contained in:
Pedro Pessoa authored and GitHub committed 2026-11-07 15:29:16 +08:00
1 parent de4c325a28
commit 50567473f7
55 files changed
+804 -25

No files matched your search

+17 -2
View File
@@ -195,9 +195,24 @@ pub async fn lsp_hover(
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.hover(doc, pos, cancel).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(doc, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
host.end_request(request);
result
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
return result;
}
let server = match result {
Ok(server) => server,
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => None,
Err(e) => return Err(e),
};
Ok(crate::plugins::merge_hover(server, hovers))
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
+94
View File
@@ -0,0 +1,94 @@
use ide_lsp::types::{Hover, Markup};
use ide_plugin::PluginHover;
pub(crate) fn merge(server: Option<Hover>, plugin: Vec<PluginHover>) -> Option<Hover> {
if plugin.is_empty() {
return server;
}
let mut parts = Vec::with_capacity(plugin.len() + 1);
let mut range = None;
if let Some(h) = server {
range = h.range;
if !h.contents.value.trim().is_empty() {
parts.push(if h.contents.kind == "markdown" { h.contents.value } else { escape(&h.contents.value) });
}
}
for h in plugin {
range = range.or(h.range);
parts.push(h.markdown);
}
Some(Hover { contents: Markup { kind: "markdown".into(), value: parts.join("\n\n---\n\n") }, range })
}
fn escape(plain: &str) -> String {
let mut out = String::with_capacity(plain.len() + plain.len() / 4);
for c in plain.chars() {
match c {
'\n' => out.push_str(" \n"),
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '#' | '+' | '-' | '.' | '!'
| '|' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{Pos, Range};
fn range(line: u32, from: u32, to: u32) -> Range {
Range::new(Pos::new(line, from), Pos::new(line, to))
}
fn plugin(markdown: &str, at: Option<Range>) -> PluginHover {
PluginHover { markdown: markdown.into(), range: at }
}
fn server(kind: &str, value: &str, at: Option<Range>) -> Hover {
Hover { contents: Markup { kind: kind.into(), value: value.into() }, range: at }
}
#[test]
fn plugin_content_follows_the_server_hover() {
let merged = merge(
Some(server("markdown", "```php\nfunction route()\n```", Some(range(3, 4, 9)))),
vec![plugin("**orders.index** · `GET /orders`", Some(range(3, 10, 22)))],
)
.unwrap();
assert_eq!(merged.contents.kind, "markdown");
assert_eq!(merged.contents.value, "```php\nfunction route()\n```\n\n---\n\n**orders.index** · `GET /orders`");
assert_eq!(merged.range, Some(range(3, 4, 9)));
}
#[test]
fn without_a_server_hover_the_plugins_answer_alone() {
let merged = merge(None, vec![plugin("one", None), plugin("two", Some(range(1, 0, 3)))]).unwrap();
assert_eq!(merged.contents.value, "one\n\n---\n\ntwo");
assert_eq!(merged.range, Some(range(1, 0, 3)));
assert!(merge(None, Vec::new()).is_none());
}
#[test]
fn plain_text_from_the_server_keeps_its_characters() {
let merged = merge(Some(server("plaintext", "a_b *c*\nd", None)), vec![plugin("x", None)]).unwrap();
assert_eq!(merged.contents.value, "a\\_b \\*c\\* \nd\n\n---\n\nx");
}
#[test]
fn an_empty_server_hover_is_left_out() {
let merged = merge(Some(server("markdown", " ", None)), vec![plugin("x", None)]).unwrap();
assert_eq!(merged.contents.value, "x");
}
#[test]
fn no_plugin_content_keeps_the_server_hover() {
let kept = merge(Some(server("plaintext", "a_b", Some(range(0, 0, 1)))), Vec::new()).unwrap();
assert_eq!((kept.contents.kind.as_str(), kept.contents.value.as_str()), ("plaintext", "a_b"));
assert_eq!(kept.range, Some(range(0, 0, 1)));
}
}
+2
View File
@@ -16,6 +16,7 @@
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
mod api;
mod hover;
mod icon_extensions;
mod icon_patterns;
mod icon_settings;
@@ -38,6 +39,7 @@ pub mod views;
pub mod web;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub(crate) use hover::merge as merge_hover;
pub use icons::{
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
};
+13 -1
View File
@@ -38,6 +38,7 @@ pub enum ProviderKind {
Definition,
LineMarkers,
SemanticTokens,
Hover,
}
#[derive(Clone, Debug, Deserialize)]
@@ -116,6 +117,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::Hover) {
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
@@ -164,7 +168,7 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
use super::{LoadedPlugin, PluginHost, PluginStatus};
use ide_core::CancellationToken;
use ide_plugin::{
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
ProviderDocument, Runtime, SemanticToken,
};
use std::sync::Weak;
@@ -345,6 +349,14 @@ impl PluginHost {
.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))
.into_iter()
.filter_map(|(_, hover)| hover)
.collect()
}
/// Ghost text from the first plugin that has some. Not bound by the
/// edit-path soft deadline (it may need the network) but by `cancel`:
/// the editor cancels a request as soon as the user types on.
+55
View File
@@ -0,0 +1,55 @@
use ide_core::Pos;
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/hover")
}
fn host(dir: &Path) -> PluginHost {
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
}
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
}
const ORDER: &str = "<?php\nclass Order\n{\n public function user() {}\n}\n$order->user;\n$order->total;\n";
#[test]
fn a_declared_name_gets_the_plugins_hover() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
let hovers = host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(5, 10));
assert_eq!(hovers.len(), 1);
assert_eq!(hovers[0].markdown, "**user** · declared on line 4");
let range = hovers[0].range.unwrap();
assert_eq!((range.start.line, range.start.col, range.end.line, range.end.col), (5, 8, 5, 12));
assert!(host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(6, 10)).is_empty());
}
#[test]
fn only_declared_languages_are_asked() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
assert!(host.provide_hover(&doc(tmp.path(), "javascript", ORDER), Pos::new(5, 10)).is_empty());
}
#[test]
fn the_kind_needs_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let pkg = tmp.path().join("old");
std::fs::create_dir_all(pkg.join("dist")).unwrap();
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
}
+35 -4
View File
@@ -4,7 +4,7 @@
//! themselves are in `wit_host`.
use crate::types::{
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument, SemanticToken,
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover, ProviderDocument, SemanticToken,
};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
@@ -15,8 +15,8 @@ use crate::types::{
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::{
bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings, syntax_bindings,
views_bindings,
bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings,
syntax_bindings, views_bindings,
};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
@@ -33,6 +33,7 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types;
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
@@ -524,6 +525,7 @@ struct Exports {
views: Option<wit_view::Guest>,
semantic: Option<wit_sem::Guest>,
services: Option<wit_svc::Guest>,
hover: Option<wit_hover::Guest>,
}
/// Most results one syntax call may return (more are dropped).
@@ -605,6 +607,7 @@ impl Runtime {
let view_indices = wit_view::GuestIndices::new(&pre).ok();
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
let syntax = match syntax_indices {
@@ -631,9 +634,13 @@ impl Runtime {
Some(indices) => Some(indices.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,
};
Ok(Self {
store,
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services },
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover },
limits: setup.limits,
version: setup.version,
})
@@ -891,6 +898,29 @@ impl Runtime {
.collect())
}
pub fn provide_hover(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Option<PluginHover>, CallError> {
if self.exports.hover.is_none() {
return Err(CallError::missing_export("hover-provider"));
}
let d = wit_hover::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let hover = self.call(Invocation::default(), timeout, |e, store| {
e.hover.as_ref().expect("checked").call_hover(store, &d, pos_to_wit(at))
})?;
Ok(hover
.filter(|h| !h.markdown.trim().is_empty())
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
}
pub fn activate(&mut self) -> Result<(), CallError> {
let data = self.store.data();
let ctx = wit_types::Context {
@@ -1068,6 +1098,7 @@ impl Runtime {
const MAX_PROVIDER_ITEMS: usize = 10_000;
const MAX_SEMANTIC_TOKENS: usize = 1 << 16;
const MAX_TOKEN_MODIFIERS: usize = 8;
const MAX_HOVER_BYTES: usize = 64 * 1024;
fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position {
wit_types::Position { line: p.line, character: p.col }
+6
View File
@@ -426,6 +426,12 @@ pub struct SemanticToken {
pub modifiers: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginHover {
pub markdown: String,
pub range: Option<ide_core::Range>,
}
// ---- declarative views (wit/views.wit) ---------------------------------------
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
+7
View File
@@ -834,3 +834,10 @@ pub(crate) mod semantic_bindings {
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
pub(crate) mod hover_bindings {
wasmtime::component::bindgen!({
world: "hover", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
+1
View File
@@ -29,6 +29,7 @@ fn components() -> Vec<PathBuf> {
repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"),
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"),
];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
Binary file not shown.
@@ -1 +1 @@
670aec4d79e92537efb7048698bbb8f236cd6006524c74459891b03c9b613450
885badd4727639ad3205b2551d2b8406490fc05b72c684c5d17f09aadd9541bd
+333
View File
@@ -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-hover"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-hover"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
+7
View File
@@ -0,0 +1,7 @@
# Hover test fixture
Over a name the PHP document declares as `function <name>(`, shows
`**<name>** · declared on line <n>` for the name's range; nothing elsewhere.
Used by `cargo test -p ide-host --test plugin_hover` to exercise the optional
`hover-provider` export. It is not a marketplace plugin.
Binary file not shown.
@@ -0,0 +1 @@
ff1b39ac7850fbe4495e274dd78cca4c906a20705e8b9a975f58cf564e8ff137
+9
View File
@@ -0,0 +1,9 @@
id = "test.hover"
name = "Hover fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
[[contributes.providers]]
kinds = ["hover"]
languages = ["php"]
+31
View File
@@ -0,0 +1,31 @@
use openidea_plugin::hover::{Document, Hover, HoverProvider};
use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin, text};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(id.into()))
}
}
impl HoverProvider for Fixture {
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> {
let text = doc.text.as_str();
let offset = text::offset(text, at)?;
let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
let start = text[..offset].rfind(|c: char| !word(c)).map_or(0, |i| i + 1);
let end = text[offset..].find(|c: char| !word(c)).map_or(text.len(), |n| offset + n);
let name = &text[start..end];
if name.is_empty() {
return None;
}
let declared = text.find(&format!("function {name}("))?;
let line = text::position(text, declared).line + 1;
let range = Range { start: text::position(text, start), end: text::position(text, end) };
Some(Hover::new(format!("**{name}** · declared on line {line}")).range(range))
}
}
register_plugin!(Fixture, hover);
Binary file not shown.
@@ -1 +1 @@
c913c2c94fecb43d00f73f3171cc2fda54caf7a28f170424b0addb1d40ed4cae
c9bb4bc3603d9e7c770cf13ad413f5b3a787e78dbeed8f41a3b8537c0740275d
Binary file not shown.
@@ -1 +1 @@
911388ae62843d7b1e4c40d91307fbe507535c60680a9626cc07d724b75a9dfb
a9cb7302b86e12e05cbbf87fb402609e2f88d3a463f6c6e96b254e643b76183d
Binary file not shown.
@@ -1 +1 @@
a3818b6ec40df8f141290537a7c024aadb02d7be1eb063ca9170799ca3271898
0be22488fe8f20a59f48fc61089b3db791a5d723d71e4d654bfd008038c01003
Binary file not shown.
@@ -1 +1 @@
9e4c12fdf905227128d97e92639cf336124929c37384e5f5796e27ebfc5a7b37
92ca920847c51036ac6dc7acd299ff9afaef9b4037e0112a0159bf50e822c7f3
+22 -2
View File
@@ -34,7 +34,7 @@ plugins/
├─ regex-tester/ # html view talking to its code, settings, the editor's selection
└─ license-header/ # file-opened events, quick fix, project scan in a task
sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:[email protected]`, world
@@ -402,7 +402,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"
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens", "hover"
languages = ["java", "xml"] # editor language ids; "*" for any
```
@@ -457,6 +457,26 @@ the language server's semantic tokens and win where they overlap one, so the
editor's colour scheme styles them like the server's. They are asked again
after each edit, under the edit-path budget; at most 65,536 per document.
#### Hover
The `hover` kind (API 1.3) adds Markdown to the hover of documents of the
declared languages, here too including languages the plugin doesn't
contribute. Register with `register_plugin!(MyPlugin, hover)` and implement
`openidea_plugin::hover::HoverProvider`:
```rust
impl HoverProvider for MyPlugin {
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> {
Some(Hover::new("**orders.index** · `GET /orders`"))
}
}
```
The host asks the language server and the plugins together, under the
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.
### Network and secrets
`host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
eb76da4bc9db84dcbad34143bac18c9ecf974276f793009791a15aab2eee291d
9384d8964c7c05d3df06521e0d724a932fdd17608cbcede13ec2acadfab3783c
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0a19948927a23de81b2a453b9992e021744093cbe41ea92daa9feb2b1bc9a2a9
9d7e857ef367eaa9748d1a2b05b7b45226c254aa578c29e1ed987c7353738acf
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
564bfd00694f27f35f09eb0b81d341207a755528b5fe7c46c96717b3868d0f65
3e11039d36306f51c728bf9d08030fe620de461fa5b0037700aea95c4d27714c
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
f3f447366fa38f9bf4911f35b6e899405b0e072ceee3ad5ea2fe90639d70fa53
ef50957a407c9aff9999fdd197a5e72c90b7ba77aedbd7569408262ab7e158be
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
85cad466adb94f2b58e62263617e885d1c47cf70db5e8b38942bd415f0d779af
7160b309f39fe973e67bb20d361384955aed6afe98f00db329c3026ffe923759
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
659446eda0b50aa0fb08f6af68f7d355fcaf3342847e2e579999dcf52a7a7399
1f1afa285a64a7f7758f08a80306d6397c8aee4d2ac7ed43369e77aae61add04
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
51ef9d43315ca9d7239999c222fbf7a2c01bc85c2b40f53f34a1a8e5d31baa6d
ddd9205da9fc947215ab90c505d8b443d0937b1dd11734e9ce94d277517c77a6
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
bdf9455c6e7c8c340c54d783f351220b38cffed36907e1b5a1f2e11801e6f5bb
c61a0ec6ffcbd71e590e5a16a6f08f2d2772481497edc77e11914cfb3d0bfcd1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7e4d00e1ea63b2eca48b9297667e04c05a9f9832f1dee2262272801d516b9a16
e11cd0b664ae058274808bc2b2152bac2738207979758d13bc0c0cd96c38ef4f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
00cbd2dd7f0937554633ba5e533f19be85f70579c7c6fedb6fe0228f8f63bf4c
59319cf4be14448b0126b07d074cc08c351cfd12a3de26f111c2445fe8bff664
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
eecf2cf561dd5e3e3edbf9f67ccb479573dd538a75d4426a1d086edf0a310043
59844ae52aa765af404b43f7895db94d0063a3a6fce5bd54bf9af680ccac357b
+38
View File
@@ -0,0 +1,38 @@
use crate::types::{Position, Range};
pub use crate::providers::Document;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hover {
pub markdown: String,
pub range: Option<Range>,
}
impl Hover {
pub fn new(markdown: impl Into<String>) -> Self {
Self { markdown: markdown.into(), range: None }
}
pub fn range(mut self, range: Range) -> Self {
self.range = Some(range);
self
}
}
pub trait HoverProvider {
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hover_can_name_the_range_it_describes() {
let range = Range { start: Position { line: 2, character: 4 }, end: Position { line: 2, character: 9 } };
let hover = Hover::new("**route** `orders.index`").range(range);
assert_eq!(hover.markdown, "**route** `orders.index`");
assert_eq!(hover.range, Some(range));
assert_eq!(Hover::new("x").range, None);
}
}
+41
View File
@@ -40,6 +40,7 @@
//! wasm32-wasip2`); see `plugins/README.md`.
pub mod host;
pub mod hover;
pub mod indexing;
#[cfg(not(target_arch = "wasm32"))]
pub mod mock;
@@ -155,6 +156,17 @@ pub mod semantic_bindings {
});
}
#[cfg(target_arch = "wasm32")]
#[doc(hidden)]
pub mod hover_bindings {
wit_bindgen::generate!({
world: "hover", path: "wit", pub_export_macro: true,
export_macro_name: "__export_hover_world",
default_bindings_module: "openidea_plugin::hover_bindings",
with: { "openidea:plugin/[email protected]": crate::bindings::openidea::plugin::types },
});
}
/// The plugin API version this SDK targets.
pub const API_VERSION: &str = "1.3";
@@ -215,6 +227,9 @@ macro_rules! __optional_exports {
(services, $plugin:ty, $component:ident) => {
$crate::__services_exports!($plugin, $component);
};
(hover, $plugin:ty, $component:ident) => {
$crate::__hover_exports!($plugin, $component);
};
(syntax, $plugin:ty, $component:ident) => {
impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for $component {
fn highlights(
@@ -384,6 +399,22 @@ macro_rules! __semantic_tokens_exports {
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! __hover_exports {
($plugin:ty, $component:ident) => {
impl $crate::hover_bindings::exports::openidea::plugin::hover_provider::Guest for $component {
fn hover(
doc: $crate::hover_bindings::exports::openidea::plugin::hover_provider::Document,
at: $crate::bindings::openidea::plugin::types::Position,
) -> ::core::option::Option<$crate::hover_bindings::exports::openidea::plugin::hover_provider::HoverContent> {
$crate::__rt::hover::<$plugin>(doc, at)
}
}
$crate::hover_bindings::__export_hover_world!($component with_types_in $crate::hover_bindings);
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! __plugin_exports {
@@ -639,6 +670,16 @@ pub mod __rt {
.collect()
}
pub fn hover<T: Plugin + crate::hover::HoverProvider + Default>(
d: crate::hover_bindings::exports::openidea::plugin::hover_provider::Document,
pos: b::types::Position,
) -> Option<crate::hover_bindings::exports::openidea::plugin::hover_provider::HoverContent> {
use crate::hover_bindings::exports::openidea::plugin::hover_provider as w;
let doc = crate::providers::Document { path: d.path, language: d.language, version: d.version, text: d.text };
with::<T, _>(|p| p.hover(&doc, at(pos)))
.map(|h| w::HoverContent { markdown: h.markdown, range: h.range.map(crate::host::__range_to) })
}
// ---- views ----
use crate::views as v;
+19
View File
@@ -0,0 +1,19 @@
package openidea:[email protected];
interface hover-provider {
use types.{position, range};
record document {
path: string,
language: string,
version: u64,
text: string,
}
record hover-content {
markdown: string,
range: option<range>,
}
hover: func(doc: document, at: position) -> option<hover-content>;
}
+9
View File
@@ -61,6 +61,11 @@ world plugin-semantic-tokens {
export semantic-tokens-provider;
}
world plugin-hover {
include plugin;
export hover-provider;
}
/// A plugin whose views show in the Services tool window
/// (`location = "services"`): their nodes' detail pane.
world plugin-services {
@@ -93,6 +98,10 @@ world semantic-tokens {
export semantic-tokens-provider;
}
world hover {
export hover-provider;
}
/// Only the `service-provider` export, generated next to `plugin` like `syntax`.
world services {
export service-provider;
+19
View File
@@ -0,0 +1,19 @@
package openidea:[email protected];
interface hover-provider {
use types.{position, range};
record document {
path: string,
language: string,
version: u64,
text: string,
}
record hover-content {
markdown: string,
range: option<range>,
}
hover: func(doc: document, at: position) -> option<hover-content>;
}
+9
View File
@@ -61,6 +61,11 @@ world plugin-semantic-tokens {
export semantic-tokens-provider;
}
world plugin-hover {
include plugin;
export hover-provider;
}
/// A plugin whose views show in the Services tool window
/// (`location = "services"`): their nodes' detail pane.
world plugin-services {
@@ -93,6 +98,10 @@ world semantic-tokens {
export semantic-tokens-provider;
}
world hover {
export hover-provider;
}
/// Only the `service-provider` export, generated next to `plugin` like `syntax`.
world services {
export service-provider;