mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 16:36:58 +00:00
* 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
1169 lines
46 KiB
Rust
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");
|
|
}
|
|
}
|