Files
openidea/crates/ide-plugin/src/runtime.rs
l0ng-ai f55cdcdc31 fix(plugins): hand plugins paths in one form on Windows (#1500)
* fix(plugins): hand plugins paths in one form on Windows

Plugins got the project root, editor path, provider documents, code action edits, vcs-status roots and find-files results as D:\… while event paths from the UI use /, so Checkstyle could not find the project of a saved file. The host now converts every path it hands to plugins with ipc_path::to_ipc.

* fix(checkstyle): match native Windows report paths

* test(plugins): use workbench IPC paths in host fixtures
2026-11-05 17:03:09 +08:00

1169 lines
46 KiB
Rust

//! Wasmtime glue for the `openidea:[email protected]` world (`wit/`): one shared
//! engine, one `Store` per plugin. Everything a call may do outside the
//! sandbox goes through [`HostApi`], implemented by the host; the imports
//! themselves are in `wit_host`.
use crate::types::{CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput,
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
};
use crate::types::{
FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use parking_lot::Mutex;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use wasmtime::component::{Component, HasSelf, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDeadline};
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types;
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.1";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
const STDERR_CAPACITY: usize = 256 << 10;
/// Message of a call interrupted through [`Runtime::cancel_handle`].
pub const CANCELLED: &str = "was cancelled";
/// What plugin code may ask of the IDE, per plugin. Implemented by the host,
/// which routes notifications and status items to windows and edits to open
/// documents. Capabilities are already checked when these are called.
pub trait HostApi: Send + Sync {
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let _ = (client, text);
Ok(())
}
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
let _ = (client, url);
Ok(())
}
/// Sets (`Some`) or removes the plugin's status bar item `id`.
fn status_item(&self, id: &str, item: Option<StatusItem>);
/// Roots of the open projects.
fn projects(&self) -> Vec<std::path::PathBuf>;
/// Unsaved text of an open editor's file.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies edits to the open document at `path` if it is still at `version`.
fn apply_edits(
&self,
client: Option<&str>,
path: &Path,
version: u64,
edits: Vec<ide_core::TextEdit>,
) -> Result<u64, HostError>;
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError>;
/// Sets, or (`None`) deletes.
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError>;
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError>;
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError>;
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
let _ = root;
Ok(Vec::new())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
/// Subscribes to (or unsubscribes from) a kind of workbench event.
/// Project files matching `glob` (root-relative), at most `limit`.
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
/// Replaces the plugin's diagnostics for `path`.
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError>;
/// Performs `request` (its host is granted), filling in secret placeholders.
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
fn secret_is_set(&self, name: &str) -> bool;
/// Runs the approved file of `process:<program>`.
fn run_process(
&self,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
stdin: Option<String>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<ProcessOutput, HostError>;
fn subscribe(&self, kind: EventKind, on: bool);
/// Starts a progress indicator; cancelling it from the UI cancels `cancel`.
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64;
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>);
fn task_cancelled(&self, id: u64) -> bool;
fn task_end(&self, id: u64);
/// The plugin's view (or custom editors) `view` must be rendered again.
fn refresh_view(&self, view: &str);
/// A message for the script of the `html` body of `view`.
fn post_view_message(&self, view: &str, message: String) -> Result<(), HostError>;
/// Opens a terminal in `client`'s window running the approved file of
/// `process:<program>` in `cwd`.
fn open_terminal(
&self,
client: Option<&str>,
title: String,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// A setting of the plugin as JSON (project over global over the schema default).
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
}
/// Shared compilation engine plus an epoch ticker thread for wall-clock limits.
pub struct PluginEngine {
engine: Engine,
linker: Linker<StoreData>,
}
impl PluginEngine {
pub fn new() -> Result<Self, String> {
let mut config = Config::new();
config.wasm_component_model(true).consume_fuel(true).epoch_interruption(true);
let engine = Engine::new(&config).map_err(|e| format!("wasm engine: {e}"))?;
let mut linker = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker).map_err(|e| e.to_string())?;
bindings::Plugin::add_to_linker::<_, HasSelf<_>>(&mut linker, |s| s).map_err(|e| e.to_string())?;
let weak = engine.weak();
std::thread::Builder::new()
.name("plugin-epoch".into())
.spawn(move || {
while let Some(engine) = weak.upgrade() {
engine.increment_epoch();
drop(engine);
std::thread::sleep(EPOCH_TICK);
}
})
.map_err(|e| e.to_string())?;
Ok(Self { engine, linker })
}
pub fn compile(&self, wasm: &Path) -> Result<Component, String> {
let bytes = std::fs::read(wasm).map_err(|e| format!("{}: {e}", wasm.display()))?;
self.compile_bytes(&bytes).map_err(|e| format!("{}: {e}", wasm.display()))
}
pub fn compile_bytes(&self, bytes: &[u8]) -> Result<Component, String> {
Component::new(&self.engine, bytes).map_err(|e| format!("not a valid plugin component: {e}"))
}
/// Compiles `wasm`, or loads it precompiled from `cache_dir`: the key is the module's hash plus the engine's compatibility hash,
/// so an engine upgrade simply misses and recompiles. Cache failures only
/// cost the compilation.
pub fn compile_cached(&self, wasm: &Path, cache_dir: &Path) -> Result<Component, String> {
let bytes = std::fs::read(wasm).map_err(|e| format!("{}: {e}", wasm.display()))?;
let file = cache_dir.join(format!("{}.cwasm", self.cache_key(&bytes)));
if file.is_file() {
// SAFETY: the cache directory belongs to the IDE (inside its plugins
// directory) and is written only by `store_precompiled` below from
// components this engine compiled; a corrupt file fails validation.
match unsafe { Component::deserialize_file(&self.engine, &file) } {
Ok(component) => return Ok(component),
Err(e) => {
tracing::info!("discarding precompiled {}: {e}", file.display());
let _ = std::fs::remove_file(&file);
}
}
}
let component = self.compile_bytes(&bytes).map_err(|e| format!("{}: {e}", wasm.display()))?;
store_precompiled(&component, &file);
Ok(component)
}
/// Fills the cache for `wasm` if needed (background warm-up). Returns the
/// cache file name, to keep it when pruning.
pub fn precompile(&self, wasm: &Path, cache_dir: &Path) -> Result<String, String> {
let bytes = std::fs::read(wasm).map_err(|e| format!("{}: {e}", wasm.display()))?;
let name = format!("{}.cwasm", self.cache_key(&bytes));
let file = cache_dir.join(&name);
if !file.is_file() {
let component = self.compile_bytes(&bytes)?;
store_precompiled(&component, &file);
}
Ok(name)
}
fn cache_key(&self, bytes: &[u8]) -> String {
use std::hash::{Hash, Hasher};
let mut engine = std::collections::hash_map::DefaultHasher::new();
self.engine.precompile_compatibility_hash().hash(&mut engine);
let module = blake3::hash(bytes).to_hex();
format!("{}-{:016x}", &module[..32], engine.finish())
}
pub fn engine(&self) -> &Engine {
&self.engine
}
/// Rejects a component this host can't run, with a message that says what
/// to do: one importing interfaces outside the plugin API and WASI, or one
/// built for a plugin API this host doesn't implement (another major, or a
/// newer minor), or one that doesn't link: an import the host lacks or
/// declares with another type (same API version, different WIT), or a
/// missing plugin export. Returns the API version the component was built
/// for (`None`: it uses no plugin interface).
pub fn check_component(&self, component: &Component) -> Result<Option<semver::Version>, String> {
let ty = component.component_type();
let imports: Vec<String> = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect();
let foreign: Vec<&str> = imports
.iter()
.map(String::as_str)
.filter(|i| !(i.starts_with(PLUGIN_PACKAGE_PREFIX) || i.starts_with("wasi:")))
.collect();
if !foreign.is_empty() {
return Err(format!("the component imports interfaces the IDE does not provide: {}", foreign.join(", ")));
}
let exports = ty.exports(&self.engine).map(|(name, _)| name.to_owned()).collect::<Vec<_>>();
let built_for = component_api(imports.iter().chain(&exports).map(String::as_str))?;
if let Some(v) = &built_for {
check_api(v, &host_api())?;
}
let pre = self.linker.instantiate_pre(component).map_err(|e| link_error(&format!("{e:#}")))?;
bindings::PluginIndices::new(&pre).map_err(|e| link_error(&format!("{e:#}")))?;
Ok(built_for)
}
}
/// Explains a component that doesn't link against this host's plugin API.
fn link_error(detail: &str) -> String {
format!(
"the component doesn't match the plugin API of this IDE ({API_VERSION}): {detail}. \
If it was built for a newer OpenIDEA, update OpenIDEA; otherwise rebuild it with the {API_VERSION} SDK"
)
}
/// Interface names of the plugin API start with this (`openidea:plugin/[email protected]`).
const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/";
/// The plugin API this host implements: the version of the `wit/` package.
pub fn host_api() -> semver::Version {
semver::Version::parse(ide_extension::ENGINE_VERSION).expect("ENGINE_VERSION is a semantic version")
}
/// The plugin API version a component was built for: the highest version on
/// its `openidea:plugin/*` imports and exports. An unversioned plugin
/// interface is an error, since nothing could tell compatible hosts apart.
pub fn component_api<'a>(names: impl IntoIterator<Item = &'a str>) -> Result<Option<semver::Version>, String> {
let mut highest: Option<semver::Version> = None;
for name in names {
let Some(rest) = name.strip_prefix(PLUGIN_PACKAGE_PREFIX) else { continue };
let Some((_, version)) = rest.split_once('@') else {
return Err(format!("the component uses the unversioned plugin interface {name:?}"));
};
let version =
semver::Version::parse(version).map_err(|e| format!("bad plugin API version in {name:?}: {e}"))?;
if highest.as_ref().is_none_or(|h| version > *h) {
highest = Some(version);
}
}
Ok(highest)
}
/// A component built for `built_for` runs on a host implementing `host` when
/// the major versions match and the host's minor is at least as new (1.x only
/// adds; patches change no interface; 0.x makes no promise, so there every
/// minor is its own API).
pub fn check_api(built_for: &semver::Version, host: &semver::Version) -> Result<(), String> {
let (b, h) = ((built_for.major, built_for.minor), (host.major, host.minor));
let same_line = b.0 == h.0 && (h.0 > 0 || b.1 == h.1);
if same_line && b <= h {
return Ok(());
}
let newer = b > h;
let (b, h) = (format!("{}.{}", b.0, b.1), format!("{}.{}", h.0, h.1));
Err(if newer {
format!("the component was built for plugin API {b}, newer than this IDE's {h}: update OpenIDEA")
} else {
format!("the component was built for plugin API {b}; this IDE runs {h}: rebuild it with the {h} SDK")
})
}
fn store_precompiled(component: &Component, file: &Path) {
let Ok(bytes) = component.serialize() else { return };
if let Some(dir) = file.parent() {
let _ = std::fs::create_dir_all(dir);
}
// Unique per write: two threads may store the same component at once, and a
// shared temporary file could be renamed into place half written.
static WRITES: AtomicU64 = AtomicU64::new(0);
let tmp = file.with_extension(format!("tmp{}-{}", std::process::id(), WRITES.fetch_add(1, Ordering::Relaxed)));
if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, file)).is_err() {
let _ = std::fs::remove_file(&tmp);
}
}
/// Interrupts the running call: the epoch callback traps on `flag`, host
/// operations (a fetch) watch `token`. Reset when a call starts.
#[derive(Default)]
pub struct CallCancel {
flag: AtomicBool,
token: Mutex<CancellationToken>,
}
impl CallCancel {
pub fn cancel(&self) {
self.flag.store(true, Ordering::Release);
self.token.lock().cancel();
}
/// Cooperative cancellation: host operations (`net`, `process`, fetch)
/// return `cancelled` and tasks report it; the guest keeps its instance.
pub fn cancel_soft(&self) {
self.token.lock().cancel();
}
pub(crate) fn token(&self) -> CancellationToken {
self.token.lock().clone()
}
fn is_set(&self) -> bool {
self.flag.load(Ordering::Acquire)
}
fn reset(&self) {
self.flag.store(false, Ordering::Release);
*self.token.lock() = CancellationToken::new();
}
}
pub struct StoreData {
/// The running call is interrupted at the next epoch tick past this.
pub(crate) deadline: Instant,
/// Wall-clock limit of the running call (for the message).
pub(crate) timeout: Duration,
pub(crate) call_started: Instant,
pub(crate) cancel: Arc<CallCancel>,
pub(crate) limits: Limits,
wasi: WasiCtx,
pub(crate) table: ResourceTable,
store_limits: StoreLimits,
stderr: MemoryOutputPipe,
stderr_seen: usize,
pub(crate) plugin_id: String,
pub(crate) granted: Vec<Capability>,
pub(crate) host: Arc<dyn HostApi>,
pub(crate) invocation: Invocation,
/// Tasks begun by the running call and not ended yet.
pub(crate) open_tasks: Vec<u64>,
pub(crate) last_report: Option<Instant>,
}
impl WasiView for StoreData {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.wasi, table: &mut self.table }
}
}
impl StoreData {
/// New guest stderr output since the last call (panic messages, eprintln!).
fn drain_stderr(&mut self) -> String {
let all = self.stderr.contents();
let new = String::from_utf8_lossy(&all[self.stderr_seen.min(all.len())..]).into_owned();
self.stderr_seen = all.len();
new
}
}
pub(crate) fn clip(mut s: String, max: usize) -> String {
if s.len() > max {
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
}
s
}
/// A call failed: either the plugin returned an error (it stays usable) or it
/// trapped / ran out of budget (the instance is poisoned and must be dropped).
#[derive(Debug)]
pub enum CallError {
/// The plugin returned an error.
Guest(GuestError),
/// The instance trapped (panic, fuel, memory, timeout) and is unusable.
Trap(String),
}
impl CallError {
fn missing_export(interface: &str) -> Self {
Self::Guest(GuestError::Failed(format!("the plugin does not export {interface}")))
}
}
impl std::fmt::Display for CallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Guest(e) => e.fmt(f),
Self::Trap(e) => f.write_str(e),
}
}
}
/// An error a plugin returned (the API's `error` variant).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GuestError {
/// A capability it needs is not granted.
Denied(String),
Stale,
NotFound(String),
Invalid(String),
/// The user cancelled, or the plugin is being stopped.
Cancelled,
Failed(String),
}
impl GuestError {
/// Whether this is the plugin failing, as opposed to an outcome the
/// user or the host caused (cancelling, a missing grant, bad input).
pub fn is_failure(&self) -> bool {
matches!(self, Self::Failed(_))
}
}
impl std::fmt::Display for GuestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Denied(c) => write!(f, "permission {c} was not granted"),
Self::Stale => f.write_str("the document changed meanwhile"),
Self::NotFound(m) => write!(f, "not found: {m}"),
Self::Invalid(m) => write!(f, "invalid: {m}"),
Self::Cancelled => f.write_str("cancelled"),
Self::Failed(m) => f.write_str(m),
}
}
}
impl From<GuestError> for ide_core::IdeError {
fn from(e: GuestError) -> Self {
use ide_core::error::ErrorKind;
let kind = match &e {
GuestError::Denied(_) => ErrorKind::PermissionDenied,
GuestError::Stale => ErrorKind::OutOfSync,
GuestError::NotFound(_) => ErrorKind::NotFound,
GuestError::Invalid(_) => ErrorKind::InvalidInput,
GuestError::Cancelled => ErrorKind::Cancelled,
GuestError::Failed(_) => ErrorKind::Internal,
};
ide_core::IdeError::new(kind, e.to_string())
}
}
pub struct RuntimeSetup {
pub plugin_id: String,
pub version: String,
/// Capabilities granted to the plugin.
pub granted: Vec<Capability>,
pub host: Arc<dyn HostApi>,
pub limits: Limits,
}
pub struct Runtime {
store: Store<StoreData>,
exports: Exports,
limits: Limits,
version: String,
}
/// What the component exports: the `plugin` world, plus optional interfaces.
struct Exports {
plugin: bindings::Plugin,
syntax: Option<wit_syntax::Guest>,
providers: Option<wit_prov::Guest>,
views: Option<wit_view::Guest>,
}
/// Most results one syntax call may return (more are dropped).
const MAX_SYNTAX_ITEMS: usize = 200_000;
/// Most events per delivered batch.
const MAX_BATCH: usize = 1000;
fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
match e {
WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p),
WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p),
WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p),
WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p),
WorkbenchEvent::Scheduled => wit_events::Event::Scheduled,
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
action: a.action,
title: a.title,
source: a.source.name().to_owned(),
shortcut: a.shortcut,
}),
}
}
impl Runtime {
pub fn instantiate(engine: &PluginEngine, component: &Component, setup: RuntimeSetup) -> Result<Self, String> {
let stderr = MemoryOutputPipe::new(STDERR_CAPACITY);
// No preopened dirs, env, args or network: host imports are the only way out.
let wasi = WasiCtx::builder().stderr(stderr.clone()).build();
let store_limits = StoreLimitsBuilder::new()
.memory_size(setup.limits.memory_bytes)
.instances(32)
.tables(64)
.memories(8)
.trap_on_grow_failure(true)
.build();
let now = Instant::now();
let data = StoreData {
deadline: now + setup.limits.timeout,
timeout: setup.limits.timeout,
call_started: now,
cancel: Arc::default(),
limits: setup.limits,
wasi,
table: ResourceTable::new(),
store_limits,
stderr,
stderr_seen: 0,
plugin_id: setup.plugin_id,
granted: setup.granted,
host: setup.host,
invocation: Invocation::default(),
open_tasks: Vec::new(),
last_report: None,
};
let mut store = Store::new(&engine.engine, data);
store.limiter(|d| &mut d.store_limits);
// Checked every epoch tick: the per-call deadline and cancellation.
store.epoch_deadline_callback(|ctx| {
let data = ctx.data();
if data.cancel.is_set() {
Err(wasmtime::Error::msg(CANCELLED))
} else if Instant::now() >= data.deadline {
Err(wasmtime::Error::msg(format!("timed out after {:?}", data.timeout)))
} else {
Ok(UpdateDeadline::Continue(1))
}
});
arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?;
let api_hint = |e: wasmtime::Error| {
let msg = describe_trap(&e, &setup.limits);
if msg.contains("openidea:plugin/") { link_error(&msg) } else { msg }
};
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
let view_indices = wit_view::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 {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let providers = match provider_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let views = match view_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
Ok(Self {
store,
exports: Exports { plugin, syntax, providers, views },
limits: setup.limits,
version: setup.version,
})
}
/// The component exports `syntax-provider`.
pub fn has_syntax(&self) -> bool {
self.exports.syntax.is_some()
}
pub fn syntax_highlights(
&mut self,
language: &str,
text: &str,
timeout: Duration,
) -> Result<Vec<SyntaxToken>, CallError> {
let tokens = self.syntax_call(timeout, |s, store| s.call_highlights(store, language, text))?;
Ok(tokens
.into_iter()
.take(MAX_SYNTAX_ITEMS)
.map(|t| SyntaxToken { start: t.start, end: t.end, kind: clip(t.kind, 32) })
.collect())
}
pub fn syntax_outline(
&mut self,
language: &str,
text: &str,
timeout: Duration,
) -> Result<Vec<SyntaxSymbol>, CallError> {
let symbols = self.syntax_call(timeout, |s, store| s.call_outline(store, language, text))?;
Ok(symbols
.into_iter()
.take(MAX_SYNTAX_ITEMS)
.map(|s| SyntaxSymbol {
name: clip(s.name, MAX_TITLE),
detail: s.detail.map(|d| clip(d, MAX_TITLE)),
kind: clip(s.kind, 32),
start: s.start,
end: s.end,
name_start: s.name_start,
name_end: s.name_end,
parent: s.parent,
})
.collect())
}
pub fn syntax_folding(
&mut self,
language: &str,
text: &str,
timeout: Duration,
) -> Result<Vec<SyntaxFold>, CallError> {
let folds = self.syntax_call(timeout, |s, store| s.call_folding(store, language, text))?;
Ok(folds
.into_iter()
.take(MAX_SYNTAX_ITEMS)
.map(|f| SyntaxFold { start_line: f.start_line, end_line: f.end_line, kind: clip(f.kind, 32) })
.collect())
}
fn syntax_call<R>(
&mut self,
timeout: Duration,
f: impl FnOnce(&wit_syntax::Guest, &mut Store<StoreData>) -> wasmtime::Result<R>,
) -> Result<R, CallError> {
if self.exports.syntax.is_none() {
return Err(CallError::missing_export("syntax-provider"));
}
self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store))
}
/// The component exports `providers`.
pub fn has_providers(&self) -> bool {
self.exports.providers.is_some()
}
fn provider_call<R>(
&mut self,
timeout: Duration,
f: impl FnOnce(&wit_prov::Guest, &mut Store<StoreData>) -> wasmtime::Result<R>,
) -> Result<R, CallError> {
if self.exports.providers.is_none() {
return Err(CallError::missing_export("providers"));
}
self.call(Invocation::default(), timeout, |e, store| f(e.providers.as_ref().expect("checked"), store))
}
pub fn provide_completion(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
timeout: Duration,
) -> Result<Vec<CompletionItem>, CallError> {
let d = doc_to_wit(doc);
let items = self.provider_call(timeout, |p, s| p.call_completion(s, &d, pos_to_wit(at), trigger))?;
Ok(items
.into_iter()
.take(MAX_PROVIDER_ITEMS)
.map(|i| CompletionItem {
label: clip(i.label, MAX_TITLE),
detail: i.detail.map(|d| clip(d, MAX_TITLE)),
kind: i.kind.map(|k| clip(k, 32)),
insert_text: clip(i.insert_text, 64 << 10),
snippet: i.snippet,
replace: i.replace.map(range_from_wit),
sort_text: i.sort_text.map(|t| clip(t, 64)),
filter_text: i.filter_text.map(|t| clip(t, MAX_TITLE)),
documentation: i.documentation.map(|d| clip(d, 16 << 10)),
additional_edits: i
.additional_edits
.into_iter()
.take(MAX_ADDITIONAL_EDITS)
.map(edit_from_wit)
.collect(),
})
.collect())
}
pub fn provide_inline_completion(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Option<InlineSuggestion>, CallError> {
let d = doc_to_wit(doc);
let s = self.provider_call(timeout, |p, s| p.call_inline_completion(s, &d, pos_to_wit(at)))?;
Ok(s.map(|s| InlineSuggestion {
insert_text: clip(s.insert_text, 64 << 10),
replace: s.replace.map(range_from_wit),
}))
}
pub fn provide_code_actions(
&mut self,
doc: &ProviderDocument,
selection: ide_core::Range,
timeout: Duration,
) -> Result<Vec<CodeAction>, CallError> {
let d = doc_to_wit(doc);
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
}
/// The edits of an action `provide_code_actions` returned with `data`.
pub fn resolve_code_action(
&mut self,
doc: &ProviderDocument,
action: &CodeAction,
timeout: Duration,
) -> Result<CodeAction, CallError> {
let d = doc_to_wit(doc);
let a = code_action_to_wit(action);
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
resolved.map(code_action_from_wit).map_err(|e| CallError::Guest(e.into()))
}
pub fn provide_definition(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Vec<Location>, CallError> {
let d = doc_to_wit(doc);
let locations = self.provider_call(timeout, |p, s| p.call_definition(s, &d, pos_to_wit(at)))?;
Ok(locations.into_iter().take(MAX_PROVIDER_ITEMS).map(location_from_wit).collect())
}
pub fn provide_line_markers(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<LineMarker>, CallError> {
let d = doc_to_wit(doc);
let markers = self.provider_call(timeout, |p, s| p.call_line_markers(s, &d))?;
Ok(markers
.into_iter()
.take(MAX_PROVIDER_ITEMS)
.map(|m| LineMarker {
line: m.line,
icon: m.icon.map(|i| clip(i, 32)),
tooltip: clip(m.tooltip, 1024),
target: m.target.map(location_from_wit),
})
.collect())
}
pub fn activate(&mut self) -> Result<(), CallError> {
let data = self.store.data();
let ctx = wit_types::Context {
plugin_id: data.plugin_id.clone(),
plugin_version: self.version.clone(),
api_version: API_VERSION.into(),
capabilities: data.granted.iter().map(|c| c.to_string()).collect(),
};
self.call(Invocation::default(), self.limits.timeout, |e, store| {
e.plugin.openidea_plugin_lifecycle().call_activate(store, &ctx)
})
.and_then(|r| r.map_err(|e| CallError::Guest(e.into())))
}
pub fn run_command(
&mut self,
id: &str,
arg: Option<&str>,
invocation: Invocation,
) -> Result<Option<String>, CallError> {
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let r = e.plugin.openidea_plugin_commands().call_run(
&mut *store,
id,
wasmtime::component::Resource::new_borrow(ctx.rep()),
arg,
);
store.data_mut().drop_invocation(ctx);
r
})?;
result.map_err(|e| CallError::Guest(e.into()))
}
/// The component exports `view-provider`.
pub fn has_views(&self) -> bool {
self.exports.views.is_some()
}
/// Content of a view or custom editor (`invocation.editor`: its document).
pub fn render_view(
&mut self,
view: &str,
viewport: Option<Viewport>,
invocation: Invocation,
) -> Result<View, CallError> {
if self.exports.views.is_none() {
return Err(CallError::missing_export("view-provider"));
}
let viewport = viewport.map(|v| wit_vt::Viewport { first_row: v.first_row, rows: v.rows.min(MAX_TABLE_ROWS) });
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let views = e.views.as_ref().expect("checked");
let r =
views.call_render(&mut *store, view, viewport, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
match result {
Ok(v) => Ok(view_from_wit(v)),
Err(e) => Err(CallError::Guest(e.into())),
}
}
/// A user action in a view or custom editor.
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
if self.exports.views.is_none() {
return Err(CallError::missing_export("view-provider"));
}
let event = event_to_wit_view(event);
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let views = e.views.as_ref().expect("checked");
let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
result.map_err(|e| CallError::Guest(e.into()))
}
/// Delivers a batch of events (from the window in `invocation`).
pub fn handle_events(&mut self, events: Vec<WorkbenchEvent>, invocation: Invocation) -> Result<(), CallError> {
let events: Vec<wit_events::Event> = events.into_iter().take(MAX_BATCH).map(event_to_wit).collect();
self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let r = e.plugin.openidea_plugin_event_handler().call_handle(
&mut *store,
&events,
wasmtime::component::Resource::new_borrow(ctx.rep()),
);
store.data_mut().drop_invocation(ctx);
r
})
}
/// Best effort; errors are logged.
pub fn deactivate(&mut self) {
if let Err(e) = self.call(Invocation::default(), self.limits.timeout, |e, store| {
e.plugin.openidea_plugin_lifecycle().call_deactivate(store)
}) {
tracing::warn!(plugin = %self.store.data().plugin_id, "deactivate failed: {e}");
}
}
/// Interrupts the running call when cancelled (the instance is then
/// poisoned, like after a timeout). Reset when the next call starts.
pub fn cancel_handle(&self) -> Arc<CallCancel> {
self.store.data().cancel.clone()
}
fn call<R>(
&mut self,
invocation: Invocation,
timeout: Duration,
f: impl FnOnce(&Exports, &mut Store<StoreData>) -> wasmtime::Result<R>,
) -> Result<R, CallError> {
arm(&mut self.store, &self.limits, timeout).map_err(|e| CallError::Trap(e.to_string()))?;
self.store.data_mut().invocation = invocation;
let result = f(&self.exports, &mut self.store);
let data = self.store.data_mut();
data.invocation = Invocation::default();
// Tasks don't outlive the call that began them.
for id in std::mem::take(&mut data.open_tasks) {
data.host.task_end(id);
}
let stderr = data.drain_stderr();
if !stderr.trim().is_empty() {
tracing::info!(plugin = %data.plugin_id, "stderr: {}", stderr.trim_end());
}
match result {
Ok(r) => Ok(r),
Err(e) => {
let mut msg = describe_trap(&e, &self.limits);
// Typically the panic message: "panicked at src/lib.rs:1:2:\nboom".
let detail: Vec<&str> =
stderr.lines().filter(|l| !l.trim().is_empty() && !l.starts_with("note:")).collect();
if !detail.is_empty() {
let tail = detail[detail.len().saturating_sub(4)..].join("\n");
msg = format!("{msg}\n{}", clip(tail, 1024));
}
Err(CallError::Trap(msg))
}
}
}
}
/// Most items one provider call may return (more are dropped).
const MAX_PROVIDER_ITEMS: usize = 10_000;
fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position {
wit_types::Position { line: p.line, character: p.col }
}
fn range_to_wit(r: ide_core::Range) -> wit_types::Range {
wit_types::Range { start: pos_to_wit(r.start), end: pos_to_wit(r.end) }
}
fn range_from_wit(r: wit_types::Range) -> ide_core::Range {
ide_core::Range::new(
ide_core::Pos::new(r.start.line, r.start.character),
ide_core::Pos::new(r.end.line, r.end.character),
)
}
fn location_from_wit(l: wit_types::Location) -> Location {
Location { path: std::path::PathBuf::from(l.path), range: range_from_wit(l.range) }
}
fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document {
wit_prov::Document { path: to_ipc(&d.path), language: d.language.clone(), version: d.version, text: d.text.clone() }
}
/// Resets the budgets for the next call: fuel, deadline, cancellation.
fn arm(store: &mut Store<StoreData>, limits: &Limits, timeout: Duration) -> wasmtime::Result<()> {
store.set_fuel(limits.fuel)?;
let data = store.data_mut();
let now = Instant::now();
data.call_started = now;
data.deadline = now + timeout;
data.timeout = timeout;
data.last_report = None;
data.cancel.reset();
// The callback above runs every tick and decides.
store.set_epoch_deadline(1);
Ok(())
}
fn describe_trap(e: &wasmtime::Error, limits: &Limits) -> String {
match e.downcast_ref::<wasmtime::Trap>() {
Some(wasmtime::Trap::OutOfFuel) => format!("exceeded its CPU budget ({} fuel per call)", limits.fuel),
Some(wasmtime::Trap::Interrupt) => CANCELLED.to_owned(),
Some(wasmtime::Trap::UnreachableCodeReached) => "crashed (panic / unreachable)".to_owned(),
Some(trap) => format!("trapped: {trap}"),
None => {
let root = e.root_cause().to_string();
if root.contains("memory") {
format!("{root} (memory limit {} MiB)", limits.memory_bytes >> 20)
} else {
root
}
}
}
}
/// Most table rows one render returns (and a viewport asks for).
const MAX_TABLE_ROWS: u32 = 2_000;
/// Most tree / list items and form fields per view.
const MAX_VIEW_ITEMS: usize = 20_000;
const MAX_CELL: usize = 64 << 10;
const MAX_HTML: usize = 4 << 20;
/// Most additional edits of a completion item.
const MAX_ADDITIONAL_EDITS: usize = 100;
fn edit_from_wit(e: wit_types::TextEdit) -> ide_core::TextEdit {
ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text }
}
fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
CodeAction {
title: clip(a.title, MAX_TITLE),
kind: a.kind.map(|k| clip(k, 64)),
preferred: a.preferred,
edits: a
.edits
.into_iter()
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
.collect(),
data: a.data.map(|d| clip(d, 64 << 10)),
}
}
fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
wit_prov::CodeAction {
title: a.title.clone(),
kind: a.kind.clone(),
preferred: a.preferred,
edits: a
.edits
.iter()
.map(|(path, edits)| wit_prov::FileEdit {
path: to_ipc(path),
edits: edits
.iter()
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
.collect(),
})
.collect(),
data: a.data.clone(),
}
}
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
s.map(|s| clip(s, max))
}
fn view_from_wit(v: wit_vt::View) -> View {
let body = match v.body {
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
wit_vt::Body::Other(e) => {
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
}
wit_vt::Body::Tree(items) => ViewBody::Tree(
items
.into_iter()
.take(MAX_VIEW_ITEMS)
.map(|i| ViewTreeItem {
key: clip(i.key, 1024),
parent: opt_clip(i.parent, 1024),
label: clip(i.label, MAX_TITLE),
icon: opt_clip(i.icon, 64),
detail: opt_clip(i.detail, MAX_TITLE),
expandable: i.expandable,
expanded: i.expanded,
})
.collect(),
),
wit_vt::Body::List(items) => ViewBody::List(
items
.into_iter()
.take(MAX_VIEW_ITEMS)
.map(|i| ViewListItem {
key: clip(i.key, 1024),
label: clip(i.label, MAX_TITLE),
icon: opt_clip(i.icon, 64),
detail: opt_clip(i.detail, MAX_TITLE),
})
.collect(),
),
wit_vt::Body::Form(fields) => ViewBody::Form(
fields
.into_iter()
.take(MAX_VIEW_ITEMS)
.filter_map(|f| {
let value = match f.value {
wit_vt::FieldValue::Text(t) => FieldValue::Text(clip(t, MAX_CELL)),
wit_vt::FieldValue::Multiline(t) => FieldValue::Multiline(clip(t, 1 << 20)),
wit_vt::FieldValue::Checkbox(b) => FieldValue::Checkbox(b),
wit_vt::FieldValue::Choice((options, selected)) => FieldValue::Choice {
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
selected: clip(selected, MAX_TITLE),
},
// A field type of a newer API: left out.
wit_vt::FieldValue::Other(_) => return None,
};
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
})
.collect(),
),
wit_vt::Body::Table(t) => ViewBody::Table(ViewTable {
columns: t.columns.into_iter().take(1000).map(|c| clip(c, MAX_TITLE)).collect(),
total_rows: t.total_rows,
first_row: t.first_row,
rows: t
.rows
.into_iter()
.take(MAX_TABLE_ROWS as usize)
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
.collect(),
editable: t.editable,
}),
wit_vt::Body::Html(h) => ViewBody::Html(clip(h, MAX_HTML)),
};
View {
toolbar: v
.toolbar
.into_iter()
.take(32)
.map(|b| ViewButton {
id: clip(b.id, 64),
label: clip(b.label, MAX_TITLE),
icon: opt_clip(b.icon, 64),
enabled: b.enabled,
})
.collect(),
body,
status: opt_clip(v.status, 1024),
}
}
fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
use wit_vt::ViewEvent as W;
match e {
ViewEvent::Button { id } => W::Button(id),
ViewEvent::Activate { key } => W::Activate(key),
ViewEvent::Select { key } => W::Select(key),
ViewEvent::Expand { key } => W::Expand(key),
ViewEvent::Collapse { key } => W::Collapse(key),
ViewEvent::Field { key, value } => W::Field((key, value)),
ViewEvent::Cell { row, column, value } => W::Cell(wit_vt::CellEdit { row, column, value }),
ViewEvent::InsertRow { before } => W::InsertRow(before),
ViewEvent::DeleteRows { rows } => W::DeleteRows(rows),
ViewEvent::Message { text } => W::Message(text),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn provider_and_resolved_action_paths_match_ipc_events() {
for (path, expected) in [
(r"C:\project\Example.java", "C:/project/Example.java"),
(r"\\?\C:\project\Example.java", "C:/project/Example.java"),
(r"\\server\share\Example.java", "//server/share/Example.java"),
] {
let doc = ProviderDocument { path: path.into(), ..Default::default() };
assert_eq!(doc_to_wit(&doc).path, expected);
let action = CodeAction {
title: "Fix".into(),
kind: None,
preferred: false,
edits: vec![(path.into(), Vec::new())],
data: None,
};
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
}
}
fn ext(kind: &str) -> wit_types::Extension {
wit_types::Extension { kind: kind.into(), data: "{}".into() }
}
#[test]
fn bodies_and_fields_of_a_newer_api_degrade() {
let view = |body| wit_vt::View { toolbar: Vec::new(), body, status: None };
let shown = view_from_wit(view(wit_vt::Body::Other(ext("markdown"))));
assert_eq!(shown.body, ViewBody::Message("This view needs a newer version of OpenIDEA (markdown).".into()));
let field = |key: &str, value| wit_vt::Field { key: key.into(), label: key.into(), value };
let form = view_from_wit(view(wit_vt::Body::Form(vec![
field("color", wit_vt::FieldValue::Other(ext("color"))),
field("name", wit_vt::FieldValue::Text("n".into())),
])));
let ViewBody::Form(fields) = form.body else { panic!("a form") };
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
}
}