дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)
A plugin system built on WebAssembly components (wasmtime, WASI p2), with a typed WIT API (`openidea:[email protected]`) and a Rust SDK, shipped as a preview. - Extension points declared in `plugin.toml` (commands, languages, themes, icon themes, menus, key bindings, status items, settings, views, custom editors, file icons and templates, run detectors, editor providers), validated before anything takes effect and projected onto the workbench's own registries: plugin themes and file icon themes are chosen in Settings › Appearance and Quick Switch, plugin shortcuts are edited in Settings › Keymap, plugin problems are grouped in Settings › Inspections. - Capabilities with risk levels and consent at install, pinned `process:` programs, secrets in the OS keychain. - Runtime: one thread per active plugin, fuel/time/memory budgets, soft and hard deadlines on the editing path, cancellation, crash backoff, idle reclaim, a precompiled-component cache; remote projects run code plugins on `openidea-server`. - Web views and custom editors with a sandboxed page, a document bridge joined to the text editor's undo history, and split or preview layouts. - Distribution: signed `.oiplugin` packages, a static marketplace index with revocation, versioned installs with rollback, install from a `.zip` or a folder. The release workflow signs and publishes the official plugins and their index, which released builds use as the default marketplace. - Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion, Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor Night, Excalidraw, Graphviz, Regex Tester, License Header. Closes #732 Co-authored-by: Deng Liu <[email protected]> Co-authored-by: florentvdry <[email protected]>
Цей коміт міститься в:
450 files changed
+47018
-3058
No files matched your search
@@ -0,0 +1,312 @@
|
||||
//! One thread per active plugin owning its [`Runtime`].
|
||||
//!
|
||||
//! Callers queue jobs and wait on a reply channel instead of holding a lock
|
||||
//! across the call, so an edit-path caller can stop waiting at its soft
|
||||
//! deadline while the call runs on (and is interrupted at the hard deadline
|
||||
//! by the store's epoch callback), and the call can be cancelled from any
|
||||
//! thread. A trap poisons the instance: the actor drops it and ends; the
|
||||
//! host's supervisor decides when to start a new one.
|
||||
//!
|
||||
//! wasmtime's async API would let one thread drive every instance, but needs
|
||||
//! async host bindings throughout; with a plugin's instance only ever entered
|
||||
//! by one job at a time a parked thread per active plugin costs little, and
|
||||
//! idle reclaim ends it.
|
||||
|
||||
use crate::runtime::{CallCancel, CallError, Runtime};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type Job = Box<dyn FnOnce(&mut Option<Runtime>) + Send>;
|
||||
|
||||
/// Why an [`Actor::call`] produced no result.
|
||||
#[derive(Debug)]
|
||||
pub enum ActorError {
|
||||
/// The plugin's own outcome: an error it returned, or a trap (the actor has ended).
|
||||
Call(CallError),
|
||||
/// The caller's wait ran out; the result, if any, is dropped.
|
||||
SoftTimeout,
|
||||
/// The actor ended (an earlier call trapped, or it was stopped) before running the call.
|
||||
Stopped,
|
||||
/// The caller cancelled the call (`call_cancellable`).
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// A call queued with [`Actor::submit`].
|
||||
pub struct Pending<R> {
|
||||
/// The outcome and when the plugin's thread produced it.
|
||||
result: mpsc::Receiver<(Result<R, CallError>, Instant)>,
|
||||
give_up: Option<Instant>,
|
||||
alive: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl<R> Pending<R> {
|
||||
/// Waits for the outcome, until the call's soft deadline if it has one. A
|
||||
/// result produced after that deadline is dropped even when it is already
|
||||
/// there (the caller was not scheduled in time, or waited on another call
|
||||
/// first), so the deadline holds however busy the machine is; a failure is
|
||||
/// still reported.
|
||||
pub fn wait(self) -> Result<R, ActorError> {
|
||||
let outcome = match self.give_up {
|
||||
Some(t) => match self.result.recv_timeout(t.saturating_duration_since(Instant::now())) {
|
||||
Ok((Ok(_), done)) if done > t => return Err(ActorError::SoftTimeout),
|
||||
Ok((r, _)) => r,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => return Err(ActorError::SoftTimeout),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// Skipped as too late, or the actor ended.
|
||||
let alive = self.alive.load(Ordering::Acquire);
|
||||
return Err(if alive { ActorError::SoftTimeout } else { ActorError::Stopped });
|
||||
}
|
||||
},
|
||||
None => self.result.recv().map_err(|_| ActorError::Stopped)?.0,
|
||||
};
|
||||
outcome.map_err(ActorError::Call)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Actor {
|
||||
tx: Mutex<Option<mpsc::Sender<Job>>>,
|
||||
thread: Mutex<Option<JoinHandle<()>>>,
|
||||
cancel: Arc<CallCancel>,
|
||||
alive: Arc<AtomicBool>,
|
||||
/// The trap that ended the actor, until someone takes it to report it.
|
||||
crash: Arc<Mutex<Option<String>>>,
|
||||
busy: Arc<AtomicBool>,
|
||||
/// Milliseconds since `epoch` at the last call's end.
|
||||
last_used: Arc<AtomicU64>,
|
||||
epoch: Instant,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
/// Moves an activated runtime onto its own thread.
|
||||
pub fn spawn(name: &str, runtime: Runtime) -> std::io::Result<Self> {
|
||||
let (tx, rx) = mpsc::channel::<Job>();
|
||||
let cancel = runtime.cancel_handle();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let busy = Arc::new(AtomicBool::new(false));
|
||||
let last_used = Arc::new(AtomicU64::new(0));
|
||||
let epoch = Instant::now();
|
||||
let thread = {
|
||||
let (alive, busy, last_used) = (alive.clone(), busy.clone(), last_used.clone());
|
||||
std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || {
|
||||
let mut slot = Some(runtime);
|
||||
while let Ok(job) = rx.recv() {
|
||||
busy.store(true, Ordering::Release);
|
||||
job(&mut slot);
|
||||
last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
|
||||
busy.store(false, Ordering::Release);
|
||||
if slot.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
alive.store(false, Ordering::Release);
|
||||
// Queued jobs are dropped with `rx`: their callers see `Stopped`.
|
||||
drop(rx);
|
||||
if let Some(mut rt) = slot {
|
||||
rt.deactivate();
|
||||
}
|
||||
})?
|
||||
};
|
||||
Ok(Self {
|
||||
tx: Mutex::new(Some(tx)),
|
||||
thread: Mutex::new(Some(thread)),
|
||||
cancel,
|
||||
alive,
|
||||
crash: Arc::default(),
|
||||
busy,
|
||||
last_used,
|
||||
epoch,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `f` on the plugin's thread. With `wait`, gives up after that long
|
||||
/// (counting the time queued behind other calls) and the call, if it has
|
||||
/// not started yet, is skipped. Without, waits for the outcome: the
|
||||
/// runtime's own deadline bounds it.
|
||||
pub fn call<R: Send + 'static>(
|
||||
&self,
|
||||
wait: Option<Duration>,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
self.submit(wait, f)?.wait()
|
||||
}
|
||||
|
||||
/// Queues `f` like [`call`](Self::call) and returns at once, so a caller
|
||||
/// can start calls on several plugins and then wait for all of them: the
|
||||
/// wait is the slowest one's, not the sum.
|
||||
pub fn submit<R: Send + 'static>(
|
||||
&self,
|
||||
wait: Option<Duration>,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<Pending<R>, ActorError> {
|
||||
let give_up = wait.map(|w| Instant::now() + w);
|
||||
let (reply, result) = mpsc::sync_channel(1);
|
||||
let (crash, alive) = (self.crash.clone(), self.alive.clone());
|
||||
let job: Job = Box::new(move |slot| {
|
||||
if give_up.is_some_and(|t| Instant::now() >= t) {
|
||||
return;
|
||||
}
|
||||
let Some(rt) = slot.as_mut() else { return };
|
||||
let outcome = f(rt);
|
||||
if let Err(CallError::Trap(e)) = &outcome {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
|
||||
// Before the reply: the caller sees the actor dead.
|
||||
alive.store(false, Ordering::Release);
|
||||
*slot = None;
|
||||
}
|
||||
let _ = reply.send((outcome, Instant::now()));
|
||||
});
|
||||
{
|
||||
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) };
|
||||
tx.send(job).map_err(|_| ActorError::Stopped)?;
|
||||
}
|
||||
Ok(Pending { result, give_up, alive: self.alive.clone() })
|
||||
}
|
||||
|
||||
/// Like [`call`](Self::call) without a soft deadline, but `cancel` (the
|
||||
/// caller's request was superseded) cancels the call cooperatively, and
|
||||
/// interrupts it if it has not returned `grace` later. Returns
|
||||
/// [`ActorError::Cancelled`] then.
|
||||
pub fn call_cancellable<R: Send + 'static>(
|
||||
&self,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
grace: Duration,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
let (reply, result) = mpsc::sync_channel(1);
|
||||
let (crash, alive) = (self.crash.clone(), self.alive.clone());
|
||||
let skip = cancel.clone();
|
||||
let job: Job = Box::new(move |slot| {
|
||||
if skip.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let Some(rt) = slot.as_mut() else { return };
|
||||
let outcome = f(rt);
|
||||
if let Err(CallError::Trap(e)) = &outcome {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
|
||||
alive.store(false, Ordering::Release);
|
||||
*slot = None;
|
||||
}
|
||||
let _ = reply.send(outcome);
|
||||
});
|
||||
{
|
||||
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) };
|
||||
tx.send(job).map_err(|_| ActorError::Stopped)?;
|
||||
}
|
||||
let mut cancelled_at: Option<Instant> = None;
|
||||
loop {
|
||||
match result.recv_timeout(Duration::from_millis(5)) {
|
||||
Ok(outcome) if cancelled_at.is_some() => {
|
||||
drop(outcome);
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
Ok(outcome) => return outcome.map_err(ActorError::Call),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped });
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if !cancel.is_cancelled() {
|
||||
continue;
|
||||
}
|
||||
match cancelled_at {
|
||||
None => {
|
||||
cancelled_at = Some(Instant::now());
|
||||
if self.is_busy() {
|
||||
self.cancel.cancel_soft();
|
||||
}
|
||||
}
|
||||
Some(at) if at.elapsed() >= grace => {
|
||||
// Did not return in time: interrupt it (the instance is rebuilt).
|
||||
self.cancel();
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues `f` without waiting; `done` gets the outcome on the actor's
|
||||
/// thread (`Stopped` if the actor ended before running it).
|
||||
pub fn post<R: Send + 'static>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
done: impl FnOnce(Result<R, ActorError>) + Send + 'static,
|
||||
) {
|
||||
let (crash, alive) = (self.crash.clone(), self.alive.clone());
|
||||
let job: Job = Box::new(move |slot| {
|
||||
let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) };
|
||||
let outcome = f(rt);
|
||||
if let Err(CallError::Trap(e)) = &outcome {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
|
||||
alive.store(false, Ordering::Release);
|
||||
*slot = None;
|
||||
}
|
||||
done(outcome.map_err(ActorError::Call));
|
||||
});
|
||||
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// A job that can't be queued is dropped, and with it `done` (unrun):
|
||||
// the caller sees the actor dead on its next look.
|
||||
if let Some(tx) = tx.as_ref() {
|
||||
let _ = tx.send(job);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cooperatively cancels the running call (see [`call_cancellable`](Self::call_cancellable)).
|
||||
pub fn cancel_soft(&self) {
|
||||
if self.is_busy() {
|
||||
self.cancel.cancel_soft();
|
||||
}
|
||||
}
|
||||
|
||||
/// Interrupts the running call (it fails as a trap: the instance is dropped).
|
||||
pub fn cancel(&self) {
|
||||
if self.busy.load(Ordering::Acquire) {
|
||||
self.cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// The trap that ended this actor; `Some` only for the first caller, which
|
||||
/// reports it (also when the caller that saw it had stopped waiting).
|
||||
pub fn take_crash(&self) -> Option<String> {
|
||||
self.crash.lock().unwrap_or_else(|e| e.into_inner()).take()
|
||||
}
|
||||
|
||||
/// The runtime is still usable (no call trapped, not stopped).
|
||||
pub fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn is_busy(&self) -> bool {
|
||||
self.busy.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Time since the last call ended (or since start).
|
||||
pub fn idle_for(&self) -> Duration {
|
||||
let last = Duration::from_millis(self.last_used.load(Ordering::Acquire));
|
||||
self.epoch.elapsed().saturating_sub(last)
|
||||
}
|
||||
|
||||
/// Deactivates the plugin and ends the thread after the queued calls.
|
||||
/// With `join`, waits for that (bounded by the runtime's deadlines).
|
||||
pub fn stop(&self, join: bool) {
|
||||
self.tx.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
if join {
|
||||
let thread = self.thread.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
if let Some(t) = thread
|
||||
&& t.thread().id() != std::thread::current().id()
|
||||
{
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Actor {
|
||||
fn drop(&mut self) {
|
||||
self.stop(false);
|
||||
}
|
||||
}
|
||||
@@ -1,771 +1,24 @@
|
||||
//! Sandboxed WebAssembly plugin host (Zed-style).
|
||||
//! The WebAssembly runtime for plugins (wasmtime, component model, WASI
|
||||
//! preview 2). Package handling, extension points and grants live in
|
||||
//! `ide-extension`; the host that assembles them is `ide-host::plugins`.
|
||||
//!
|
||||
//! A plugin is a directory with a `plugin.toml` manifest ([`PluginManifest`])
|
||||
//! and, optionally, a WebAssembly component implementing the
|
||||
//! `openidea:plugin/[email protected]` world (`wit/plugin.wit`). Contribution-only
|
||||
//! plugins (themes, languages) need no code.
|
||||
//!
|
||||
//! Each plugin with code runs in its own wasmtime `Store` with WASI preview 2
|
||||
//! (no files, env, network), a fuel budget and wall-clock deadline per call,
|
||||
//! and a linear-memory cap. A trap (panic, budget exhausted, permission
|
||||
//! violation) drops the instance and marks the plugin `Failed` for the session;
|
||||
//! the IDE keeps running.
|
||||
//!
|
||||
//! Sources: `<plugins_dir>/<id>/` (installed) and `<project>/.openidea/plugins/*/`
|
||||
//! (dev plugins, loaded by [`PluginHost::sync_project`]).
|
||||
//! Each plugin with code runs in its own wasmtime `Store`: no files, env or
|
||||
//! network beyond what [`HostApi`] grants, a fuel budget and wall-clock
|
||||
//! deadline per call, and a linear-memory cap. A trap (panic, budget
|
||||
//! exhausted, permission violation, cancellation) poisons the instance; the
|
||||
//! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread.
|
||||
|
||||
mod manifest;
|
||||
mod package;
|
||||
mod actor;
|
||||
mod runtime;
|
||||
mod sandbox;
|
||||
mod theme;
|
||||
mod types;
|
||||
mod wit_host;
|
||||
|
||||
pub use manifest::{
|
||||
API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest, ThemeDecl,
|
||||
pub use actor::{Actor, ActorError, Pending};
|
||||
pub use runtime::{
|
||||
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
|
||||
component_api, host_api,
|
||||
};
|
||||
pub use sandbox::resolve_in_root;
|
||||
pub use theme::{ThemeDef, ThemeKind};
|
||||
pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root};
|
||||
pub use types::*;
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::LanguageConfig;
|
||||
use parking_lot::{Condvar, Mutex, RwLock};
|
||||
use runtime::{CallEffects, CallError, PluginEngine, Registrations, Runtime, RuntimeSetup};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const STATE_FILE: &str = "state.json";
|
||||
/// Top-level files copied with a package on install besides those the manifest references.
|
||||
const LICENSE_FILES: &[&str] =
|
||||
&["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING"];
|
||||
/// Dev plugins live here, relative to a project root.
|
||||
pub const DEV_PLUGINS_DIR: &str = ".openidea/plugins";
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct PersistedState {
|
||||
#[serde(default)]
|
||||
disabled: BTreeSet<String>,
|
||||
#[serde(default)]
|
||||
theme: Option<ThemeRef>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct ThemeRef {
|
||||
plugin: String,
|
||||
theme: String,
|
||||
/// The theme's window frame colour as the UI resolved it (`#rrggbb[aa]`), so
|
||||
/// new windows open in it before the plugin is loaded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
frame: Option<String>,
|
||||
}
|
||||
|
||||
struct Meta {
|
||||
status: PluginStatus,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
struct LoadedPlugin {
|
||||
manifest: PluginManifest,
|
||||
dir: PathBuf,
|
||||
source: PluginSource,
|
||||
/// Project that owns a dev plugin.
|
||||
dev_root: Option<PathBuf>,
|
||||
/// Manifest contributions, restored on every (re)start.
|
||||
static_regs: Registrations,
|
||||
registrations: Arc<Mutex<Registrations>>,
|
||||
meta: Mutex<Meta>,
|
||||
runtime: Mutex<Option<Runtime>>,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
fn is_live(&self) -> bool {
|
||||
matches!(self.meta.lock().status, PluginStatus::Active | PluginStatus::Static)
|
||||
}
|
||||
|
||||
fn set_failed(&self, error: String) {
|
||||
let mut meta = self.meta.lock();
|
||||
meta.status = PluginStatus::Failed;
|
||||
meta.error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
|
||||
|
||||
pub struct PluginHost {
|
||||
project_policy: RwLock<ProjectPolicy>,
|
||||
engine: PluginEngine,
|
||||
plugins_dir: PathBuf,
|
||||
sink: Arc<dyn PluginEventSink>,
|
||||
limits: Limits,
|
||||
plugins: RwLock<BTreeMap<String, Arc<LoadedPlugin>>>,
|
||||
state: Mutex<PersistedState>,
|
||||
/// client (window) → path of its focused editor.
|
||||
active_files: Arc<Mutex<HashMap<String, String>>>,
|
||||
/// Serializes loading / unloading so concurrent windows can't race.
|
||||
lifecycle: Mutex<()>,
|
||||
/// Set once the first [`load_all`](Self::load_all) has finished.
|
||||
initial_load: (Mutex<bool>, Condvar),
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Creates the host; call [`load_all`](Self::load_all) to load installed plugins.
|
||||
pub fn new(plugins_dir: PathBuf, sink: Arc<dyn PluginEventSink>, limits: Limits) -> IdeResult<Self> {
|
||||
std::fs::create_dir_all(&plugins_dir)?;
|
||||
let state = std::fs::read_to_string(plugins_dir.join(STATE_FILE))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
project_policy: RwLock::new(Arc::new(|_| false)),
|
||||
engine: PluginEngine::new().map_err(IdeError::internal)?,
|
||||
plugins_dir,
|
||||
sink,
|
||||
limits,
|
||||
plugins: RwLock::default(),
|
||||
state: Mutex::new(state),
|
||||
active_files: Arc::default(),
|
||||
lifecycle: Mutex::new(()),
|
||||
initial_load: (Mutex::new(false), Condvar::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plugins_dir(&self) -> &Path {
|
||||
&self.plugins_dir
|
||||
}
|
||||
|
||||
/// Loads every package under the plugins directory (already loaded ones are kept).
|
||||
pub fn load_all(&self) {
|
||||
let _guard = self.lifecycle.lock();
|
||||
for dir in package_dirs(&self.plugins_dir) {
|
||||
if !self.is_loaded_from(&dir) {
|
||||
self.load_dir(&dir, PluginSource::Installed, None);
|
||||
}
|
||||
}
|
||||
let (done, cv) = &self.initial_load;
|
||||
*done.lock() = true;
|
||||
cv.notify_all();
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
|
||||
/// Waits (at most `timeout`) for the first [`load_all`](Self::load_all), so a
|
||||
/// listing made at startup already holds the installed plugins and the
|
||||
/// selected theme. Returns whether loading has finished.
|
||||
pub fn wait_initial_load(&self, timeout: Duration) -> bool {
|
||||
let (done, cv) = &self.initial_load;
|
||||
let mut done = done.lock();
|
||||
if !*done {
|
||||
cv.wait_while_for(&mut done, |d| !*d, timeout);
|
||||
}
|
||||
*done
|
||||
}
|
||||
|
||||
/// Loads new dev plugins from `<root>/.openidea/plugins/` and unloads vanished ones.
|
||||
pub fn set_project_policy(&self, policy: ProjectPolicy) {
|
||||
*self.project_policy.write() = policy;
|
||||
}
|
||||
|
||||
pub fn restrict_project(&self, root: &Path) {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let ids: Vec<_> = self
|
||||
.plugins
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, p)| p.dev_root.as_deref() == Some(root))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids {
|
||||
self.unload(&id);
|
||||
}
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
|
||||
pub fn sync_project(&self, root: &Path) {
|
||||
if !(self.project_policy.read())(root) {
|
||||
self.restrict_project(root);
|
||||
return;
|
||||
}
|
||||
let _guard = self.lifecycle.lock();
|
||||
let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins"));
|
||||
let stale: Vec<String> = self
|
||||
.plugins
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, p)| p.dev_root.as_deref() == Some(root) && !dirs.contains(&p.dir))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
let mut changed = !stale.is_empty();
|
||||
for id in stale {
|
||||
self.unload(&id);
|
||||
}
|
||||
for dir in dirs {
|
||||
if !self.is_loaded_from(&dir) {
|
||||
self.load_dir(&dir, PluginSource::Dev, Some(root.to_path_buf()));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installed plugins plus the dev plugins of `project` (if any).
|
||||
pub fn list(&self, project: Option<&Path>) -> PluginsSnapshot {
|
||||
let mut plugins: Vec<PluginInfo> = self
|
||||
.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
|
||||
.map(|p| self.info(p))
|
||||
.collect();
|
||||
plugins.sort_by_key(|p| p.name.to_lowercase());
|
||||
PluginsSnapshot { plugins, active_theme: self.active_theme() }
|
||||
}
|
||||
|
||||
pub fn info_of(&self, id: &str) -> IdeResult<PluginInfo> {
|
||||
Ok(self.info(&*self.get(id)?))
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
{
|
||||
let mut state = self.state.lock();
|
||||
if enabled {
|
||||
state.disabled.remove(id)
|
||||
} else {
|
||||
state.disabled.insert(id.to_owned())
|
||||
};
|
||||
}
|
||||
self.save_state();
|
||||
self.stop(&plugin);
|
||||
if enabled {
|
||||
self.start(&plugin);
|
||||
} else {
|
||||
plugin.meta.lock().status = PluginStatus::Disabled;
|
||||
}
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.emit_theme_if_owned(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-reads a plugin from disk (e.g. after rebuilding a dev plugin).
|
||||
pub fn reload(&self, id: &str) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
let (dir, source, root) = (plugin.dir.clone(), plugin.source, plugin.dev_root.clone());
|
||||
self.unload(id);
|
||||
let new_id = self.load_dir(&dir, source, root);
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.emit_theme_if_owned(&new_id);
|
||||
self.info_of(&new_id)
|
||||
}
|
||||
|
||||
/// Installs the package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder.
|
||||
pub fn install_from_path(&self, path: &Path) -> IdeResult<PluginInfo> {
|
||||
if package::is_zip(path) && path.is_file() {
|
||||
// Outside the plugins directory: a crash must not leave a package that loads.
|
||||
let unpacked = tempfile::Builder::new().prefix("openidea-plugin-").tempdir()?;
|
||||
let root = package::unpack_zip(path, unpacked.path())?;
|
||||
return self.install_from_dir(&root);
|
||||
}
|
||||
if path.file_name().is_some_and(|name| name == MANIFEST_FILE) && path.is_file() {
|
||||
return self.install_from_dir(path.parent().unwrap_or(path));
|
||||
}
|
||||
self.install_from_dir(path)
|
||||
}
|
||||
|
||||
/// Copies a plugin package (manifest + referenced files) into the plugins
|
||||
/// directory, replacing an installed version with the same id.
|
||||
pub fn install_from_dir(&self, src: &Path) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let manifest = PluginManifest::load(src).map_err(IdeError::invalid)?;
|
||||
for file in manifest.files() {
|
||||
if !src.join(file).is_file() {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is missing (referenced by plugin.toml); build the plugin first",
|
||||
file.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(wasm) = &manifest.wasm {
|
||||
self.engine.compile(&src.join(wasm)).map_err(IdeError::invalid)?;
|
||||
}
|
||||
let id = manifest.id.clone();
|
||||
if let Some(existing) = self.plugins.read().get(&id)
|
||||
&& existing.source == PluginSource::Dev
|
||||
{
|
||||
return Err(IdeError::new(
|
||||
ide_core::error::ErrorKind::AlreadyExists,
|
||||
format!("a dev plugin with id {id:?} is loaded from {}", existing.dir.display()),
|
||||
));
|
||||
}
|
||||
let target = self.plugins_dir.join(&id);
|
||||
let same_dir = src.canonicalize().ok().zip(target.canonicalize().ok()).is_some_and(|(a, b)| a == b);
|
||||
self.unload(&id);
|
||||
if !same_dir {
|
||||
let staging = self.plugins_dir.join(format!(".install-{id}"));
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
let copy = || -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&staging)?;
|
||||
std::fs::copy(src.join(MANIFEST_FILE), staging.join(MANIFEST_FILE))?;
|
||||
for file in manifest.files() {
|
||||
let to = staging.join(file);
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(src.join(file), to)?;
|
||||
}
|
||||
for name in LICENSE_FILES {
|
||||
if src.join(name).is_file() {
|
||||
std::fs::copy(src.join(name), staging.join(name))?;
|
||||
}
|
||||
}
|
||||
if target.exists() {
|
||||
std::fs::remove_dir_all(&target)?;
|
||||
}
|
||||
std::fs::rename(&staging, &target)
|
||||
};
|
||||
if let Err(e) = copy() {
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
self.load_dir(&target, PluginSource::Installed, None);
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.emit_theme_if_owned(&id);
|
||||
self.info_of(&id)
|
||||
}
|
||||
|
||||
pub fn uninstall(&self, id: &str) -> IdeResult<()> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
if plugin.source != PluginSource::Installed {
|
||||
return Err(IdeError::invalid("dev plugins are removed by deleting them from .openidea/plugins"));
|
||||
}
|
||||
self.unload(id);
|
||||
std::fs::remove_dir_all(&plugin.dir)?;
|
||||
let theme_cleared = {
|
||||
let mut state = self.state.lock();
|
||||
state.disabled.remove(id);
|
||||
let owned = state.theme.as_ref().is_some_and(|t| t.plugin == id);
|
||||
if owned {
|
||||
state.theme = None;
|
||||
}
|
||||
owned
|
||||
};
|
||||
self.save_state();
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
if theme_cleared {
|
||||
self.emit(None, PluginEvent::ThemeChanged { theme: None });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs a plugin command. Blocks for up to the configured time limit.
|
||||
pub fn run_command(
|
||||
&self,
|
||||
id: &str,
|
||||
command: &str,
|
||||
arg: Option<&str>,
|
||||
invocation: Invocation,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let plugin = self.get(id)?;
|
||||
let client = invocation.client.clone();
|
||||
let mut runtime = plugin.runtime.lock();
|
||||
let Some(rt) = runtime.as_mut() else {
|
||||
let meta = plugin.meta.lock();
|
||||
return Err(IdeError::unavailable(match meta.status {
|
||||
PluginStatus::Disabled => format!("plugin {id:?} is disabled"),
|
||||
PluginStatus::Static => format!("plugin {id:?} has no code to run commands"),
|
||||
_ => format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed")),
|
||||
}));
|
||||
};
|
||||
match rt.run_command(command, arg, invocation) {
|
||||
Ok((value, fx)) => {
|
||||
drop(runtime);
|
||||
self.apply_effects(&plugin, fx);
|
||||
Ok(value)
|
||||
}
|
||||
Err(CallError::Guest(e)) => Err(IdeError::internal(e)),
|
||||
Err(CallError::Trap(e)) => {
|
||||
*runtime = None;
|
||||
drop(runtime);
|
||||
self.fail(&plugin, &e, client.as_deref());
|
||||
Err(IdeError::internal(format!("plugin {id:?} {e}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The UI reports which file its focused editor shows (for `active-file`).
|
||||
pub fn set_active_file(&self, client: &str, path: Option<String>) {
|
||||
let mut files = self.active_files.lock();
|
||||
match path {
|
||||
Some(p) => files.insert(client.to_owned(), p),
|
||||
None => files.remove(client),
|
||||
};
|
||||
}
|
||||
|
||||
/// Selects a plugin theme (`None` restores the built-in theme).
|
||||
pub fn set_theme(&self, selection: Option<(&str, &str)>) -> IdeResult<Option<ActiveTheme>> {
|
||||
if let Some((plugin, theme)) = selection {
|
||||
let p = self.get(plugin)?;
|
||||
if !p.registrations.lock().themes.iter().any(|t| t.id == theme) {
|
||||
return Err(IdeError::not_found(format!("plugin {plugin:?} has no theme {theme:?}")));
|
||||
}
|
||||
}
|
||||
self.state.lock().theme =
|
||||
selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into(), frame: None });
|
||||
self.save_state();
|
||||
let active = self.active_theme();
|
||||
self.emit(None, PluginEvent::ThemeChanged { theme: active.clone() });
|
||||
Ok(active)
|
||||
}
|
||||
|
||||
/// Records the selected theme's resolved window frame colour (`#rgb`,
|
||||
/// `#rrggbb` or `#rrggbbaa`); ignored without a selected theme.
|
||||
pub fn set_theme_frame(&self, frame: Option<&str>) -> IdeResult<()> {
|
||||
if let Some(f) = frame
|
||||
&& !is_hex_color(f)
|
||||
{
|
||||
return Err(IdeError::invalid(format!("not a hex colour: {f:?}")));
|
||||
}
|
||||
let changed = match self.state.lock().theme.as_mut() {
|
||||
Some(t) if t.frame.as_deref() != frame => {
|
||||
t.frame = frame.map(str::to_owned);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if changed {
|
||||
self.save_state();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Frame colour for a new window: the recorded one of the selected theme,
|
||||
/// unless its plugin is disabled. Available before plugins are loaded.
|
||||
pub fn startup_frame(&self) -> Option<String> {
|
||||
let state = self.state.lock();
|
||||
let t = state.theme.as_ref()?;
|
||||
if state.disabled.contains(&t.plugin) {
|
||||
return None;
|
||||
}
|
||||
t.frame.clone()
|
||||
}
|
||||
|
||||
/// The selected plugin theme, when its plugin is enabled and running.
|
||||
pub fn active_theme(&self) -> Option<ActiveTheme> {
|
||||
let sel = self.state.lock().theme.clone()?;
|
||||
let plugin = self.plugins.read().get(&sel.plugin).cloned()?;
|
||||
if !plugin.is_live() {
|
||||
return None;
|
||||
}
|
||||
let theme = plugin.registrations.lock().themes.iter().find(|t| t.id == sel.theme).cloned()?;
|
||||
Some(ActiveTheme { plugin_id: sel.plugin, theme })
|
||||
}
|
||||
|
||||
/// Languages contributed by live plugins (for runtime language registration).
|
||||
pub fn languages(&self) -> Vec<LanguageConfig> {
|
||||
self.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.is_live())
|
||||
.flat_map(|p| p.registrations.lock().languages.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Deactivates every plugin (also done on drop).
|
||||
pub fn shutdown(&self) {
|
||||
for plugin in self.plugins.read().values() {
|
||||
if let Some(mut rt) = plugin.runtime.lock().take() {
|
||||
rt.deactivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
fn get(&self, id: &str) -> IdeResult<Arc<LoadedPlugin>> {
|
||||
let plugin =
|
||||
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?;
|
||||
if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) {
|
||||
return Err(IdeError::unavailable("Project is in Restricted Mode"));
|
||||
}
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
fn is_loaded_from(&self, dir: &Path) -> bool {
|
||||
self.plugins.read().values().any(|p| p.dir == dir)
|
||||
}
|
||||
|
||||
/// Loads one package and starts it unless disabled. Returns the plugin id.
|
||||
fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option<PathBuf>) -> String {
|
||||
let (manifest, static_regs, error) = match PluginManifest::load(dir) {
|
||||
Ok(m) => match static_contributions(dir, &m) {
|
||||
Ok(regs) => (m, regs, None),
|
||||
Err(e) => (m, Registrations::default(), Some(e)),
|
||||
},
|
||||
Err(e) => (placeholder_manifest(dir), Registrations::default(), Some(e)),
|
||||
};
|
||||
let id = manifest.id.clone();
|
||||
let plugin = Arc::new(LoadedPlugin {
|
||||
manifest,
|
||||
dir: dir.to_path_buf(),
|
||||
source,
|
||||
dev_root,
|
||||
registrations: Arc::new(Mutex::new(static_regs.clone())),
|
||||
static_regs,
|
||||
meta: Mutex::new(Meta { status: PluginStatus::Disabled, error: None }),
|
||||
runtime: Mutex::new(None),
|
||||
});
|
||||
{
|
||||
let mut plugins = self.plugins.write();
|
||||
if let Some(existing) = plugins.get(&id) {
|
||||
tracing::warn!(
|
||||
"plugin {id:?} at {} ignored: already loaded from {}",
|
||||
dir.display(),
|
||||
existing.dir.display()
|
||||
);
|
||||
return id;
|
||||
}
|
||||
plugins.insert(id.clone(), plugin.clone());
|
||||
}
|
||||
if let Some(e) = error {
|
||||
tracing::warn!("plugin {}: {e}", dir.display());
|
||||
plugin.set_failed(e);
|
||||
} else if !self.state.lock().disabled.contains(&id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn unload(&self, id: &str) {
|
||||
let Some(plugin) = self.plugins.write().remove(id) else { return };
|
||||
self.stop(&plugin);
|
||||
}
|
||||
|
||||
fn start(&self, plugin: &Arc<LoadedPlugin>) {
|
||||
*plugin.registrations.lock() = plugin.static_regs.clone();
|
||||
let Some(wasm) = &plugin.manifest.wasm else {
|
||||
*plugin.meta.lock() = Meta { status: PluginStatus::Static, error: None };
|
||||
return;
|
||||
};
|
||||
let started = self.engine.compile(&plugin.dir.join(wasm)).and_then(|component| {
|
||||
let active_files = self.active_files.clone();
|
||||
let setup = RuntimeSetup {
|
||||
plugin_id: plugin.manifest.id.clone(),
|
||||
version: plugin.manifest.version.clone(),
|
||||
permissions: plugin.manifest.permissions.clone(),
|
||||
registrations: plugin.registrations.clone(),
|
||||
sink: self.sink.clone(),
|
||||
active_file: Arc::new(move |client| client.and_then(|c| active_files.lock().get(c).cloned())),
|
||||
limits: self.limits,
|
||||
};
|
||||
let mut rt = Runtime::instantiate(&self.engine, &component, setup)?;
|
||||
match rt.activate() {
|
||||
Ok(fx) => Ok((rt, fx)),
|
||||
Err(CallError::Guest(e)) => Err(format!("activation failed: {e}")),
|
||||
Err(CallError::Trap(e)) => Err(format!("activation {e}")),
|
||||
}
|
||||
});
|
||||
match started {
|
||||
Ok((rt, _fx)) => {
|
||||
*plugin.runtime.lock() = Some(rt);
|
||||
*plugin.meta.lock() = Meta { status: PluginStatus::Active, error: None };
|
||||
}
|
||||
Err(e) => self.fail(plugin, &e, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&self, plugin: &LoadedPlugin) {
|
||||
if let Some(mut rt) = plugin.runtime.lock().take() {
|
||||
rt.deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
|
||||
tracing::warn!(plugin = %plugin.manifest.id, "disabled after failure: {error}");
|
||||
plugin.set_failed(error.to_owned());
|
||||
self.emit(
|
||||
client,
|
||||
PluginEvent::Notification {
|
||||
plugin_id: plugin.manifest.id.clone(),
|
||||
level: NotificationLevel::Error,
|
||||
title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest.name),
|
||||
body: error.to_owned(),
|
||||
},
|
||||
);
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.emit_theme_if_owned(&plugin.manifest.id);
|
||||
}
|
||||
|
||||
fn apply_effects(&self, plugin: &LoadedPlugin, fx: CallEffects) {
|
||||
if fx.commands_changed {
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
let sel = self.state.lock().theme.clone();
|
||||
if let Some(sel) = sel
|
||||
&& sel.plugin == plugin.manifest.id
|
||||
&& fx.themes_changed.contains(&sel.theme)
|
||||
{
|
||||
self.emit(None, PluginEvent::ThemeChanged { theme: self.active_theme() });
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_theme_if_owned(&self, id: &str) {
|
||||
let owned = self.state.lock().theme.as_ref().is_some_and(|t| t.plugin == id);
|
||||
if owned {
|
||||
self.emit(None, PluginEvent::ThemeChanged { theme: self.active_theme() });
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&self, client: Option<&str>, event: PluginEvent) {
|
||||
self.sink.emit(client, event);
|
||||
}
|
||||
|
||||
fn save_state(&self) {
|
||||
let json = serde_json::to_string_pretty(&*self.state.lock()).unwrap_or_default();
|
||||
if let Err(e) = std::fs::write(self.plugins_dir.join(STATE_FILE), json) {
|
||||
tracing::warn!("saving plugin state: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn info(&self, p: &LoadedPlugin) -> PluginInfo {
|
||||
let m = &p.manifest;
|
||||
let regs = p.registrations.lock().clone();
|
||||
let meta = p.meta.lock();
|
||||
PluginInfo {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
authors: m.authors.clone(),
|
||||
homepage: m.homepage.clone(),
|
||||
change_notes: m.change_notes.clone(),
|
||||
source: p.source,
|
||||
path: p.dir.clone().into(),
|
||||
enabled: !self.state.lock().disabled.contains(&m.id),
|
||||
status: meta.status,
|
||||
error: meta.error.clone(),
|
||||
has_code: m.wasm.is_some(),
|
||||
permissions: m.permissions.clone(),
|
||||
commands: regs.commands,
|
||||
languages: regs
|
||||
.languages
|
||||
.iter()
|
||||
.map(|l| PluginLanguage {
|
||||
id: l.id.to_string(),
|
||||
name: l.name.clone(),
|
||||
extensions: l.extensions.clone(),
|
||||
})
|
||||
.collect(),
|
||||
themes: regs
|
||||
.themes
|
||||
.iter()
|
||||
.map(|t| PluginTheme { id: t.id.clone(), name: t.name.clone(), kind: t.kind })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PluginHost {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hex_color(s: &str) -> bool {
|
||||
s.strip_prefix('#').is_some_and(|h| matches!(h.len(), 3 | 6 | 8) && h.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
/// Plugin packages found in `dirs` (first occurrence of an id wins). Invalid
|
||||
/// manifests are skipped; packages with missing files carry a `problem`.
|
||||
pub fn scan_catalog(dirs: &[PathBuf]) -> Vec<CatalogPlugin> {
|
||||
let mut out: Vec<CatalogPlugin> = Vec::new();
|
||||
for dir in dirs.iter().flat_map(|d| package_dirs(d)) {
|
||||
let Ok(m) = PluginManifest::load(&dir) else { continue };
|
||||
if out.iter().any(|c| c.id == m.id) {
|
||||
continue;
|
||||
}
|
||||
let problem = m
|
||||
.files()
|
||||
.into_iter()
|
||||
.find(|f| !dir.join(f).is_file())
|
||||
.map(|f| format!("{} is missing; build the plugin first", f.display()));
|
||||
out.push(CatalogPlugin {
|
||||
has_code: m.wasm.is_some(),
|
||||
language_count: m.contributes.languages.len() as u32,
|
||||
theme_count: m.contributes.themes.len() as u32,
|
||||
commands: m.contributes.commands.clone(),
|
||||
permissions: m.permissions.clone(),
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
description: m.description,
|
||||
authors: m.authors,
|
||||
homepage: m.homepage,
|
||||
change_notes: m.change_notes,
|
||||
path: dir.into(),
|
||||
problem,
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|a| a.name.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
/// Subdirectories of `dir` that contain a `plugin.toml`, sorted.
|
||||
fn package_dirs(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut dirs: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.join(MANIFEST_FILE).is_file()
|
||||
&& !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))
|
||||
})
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
|
||||
fn static_contributions(dir: &Path, m: &PluginManifest) -> Result<Registrations, String> {
|
||||
let mut regs = Registrations::default();
|
||||
for cmd in &m.contributes.commands {
|
||||
regs.add_command(cmd.clone());
|
||||
}
|
||||
let read = |p: &Path| std::fs::read_to_string(dir.join(p)).map_err(|e| format!("{}: {e}", p.display()));
|
||||
for f in &m.contributes.languages {
|
||||
let cfg = runtime::parse_language(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
|
||||
regs.add_language(cfg);
|
||||
}
|
||||
for f in &m.contributes.themes {
|
||||
let mut theme = ThemeDef::parse(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
|
||||
if let Some(scheme) = &f.editor_scheme {
|
||||
theme.merge_editor_scheme(&read(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?;
|
||||
}
|
||||
regs.add_theme(theme);
|
||||
}
|
||||
Ok(regs)
|
||||
}
|
||||
|
||||
/// Stand-in for a package whose manifest failed to parse, so it still shows up (as failed).
|
||||
fn placeholder_manifest(dir: &Path) -> PluginManifest {
|
||||
let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
PluginManifest {
|
||||
id: format!("invalid-{}", name.to_lowercase().replace(|c: char| !c.is_ascii_alphanumeric(), "-")),
|
||||
name,
|
||||
version: "0.0.0".into(),
|
||||
description: String::new(),
|
||||
authors: Vec::new(),
|
||||
homepage: None,
|
||||
change_notes: None,
|
||||
openidea_api: API_VERSION.into(),
|
||||
wasm: None,
|
||||
permissions: Vec::new(),
|
||||
contributes: Contributions::default(),
|
||||
}
|
||||
}
|
||||
pub use wasmtime::component::Component;
|
||||
@@ -1,279 +0,0 @@
|
||||
//! `plugin.toml`: the plugin package manifest.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// Plugin API version implemented by this host (`openidea-api` in the manifest).
|
||||
pub const API_VERSION: &str = "0.1";
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
|
||||
/// Capabilities a plugin must declare to use the gated host functions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Permission {
|
||||
/// `workspace-root`, `read-file` (inside the project root only).
|
||||
ProjectRead,
|
||||
/// `active-file`.
|
||||
EditorRead,
|
||||
/// `insert-text`, `replace-selection`.
|
||||
EditorWrite,
|
||||
}
|
||||
|
||||
impl Permission {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Permission::ProjectRead => "project-read",
|
||||
Permission::EditorRead => "editor-read",
|
||||
Permission::EditorWrite => "editor-write",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct PluginManifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub homepage: Option<String>,
|
||||
/// Release notes of this version (Settings › Plugins › What's New).
|
||||
#[serde(default)]
|
||||
pub change_notes: Option<String>,
|
||||
pub openidea_api: String,
|
||||
/// Component file relative to the package directory; absent for
|
||||
/// contribution-only plugins (themes, languages).
|
||||
#[serde(default)]
|
||||
pub wasm: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<Permission>,
|
||||
#[serde(default)]
|
||||
pub contributes: Contributions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct Contributions {
|
||||
#[serde(default)]
|
||||
pub commands: Vec<CommandDecl>,
|
||||
#[serde(default)]
|
||||
pub languages: Vec<FileDecl>,
|
||||
#[serde(default)]
|
||||
pub themes: Vec<ThemeDecl>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandDecl {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// A contributed file, relative to the package directory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FileDecl {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// A contributed theme: the UI theme file plus an optional editor colour scheme
|
||||
/// whose tokens are merged over it (IntelliJ's `.theme.json` + `.icls`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct ThemeDecl {
|
||||
pub path: PathBuf,
|
||||
#[serde(default)]
|
||||
pub editor_scheme: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl PluginManifest {
|
||||
pub fn parse(src: &str) -> Result<Self, String> {
|
||||
let manifest: Self = toml::from_str(src).map_err(|e| e.to_string())?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<Self, String> {
|
||||
let path = dir.join(MANIFEST_FILE);
|
||||
let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
validate_plugin_id(&self.id)?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("`name` must not be empty".into());
|
||||
}
|
||||
if !is_version(&self.version) {
|
||||
return Err(format!("`version` must look like 1.2.3, got {:?}", self.version));
|
||||
}
|
||||
if !api_compatible(&self.openidea_api) {
|
||||
return Err(format!(
|
||||
"unsupported openidea-api {:?} (this IDE implements {API_VERSION})",
|
||||
self.openidea_api
|
||||
));
|
||||
}
|
||||
if let Some(wasm) = &self.wasm {
|
||||
check_relative(wasm, "wasm")?;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for c in &self.contributes.commands {
|
||||
validate_command_id(&c.id)?;
|
||||
if !seen.insert(c.id.as_str()) {
|
||||
return Err(format!("duplicate command id {:?}", c.id));
|
||||
}
|
||||
if c.title.trim().is_empty() {
|
||||
return Err(format!("command {:?} has an empty title", c.id));
|
||||
}
|
||||
}
|
||||
let theme_files = self.contributes.themes.iter().flat_map(|t| std::iter::once(&t.path).chain(&t.editor_scheme));
|
||||
for path in self.contributes.languages.iter().map(|f| &f.path).chain(theme_files) {
|
||||
check_relative(path, "contributes path")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file of the package besides the manifest (what an install copies).
|
||||
pub fn files(&self) -> Vec<&Path> {
|
||||
let mut files: Vec<&Path> = self.wasm.iter().map(PathBuf::as_path).collect();
|
||||
files.extend(self.contributes.languages.iter().map(|f| f.path.as_path()));
|
||||
for t in &self.contributes.themes {
|
||||
files.push(t.path.as_path());
|
||||
files.extend(t.editor_scheme.as_deref());
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
pub fn has(&self, p: Permission) -> bool {
|
||||
self.permissions.contains(&p)
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a-z0-9][a-z0-9_-]*`, at most 64 chars (no dots: it is one segment of an action id).
|
||||
pub fn validate_plugin_id(id: &str) -> Result<(), String> {
|
||||
validate_ident(id, "plugin id", false)
|
||||
}
|
||||
|
||||
/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars.
|
||||
pub fn validate_command_id(id: &str) -> Result<(), String> {
|
||||
validate_ident(id, "command id", true)
|
||||
}
|
||||
|
||||
fn validate_ident(id: &str, what: &str, dots: bool) -> Result<(), String> {
|
||||
let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
|
||||
let ok_rest =
|
||||
id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_' || (dots && c == '.'));
|
||||
if ok_first && ok_rest && id.len() <= 64 {
|
||||
Ok(())
|
||||
} else {
|
||||
let allowed = if dots { "a-z, 0-9, '.', '-', '_'" } else { "a-z, 0-9, '-', '_'" };
|
||||
Err(format!("invalid {what} {id:?}: use {allowed}, starting with a letter or digit (max 64)"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_version(v: &str) -> bool {
|
||||
let core = v.split(['-', '+']).next().unwrap_or_default();
|
||||
let parts: Vec<&str> = core.split('.').collect();
|
||||
parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// `0.1` and `0.1.x` are compatible with API 0.1 (pre-1.0: minor is breaking).
|
||||
fn api_compatible(v: &str) -> bool {
|
||||
v == API_VERSION || v.strip_prefix(API_VERSION).is_some_and(|rest| rest.starts_with('.'))
|
||||
}
|
||||
|
||||
/// Package paths must stay inside the package directory.
|
||||
fn check_relative(path: &Path, what: &str) -> Result<(), String> {
|
||||
let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)));
|
||||
if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FULL: &str = r#"
|
||||
id = "hello"
|
||||
name = "Hello"
|
||||
version = "0.1.0"
|
||||
description = "Says hello"
|
||||
authors = ["A"]
|
||||
openidea-api = "0.1"
|
||||
wasm = "dist/plugin.wasm"
|
||||
permissions = ["project-read", "editor-write"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "say.hi"
|
||||
title = "Say Hi"
|
||||
category = "Hello"
|
||||
|
||||
[[contributes.themes]]
|
||||
path = "themes/dark.json"
|
||||
editor-scheme = "themes/dark.scheme.json"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_full_manifest() {
|
||||
let m = PluginManifest::parse(FULL).unwrap();
|
||||
assert_eq!(m.id, "hello");
|
||||
assert_eq!(m.permissions, vec![Permission::ProjectRead, Permission::EditorWrite]);
|
||||
assert_eq!(m.contributes.commands[0].category.as_deref(), Some("Hello"));
|
||||
assert_eq!(
|
||||
m.files(),
|
||||
vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json"), Path::new("themes/dark.scheme.json")]
|
||||
);
|
||||
assert!(m.has(Permission::EditorWrite) && !m.has(Permission::EditorRead));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_contribution_only_manifest() {
|
||||
let m =
|
||||
PluginManifest::parse("id = \"t\"\nname = \"T\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1.3\"").unwrap();
|
||||
assert!(m.wasm.is_none() && m.permissions.is_empty());
|
||||
}
|
||||
|
||||
fn err(src: &str) -> String {
|
||||
PluginManifest::parse(src).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_manifests() {
|
||||
let base = |extra: &str, id: &str, api: &str| {
|
||||
format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nopenidea-api = \"{api}\"\n{extra}")
|
||||
};
|
||||
assert!(err(&base("", "Bad.Id", "0.1")).contains("invalid plugin id"));
|
||||
assert!(err(&base("", "ok", "0.2")).contains("unsupported openidea-api"));
|
||||
assert!(err(&base("", "ok", "0.10")).contains("unsupported openidea-api"));
|
||||
assert!(err(&base("permissions = [\"network\"]", "ok", "0.1")).contains("unknown variant"));
|
||||
assert!(err(&base("wasm = \"../evil.wasm\"", "ok", "0.1")).contains("relative path"));
|
||||
assert!(err(&base("wasm = \"/abs.wasm\"", "ok", "0.1")).contains("relative path"));
|
||||
assert!(err(&base("bogus = 1", "ok", "0.1")).contains("unknown field"));
|
||||
let dup =
|
||||
"[[contributes.commands]]\nid = \"a\"\ntitle = \"A\"\n[[contributes.commands]]\nid = \"a\"\ntitle = \"B\"";
|
||||
assert!(err(&base(dup, "ok", "0.1")).contains("duplicate command"));
|
||||
let traversal = "[[contributes.languages]]\npath = \"a/../../x.toml\"";
|
||||
assert!(err(&base(traversal, "ok", "0.1")).contains("relative path"));
|
||||
let scheme_escape = "[[contributes.themes]]\npath = \"t.json\"\neditor-scheme = \"../s.json\"";
|
||||
assert!(err(&base(scheme_escape, "ok", "0.1")).contains("relative path"));
|
||||
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nopenidea-api = \"0.1\"").contains("version"));
|
||||
assert!(err("id = \"ok\"\nname = \" \"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"").contains("name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ident_rules() {
|
||||
assert!(validate_command_id("word-count.v2_x").is_ok());
|
||||
assert!(validate_command_id("-x").is_err());
|
||||
assert!(validate_command_id("").is_err());
|
||||
assert!(validate_command_id(&"a".repeat(65)).is_err());
|
||||
assert!(validate_plugin_id("a.b").is_err());
|
||||
assert!(is_version("1.2.3-beta.1+build"));
|
||||
assert!(!is_version("1.2.x"));
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//! Plugin packages on disk: a folder, its `plugin.toml`, or a `.zip` of the folder.
|
||||
|
||||
use crate::MANIFEST_FILE;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Far above any real package; stops a zip bomb before it fills the disk.
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
const MAX_UNPACKED_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
pub(crate) fn is_zip(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"))
|
||||
}
|
||||
|
||||
fn invalid_zip(zip: &Path, reason: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::invalid(format!("{} is not a valid plugin archive: {reason}", zip.display()))
|
||||
}
|
||||
|
||||
/// Unpacks `zip` into `dest` and returns the package folder inside it. Refuses the
|
||||
/// whole archive when an entry would leave `dest` or is a symbolic link.
|
||||
pub(crate) fn unpack_zip(zip: &Path, dest: &Path) -> IdeResult<PathBuf> {
|
||||
let mut archive = zip::ZipArchive::new(BufReader::new(File::open(zip)?)).map_err(|e| invalid_zip(zip, e))?;
|
||||
if archive.len() > MAX_ENTRIES {
|
||||
return Err(invalid_zip(zip, format!("more than {MAX_ENTRIES} entries")));
|
||||
}
|
||||
let mut budget = MAX_UNPACKED_BYTES;
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive.by_index(i).map_err(|e| invalid_zip(zip, e))?;
|
||||
let name = entry.name().to_owned();
|
||||
let Some(rel) = entry.enclosed_name() else {
|
||||
return Err(invalid_zip(zip, format!("unsafe path {name:?}")));
|
||||
};
|
||||
if entry.is_symlink() {
|
||||
return Err(invalid_zip(zip, format!("symbolic link {name:?}")));
|
||||
}
|
||||
let out = dest.join(rel);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&out)?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = out.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// One byte over the budget tells a full budget from a file that exactly fits.
|
||||
let written =
|
||||
io::copy(&mut (&mut entry).take(budget + 1), &mut File::create(&out)?).map_err(|e| invalid_zip(zip, e))?;
|
||||
if written > budget {
|
||||
return Err(invalid_zip(zip, "it unpacks to more than 1 GB"));
|
||||
}
|
||||
budget -= written;
|
||||
}
|
||||
package_root(dest).ok_or_else(|| {
|
||||
IdeError::invalid(format!(
|
||||
"{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder",
|
||||
zip.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// `dir` itself, or its only folder as when a zip holds the package folder.
|
||||
/// Archiver metadata (`__MACOSX`, dot files) does not count as a second folder.
|
||||
fn package_root(dir: &Path) -> Option<PathBuf> {
|
||||
if dir.join(MANIFEST_FILE).is_file() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
let mut folders = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy();
|
||||
p.is_dir() && !name.starts_with('.') && name != "__MACOSX"
|
||||
});
|
||||
let only = folders.next().filter(|_| folders.next().is_none())?;
|
||||
only.join(MANIFEST_FILE).is_file().then_some(only)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,191 +0,0 @@
|
||||
//! Theme contributions: design-token overrides applied as CSS variables on `:root`.
|
||||
//! Theme and editor-scheme files are JSON that may contain `//` and `/* */` comments.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const MAX_TOKENS: usize = 512;
|
||||
const MAX_VALUE_LEN: usize = 256;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ThemeKind {
|
||||
Dark,
|
||||
Light,
|
||||
}
|
||||
|
||||
/// `{"id", "name", "kind", "tokens": {"--bg-app": "#002b36", ...}}`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThemeDef {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: ThemeKind,
|
||||
pub tokens: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// `{"name"?, "tokens": {...}}`: an editor colour scheme merged over a theme.
|
||||
#[derive(Deserialize)]
|
||||
struct EditorScheme {
|
||||
tokens: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ThemeDef {
|
||||
pub fn parse(src: &str) -> Result<Self, String> {
|
||||
let theme: Self =
|
||||
serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid theme JSON: {e}"))?;
|
||||
theme.validate()?;
|
||||
Ok(theme)
|
||||
}
|
||||
|
||||
/// Merges an editor colour scheme file over the theme's tokens (the scheme wins).
|
||||
pub fn merge_editor_scheme(&mut self, src: &str) -> Result<(), String> {
|
||||
let scheme: EditorScheme =
|
||||
serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid editor scheme JSON: {e}"))?;
|
||||
self.tokens.extend(scheme.tokens);
|
||||
self.validate()
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
crate::manifest::validate_command_id(&self.id).map_err(|e| e.replace("command id", "theme id"))?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("theme name must not be empty".into());
|
||||
}
|
||||
if self.tokens.len() > MAX_TOKENS {
|
||||
return Err(format!("too many tokens (max {MAX_TOKENS})"));
|
||||
}
|
||||
for (name, value) in &self.tokens {
|
||||
let valid_name = name.len() > 2
|
||||
&& name.len() <= 64
|
||||
&& name.starts_with("--")
|
||||
&& name[2..].bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
||||
if !valid_name {
|
||||
return Err(format!("invalid token name {name:?}: expected a CSS variable like --bg-app"));
|
||||
}
|
||||
if !is_safe_value(value) {
|
||||
return Err(format!("token {name}: unsupported value {value:?}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
|
||||
/// parse errors still point at the right line.
|
||||
fn strip_json_comments(src: &str) -> String {
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut chars = src.chars().peekable();
|
||||
let (mut in_str, mut escaped) = (false, false);
|
||||
while let Some(c) = chars.next() {
|
||||
if in_str {
|
||||
out.push(c);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == '"' {
|
||||
in_str = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match (c, chars.peek()) {
|
||||
('/', Some('/')) => {
|
||||
if chars.by_ref().any(|c| c == '\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
('/', Some('*')) => {
|
||||
chars.next();
|
||||
let mut prev = ' ';
|
||||
for c in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
out.push('\n');
|
||||
}
|
||||
if prev == '*' && c == '/' {
|
||||
break;
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
out.push(' ');
|
||||
}
|
||||
_ => {
|
||||
in_str = c == '"';
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Values are plain CSS values (colours, lengths, font stacks). Anything that
|
||||
/// could break out of the declaration or fetch resources is rejected.
|
||||
fn is_safe_value(v: &str) -> bool {
|
||||
let lower = v.to_ascii_lowercase();
|
||||
!v.trim().is_empty()
|
||||
&& v.len() <= MAX_VALUE_LEN
|
||||
&& !v.chars().any(|c| matches!(c, ';' | '{' | '}' | '<' | '>' | '\\' | '\n' | '\r'))
|
||||
&& !lower.contains("url(")
|
||||
&& !lower.contains("image(")
|
||||
&& !lower.contains("@import")
|
||||
&& !lower.contains("expression(")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_validates() {
|
||||
let t = ThemeDef::parse(r##"{"id":"sol","name":"Sol","kind":"dark","tokens":{"--bg-app":"#002b36","--font-ui":"'Inter', sans-serif"}}"##)
|
||||
.unwrap();
|
||||
assert_eq!(t.kind, ThemeKind::Dark);
|
||||
assert_eq!(t.tokens["--bg-app"], "#002b36");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_tokens() {
|
||||
let mk = |k: &str, v: &str| {
|
||||
ThemeDef { id: "t".into(), name: "T".into(), kind: ThemeKind::Light, tokens: [(k.into(), v.into())].into() }
|
||||
.validate()
|
||||
};
|
||||
assert!(mk("--bg", "red").is_ok());
|
||||
assert!(mk("bg", "red").is_err());
|
||||
assert!(mk("--Bg", "red").is_err());
|
||||
assert!(mk("--bg", "red; color: blue").is_err());
|
||||
assert!(mk("--bg", "url(https://evil)").is_err());
|
||||
assert!(mk("--bg", "}body{").is_err());
|
||||
assert!(mk("--bg", " ").is_err());
|
||||
assert!(ThemeDef::parse("{\"id\":\"x\"}").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_comments_outside_strings() {
|
||||
let src = r##"{
|
||||
// line comment
|
||||
"id": "c", "name": "C // not a comment", "kind": "dark",
|
||||
/* block
|
||||
comment */
|
||||
"tokens": { "--bg-app": "#000" /* inline */, "--font-ui": "\"a/*b\", sans-serif" }
|
||||
}"##;
|
||||
let t = ThemeDef::parse(src).unwrap();
|
||||
assert_eq!(t.name, "C // not a comment");
|
||||
assert_eq!(t.tokens["--font-ui"], "\"a/*b\", sans-serif");
|
||||
assert!(ThemeDef::parse("{\n // x\n \"id\": 1\n}").unwrap_err().contains("line 3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_editor_scheme_over_theme() {
|
||||
let mut t = ThemeDef::parse(
|
||||
r##"{"id":"m","name":"M","kind":"dark","tokens":{"--bg-app":"#111","--syn-keyword":"#222"}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
t.merge_editor_scheme(r##"{"name":"M", /* editor */ "tokens":{"--syn-keyword":"#333","--caret":"#fff"}}"##)
|
||||
.unwrap();
|
||||
assert_eq!(t.tokens["--bg-app"], "#111");
|
||||
assert_eq!(t.tokens["--syn-keyword"], "#333");
|
||||
assert_eq!(t.tokens["--caret"], "#fff");
|
||||
assert!(t.clone().merge_editor_scheme(r#"{"tokens":{"--caret":"url(x)"}}"#).is_err());
|
||||
assert!(t.merge_editor_scheme(r#"{"name":"no tokens"}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
//! UI-facing types and the event stream.
|
||||
//! Types shared by the runtime and the host.
|
||||
|
||||
use crate::manifest::{CommandDecl, Permission};
|
||||
use crate::theme::{ThemeDef, ThemeKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -16,156 +13,457 @@ pub enum NotificationLevel {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PluginSource {
|
||||
/// Installed under `<app-data>/plugins/<id>/`.
|
||||
Installed,
|
||||
/// Loaded from `<project>/.openidea/plugins/<dir>/` while that project is open.
|
||||
Dev,
|
||||
/// The focused editor of the window a command came from.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EditorContext {
|
||||
pub path: PathBuf,
|
||||
pub language: String,
|
||||
/// Selections when the command was invoked (cursors are empty ranges).
|
||||
pub selections: Vec<ide_core::Range>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PluginStatus {
|
||||
/// Code loaded and activated.
|
||||
Active,
|
||||
/// No code; static contributions only.
|
||||
Static,
|
||||
Disabled,
|
||||
/// Failed to load, or trapped and was disabled for this session (see `error`).
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginLanguage {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginTheme {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: ThemeKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub change_notes: Option<String>,
|
||||
pub source: PluginSource,
|
||||
/// Package directory.
|
||||
pub path: ide_core::IpcPath,
|
||||
pub enabled: bool,
|
||||
pub status: PluginStatus,
|
||||
pub error: Option<String>,
|
||||
pub has_code: bool,
|
||||
pub permissions: Vec<Permission>,
|
||||
/// Commands contributed while enabled (manifest + registered at runtime).
|
||||
pub commands: Vec<CommandDecl>,
|
||||
pub languages: Vec<PluginLanguage>,
|
||||
pub themes: Vec<PluginTheme>,
|
||||
}
|
||||
|
||||
/// A plugin package available for installation from a local catalog
|
||||
/// directory (the samples shipped with the IDE). Not installed by listing it.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogPlugin {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub change_notes: Option<String>,
|
||||
/// Package directory; install with `install_from_path(path)`.
|
||||
pub path: ide_core::IpcPath,
|
||||
pub has_code: bool,
|
||||
pub permissions: Vec<Permission>,
|
||||
pub commands: Vec<CommandDecl>,
|
||||
pub language_count: u32,
|
||||
pub theme_count: u32,
|
||||
/// Why the package cannot be installed as is (e.g. its WASM is not built).
|
||||
pub problem: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActiveTheme {
|
||||
pub plugin_id: String,
|
||||
pub theme: ThemeDef,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginsSnapshot {
|
||||
pub plugins: Vec<PluginInfo>,
|
||||
pub active_theme: Option<ActiveTheme>,
|
||||
}
|
||||
|
||||
/// Backend → UI notifications (Tauri event `ide://plugin`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PluginEvent {
|
||||
/// Plugins, their status or their commands changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
InsertText { plugin_id: String, text: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ReplaceSelection { plugin_id: String, text: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ThemeChanged { theme: Option<ActiveTheme> },
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
/// (`None` = broadcast, e.g. during activation).
|
||||
pub trait PluginEventSink: Send + Sync {
|
||||
fn emit(&self, client: Option<&str>, event: PluginEvent);
|
||||
}
|
||||
|
||||
impl<F: Fn(Option<&str>, PluginEvent) + Send + Sync> PluginEventSink for F {
|
||||
fn emit(&self, client: Option<&str>, event: PluginEvent) {
|
||||
self(client, event)
|
||||
}
|
||||
}
|
||||
|
||||
pub type BufferText = Arc<dyn Fn(&Path) -> Option<String> + Send + Sync>;
|
||||
|
||||
/// Where a command runs: the invoking window and its project.
|
||||
#[derive(Clone, Default)]
|
||||
/// Where a command or event batch comes from: the window and its project.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Invocation {
|
||||
pub client: Option<String>,
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
/// Unsaved editor contents win over disk for `read-file`.
|
||||
pub buffer_text: Option<BufferText>,
|
||||
pub editor: Option<EditorContext>,
|
||||
}
|
||||
|
||||
/// Per-call resource limits for plugin code.
|
||||
/// A failed host call, as the plugin sees it (WIT `types.error`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HostError {
|
||||
/// Not granted; names the capability.
|
||||
Denied(String),
|
||||
/// The document changed since the version an edit was made for.
|
||||
Stale,
|
||||
NotFound(String),
|
||||
Invalid(String),
|
||||
Cancelled,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HostError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Denied(c) => write!(f, "permission denied: {c} is not granted"),
|
||||
Self::Stale => f.write_str("the document changed"),
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin's status bar item.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusItem {
|
||||
pub text: String,
|
||||
pub tooltip: Option<String>,
|
||||
/// One of the plugin's commands, run on click.
|
||||
pub command: Option<String>,
|
||||
}
|
||||
|
||||
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum EventKind {
|
||||
FileOpened,
|
||||
FileSaved,
|
||||
EditorFocused,
|
||||
ActionPerformed,
|
||||
VcsChanged,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub const ALL: [Self; 6] = [
|
||||
Self::FileOpened,
|
||||
Self::FileSaved,
|
||||
Self::EditorFocused,
|
||||
Self::ActionPerformed,
|
||||
Self::VcsChanged,
|
||||
Self::Scheduled,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::FileOpened => "file-opened",
|
||||
Self::FileSaved => "file-saved",
|
||||
Self::EditorFocused => "editor-focused",
|
||||
Self::ActionPerformed => "action-performed",
|
||||
Self::VcsChanged => "vcs-changed",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|k| k.name() == s)
|
||||
}
|
||||
|
||||
/// The capability a subscription needs (`None`: `scheduled`, which the
|
||||
/// manifest declares).
|
||||
pub fn capability(self) -> Option<ide_extension::Capability> {
|
||||
match self {
|
||||
Self::ActionPerformed => Some(ide_extension::Capability::InputEvents),
|
||||
Self::Scheduled => None,
|
||||
_ => Some(ide_extension::Capability::Events),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the user started an action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ActionSource {
|
||||
Keyboard,
|
||||
Menu,
|
||||
Toolbar,
|
||||
ContextMenu,
|
||||
Search,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionPerformed {
|
||||
pub action: String,
|
||||
pub title: String,
|
||||
pub source: ActionSource,
|
||||
pub shortcut: Option<String>,
|
||||
}
|
||||
|
||||
/// A workbench event delivered to plugins.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum WorkbenchEvent {
|
||||
FileOpened(String),
|
||||
FileSaved(String),
|
||||
EditorFocused(Option<String>),
|
||||
ActionPerformed(ActionPerformed),
|
||||
VcsChanged(String),
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl WorkbenchEvent {
|
||||
pub fn kind(&self) -> EventKind {
|
||||
match self {
|
||||
Self::FileOpened(_) => EventKind::FileOpened,
|
||||
Self::FileSaved(_) => EventKind::FileSaved,
|
||||
Self::EditorFocused(_) => EventKind::EditorFocused,
|
||||
Self::ActionPerformed(_) => EventKind::ActionPerformed,
|
||||
Self::VcsChanged(_) => EventKind::VcsChanged,
|
||||
Self::Scheduled => EventKind::Scheduled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the repository containing a project (WIT `vcs.repository-status`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RepositoryStatus {
|
||||
pub root: String,
|
||||
pub branch: Option<String>,
|
||||
pub commit: Option<String>,
|
||||
pub upstream: Option<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
pub operation: Option<String>,
|
||||
pub changed_files: u32,
|
||||
}
|
||||
|
||||
/// Resource limits and supervision policy for plugin code.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
/// Wasm fuel (≈ instructions) per host → plugin call.
|
||||
pub fuel: u64,
|
||||
/// Wall-clock limit per call (epoch interruption).
|
||||
/// Wall-clock limit of commands, events, activation and deactivation.
|
||||
pub timeout: Duration,
|
||||
/// Wall-clock limit of a call that began a task (`tasks.begin`).
|
||||
pub background: Duration,
|
||||
/// Edit-path calls (syntax): the caller stops waiting after this and the
|
||||
/// result is dropped; the plugin keeps its instance.
|
||||
pub edit_soft: Duration,
|
||||
/// Edit-path calls still running after this are interrupted (the instance
|
||||
/// is rebuilt on next use).
|
||||
pub edit_hard: Duration,
|
||||
/// Linear memory cap per plugin.
|
||||
pub memory_bytes: usize,
|
||||
/// Wait before restarting after a crash; doubles with each consecutive crash.
|
||||
pub restart_backoff: Duration,
|
||||
/// Consecutive crashes after which the plugin is stopped for the session.
|
||||
pub max_failures: u32,
|
||||
/// Instances unused for this long are reclaimed (`None`: kept).
|
||||
pub idle_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { fuel: 5_000_000_000, timeout: Duration::from_secs(5), memory_bytes: 256 << 20 }
|
||||
Self {
|
||||
fuel: 5_000_000_000,
|
||||
timeout: Duration::from_secs(5),
|
||||
background: Duration::from_secs(60),
|
||||
edit_soft: Duration::from_millis(50),
|
||||
edit_hard: Duration::from_millis(500),
|
||||
memory_bytes: 256 << 20,
|
||||
restart_backoff: Duration::from_secs(1),
|
||||
max_failures: 3,
|
||||
idle_timeout: Some(Duration::from_secs(300)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// Delay before the restart that follows the `failures`-th consecutive crash.
|
||||
pub fn backoff(&self, failures: u32) -> Duration {
|
||||
let factor = 1u32 << failures.saturating_sub(1).min(6);
|
||||
(self.restart_backoff * factor).min(Duration::from_secs(60).max(self.restart_backoff))
|
||||
}
|
||||
}
|
||||
|
||||
/// A highlighted range from a plugin's `syntax-provider` (UTF-8 byte offsets).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SyntaxToken {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// A structure-view symbol from a plugin's `syntax-provider` (UTF-8 byte offsets).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SyntaxSymbol {
|
||||
pub name: String,
|
||||
pub detail: Option<String>,
|
||||
pub kind: String,
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub name_start: u32,
|
||||
pub name_end: u32,
|
||||
/// Index of the enclosing symbol in the same list.
|
||||
pub parent: Option<u32>,
|
||||
}
|
||||
|
||||
/// A foldable line range from a plugin's `syntax-provider`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SyntaxFold {
|
||||
pub start_line: u32,
|
||||
pub end_line: u32,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// An HTTP request from a plugin (WIT `net.request`); headers may hold
|
||||
/// `{{secret:<name>}}` placeholders for the host to fill in.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HttpRequest {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<Vec<u8>>,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// What a one-off process printed (WIT `process.output`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProcessOutput {
|
||||
pub status: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// Where `net:` requests may go: the URL's host when it is `https`, or
|
||||
/// `http` to the loopback interface.
|
||||
pub fn net_host(url: &str) -> Result<String, String> {
|
||||
let (scheme, rest) = url.split_once("://").ok_or_else(|| format!("{url:?} is not an absolute URL"))?;
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||
let host = if let Some(v6) = authority.strip_prefix('[') {
|
||||
v6.split(']').next().unwrap_or_default().to_owned()
|
||||
} else {
|
||||
authority.split(':').next().unwrap_or_default().to_ascii_lowercase()
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err(format!("{url:?} has no host"));
|
||||
}
|
||||
let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"https" => Ok(host),
|
||||
"http" if loopback => Ok(host),
|
||||
"http" => Err(format!("{url:?}: plain http is only allowed to localhost")),
|
||||
other => Err(format!("{url:?}: unsupported scheme {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn net_hosts() {
|
||||
assert_eq!(net_host("https://API.example.com:8443/v1?x").unwrap(), "api.example.com");
|
||||
assert_eq!(net_host("http://localhost:11434/api").unwrap(), "localhost");
|
||||
assert_eq!(net_host("https://user:[email protected]/").unwrap(), "h.io");
|
||||
assert!(net_host("http://example.com/").is_err());
|
||||
assert!(net_host("file:///etc/passwd").is_err());
|
||||
assert!(net_host("example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// The document a provider request is about.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProviderDocument {
|
||||
pub path: PathBuf,
|
||||
pub language: String,
|
||||
pub version: u64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Location {
|
||||
pub path: PathBuf,
|
||||
pub range: ide_core::Range,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CompletionItem {
|
||||
pub label: String,
|
||||
pub detail: Option<String>,
|
||||
/// WIT `completion-kind` name (`method`, `keyword`, …).
|
||||
pub kind: Option<String>,
|
||||
pub insert_text: String,
|
||||
pub replace: Option<ide_core::Range>,
|
||||
pub sort_text: Option<String>,
|
||||
pub documentation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InlineSuggestion {
|
||||
pub insert_text: String,
|
||||
pub replace: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CodeAction {
|
||||
pub title: String,
|
||||
pub kind: Option<String>,
|
||||
pub preferred: bool,
|
||||
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LineMarker {
|
||||
pub line: u32,
|
||||
pub icon: Option<String>,
|
||||
pub tooltip: String,
|
||||
pub target: Option<Location>,
|
||||
}
|
||||
|
||||
// ---- declarative views (wit/views.wit) ---------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewButton {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub icon: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewTreeItem {
|
||||
pub key: String,
|
||||
pub parent: Option<String>,
|
||||
pub label: String,
|
||||
pub icon: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
pub expandable: bool,
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewListItem {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub icon: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum FieldValue {
|
||||
Text(String),
|
||||
Multiline(String),
|
||||
Checkbox(bool),
|
||||
Choice { options: Vec<String>, selected: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewField {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub value: FieldValue,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewTable {
|
||||
pub columns: Vec<String>,
|
||||
pub total_rows: u32,
|
||||
pub first_row: u32,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum ViewBody {
|
||||
Message(String),
|
||||
Tree(Vec<ViewTreeItem>),
|
||||
List(Vec<ViewListItem>),
|
||||
Form(Vec<ViewField>),
|
||||
Table(ViewTable),
|
||||
Html(String),
|
||||
}
|
||||
|
||||
/// What a view or custom editor shows.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct View {
|
||||
pub toolbar: Vec<ViewButton>,
|
||||
pub body: ViewBody,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// The table rows the UI shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Viewport {
|
||||
pub first_row: u32,
|
||||
pub rows: u32,
|
||||
}
|
||||
|
||||
/// A user action in a view.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum ViewEvent {
|
||||
Button { id: String },
|
||||
Activate { key: String },
|
||||
Select { key: String },
|
||||
Expand { key: String },
|
||||
Collapse { key: String },
|
||||
Field { key: String, value: String },
|
||||
Cell { row: u32, column: u32, value: String },
|
||||
InsertRow { before: u32 },
|
||||
DeleteRows { rows: Vec<u32> },
|
||||
Message { text: String },
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
//! The host side of the `openidea:[email protected]` world: bindings, resource
|
||||
//! types and the implementation of every import. Capabilities are checked
|
||||
//! here; a missing one returns `error::denied` (the plugin can degrade).
|
||||
|
||||
use crate::runtime::{StoreData, clip};
|
||||
use crate::sandbox;
|
||||
use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host};
|
||||
use ide_extension::Capability;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use wasmtime::component::Resource;
|
||||
|
||||
pub(crate) mod bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "plugin",
|
||||
path: "../../wit",
|
||||
imports: { default: trappable },
|
||||
with: {
|
||||
"openidea:plugin/workspace.project": super::ProjectRes,
|
||||
"openidea:plugin/editor.text-editor": super::EditorRes,
|
||||
"openidea:plugin/invocation.invocation": super::InvocationRes,
|
||||
"openidea:plugin/tasks.task": super::TaskRes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `syntax-provider` export, bound on its own when present.
|
||||
pub(crate) mod syntax_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "plugin-syntax",
|
||||
path: "../../wit",
|
||||
imports: { default: trappable },
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/log": super::bindings::openidea::plugin::log,
|
||||
"openidea:plugin/ui": super::bindings::openidea::plugin::ui,
|
||||
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
||||
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
||||
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `providers` export.
|
||||
pub(crate) mod providers_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "plugin-providers",
|
||||
path: "../../wit",
|
||||
imports: { default: trappable },
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/log": super::bindings::openidea::plugin::log,
|
||||
"openidea:plugin/ui": super::bindings::openidea::plugin::ui,
|
||||
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
||||
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
||||
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `view-provider` export.
|
||||
pub(crate) mod views_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "plugin-views",
|
||||
path: "../../wit",
|
||||
imports: { default: trappable },
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/log": super::bindings::openidea::plugin::log,
|
||||
"openidea:plugin/ui": super::bindings::openidea::plugin::ui,
|
||||
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
||||
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
||||
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
use bindings::openidea::plugin::{
|
||||
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
|
||||
vcs, views, workspace,
|
||||
};
|
||||
|
||||
/// A project handle: its root.
|
||||
pub struct ProjectRes {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
/// The invoking window's focused editor.
|
||||
pub struct EditorRes {
|
||||
ctx: EditorContext,
|
||||
}
|
||||
|
||||
/// The current call's invocation (read from [`StoreData`]).
|
||||
pub struct InvocationRes;
|
||||
|
||||
pub struct TaskRes {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
const MAX_EDITS: usize = 10_000;
|
||||
const MAX_DIAGNOSTICS: usize = 5_000;
|
||||
const MAX_REQUEST_BODY: usize = 8 << 20;
|
||||
const MAX_FIND: u32 = 10_000;
|
||||
const MAX_STATUS_TEXT: usize = 128;
|
||||
|
||||
type Fallible<T> = wasmtime::Result<Result<T, wit::Error>>;
|
||||
|
||||
pub(crate) fn to_wit(e: HostError) -> wit::Error {
|
||||
match e {
|
||||
HostError::Denied(c) => wit::Error::Denied(c),
|
||||
HostError::Stale => wit::Error::Stale,
|
||||
HostError::NotFound(m) => wit::Error::NotFound(m),
|
||||
HostError::Invalid(m) => wit::Error::Invalid(m),
|
||||
HostError::Cancelled => wit::Error::Cancelled,
|
||||
HostError::Failed(m) => wit::Error::Failed(m),
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest's `error` as text (`denied: net:example.com`, `stale`, …).
|
||||
impl From<wit::Error> for crate::runtime::GuestError {
|
||||
fn from(e: wit::Error) -> Self {
|
||||
let clip = |m: String| clip(m, crate::runtime::MAX_TITLE * 16);
|
||||
match e {
|
||||
wit::Error::Denied(c) => Self::Denied(clip(c)),
|
||||
wit::Error::Stale => Self::Stale,
|
||||
wit::Error::NotFound(m) => Self::NotFound(clip(m)),
|
||||
wit::Error::Invalid(m) => Self::Invalid(clip(m)),
|
||||
wit::Error::Cancelled => Self::Cancelled,
|
||||
wit::Error::Failed(m) => Self::Failed(clip(m)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pos(p: wit::Position) -> ide_core::Pos {
|
||||
ide_core::Pos::new(p.line, p.character)
|
||||
}
|
||||
|
||||
fn range_to_wit(r: &ide_core::Range) -> wit::Range {
|
||||
wit::Range {
|
||||
start: wit::Position { line: r.start.line, character: r.start.col },
|
||||
end: wit::Position { line: r.end.line, character: r.end.col },
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreData {
|
||||
fn require(&self, cap: Capability) -> Result<(), wit::Error> {
|
||||
if self.granted.iter().any(|g| g.covers(&cap)) { Ok(()) } else { Err(wit::Error::Denied(cap.to_string())) }
|
||||
}
|
||||
|
||||
fn client(&self) -> Option<&str> {
|
||||
self.invocation.client.as_deref()
|
||||
}
|
||||
|
||||
fn push<T: Send + 'static>(&mut self, value: T) -> wasmtime::Result<Resource<T>> {
|
||||
Ok(self.table.push(value)?)
|
||||
}
|
||||
|
||||
pub(crate) fn new_invocation(&mut self) -> wasmtime::Result<Resource<InvocationRes>> {
|
||||
self.push(InvocationRes)
|
||||
}
|
||||
|
||||
pub(crate) fn drop_invocation(&mut self, res: Resource<InvocationRes>) {
|
||||
let _ = self.table.delete(res);
|
||||
}
|
||||
}
|
||||
|
||||
impl wit::Host for StoreData {}
|
||||
|
||||
impl log::Host for StoreData {
|
||||
fn log(&mut self, level: wit::LogLevel, message: String) -> wasmtime::Result<()> {
|
||||
let id = &self.plugin_id;
|
||||
let message = clip(message, crate::runtime::MAX_TEXT);
|
||||
match level {
|
||||
wit::LogLevel::Trace => tracing::trace!(plugin = %id, "{message}"),
|
||||
wit::LogLevel::Debug => tracing::debug!(plugin = %id, "{message}"),
|
||||
wit::LogLevel::Info => tracing::info!(plugin = %id, "{message}"),
|
||||
wit::LogLevel::Warn => tracing::warn!(plugin = %id, "{message}"),
|
||||
wit::LogLevel::Error => tracing::error!(plugin = %id, "{message}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ui::Host for StoreData {
|
||||
fn show_notification(
|
||||
&mut self,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
) -> wasmtime::Result<()> {
|
||||
let level = match level {
|
||||
wit::NotificationLevel::Info => NotificationLevel::Info,
|
||||
wit::NotificationLevel::Warning => NotificationLevel::Warning,
|
||||
wit::NotificationLevel::Error => NotificationLevel::Error,
|
||||
};
|
||||
let max = crate::runtime::MAX_TITLE;
|
||||
self.host.notify(self.client(), level, clip(title, max), clip(body, 16 << 10));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_status_item(&mut self, id: String, item: ui::StatusItem) -> wasmtime::Result<()> {
|
||||
let item = StatusItem {
|
||||
text: clip(item.text, MAX_STATUS_TEXT),
|
||||
tooltip: item.tooltip.map(|t| clip(t, 1024)),
|
||||
command: item.command.map(|c| clip(c, 128)),
|
||||
};
|
||||
self.host.status_item(&clip(id, 64), Some(item));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_status_item(&mut self, id: String) -> wasmtime::Result<()> {
|
||||
self.host.status_item(&clip(id, 64), None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl storage::Host for StoreData {
|
||||
fn get(&mut self, key: String) -> Fallible<Option<String>> {
|
||||
if let Err(e) = self.require(Capability::Storage) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.storage_get(&key).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn set(&mut self, key: String, value: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Storage) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.storage_set(&key, Some(value)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn delete(&mut self, key: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Storage) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.storage_set(&key, None).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn keys(&mut self, prefix: String) -> Fallible<Vec<String>> {
|
||||
if let Err(e) = self.require(Capability::Storage) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.storage_keys(&prefix).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl workspace::Host for StoreData {
|
||||
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for root in self.host.projects() {
|
||||
out.push(self.push(ProjectRes { root })?);
|
||||
}
|
||||
Ok(Ok(out))
|
||||
}
|
||||
}
|
||||
|
||||
impl workspace::HostProject for StoreData {
|
||||
fn root(&mut self, this: Resource<ProjectRes>) -> Fallible<String> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
fn read_file(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<String> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
if let Some(text) = self.host.buffer_text(&real) {
|
||||
return Ok(Ok(text));
|
||||
}
|
||||
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn find_files(&mut self, this: Resource<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
Ok(self.host.find_files(&root, &glob, limit.min(MAX_FIND) as usize).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<ProjectRes>) -> wasmtime::Result<()> {
|
||||
self.table.delete(rep)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl editor::Host for StoreData {}
|
||||
|
||||
impl editor::HostTextEditor for StoreData {
|
||||
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn language(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
Ok(self.table.get(&this)?.ctx.language.clone())
|
||||
}
|
||||
|
||||
fn document(&mut self, this: Resource<EditorRes>) -> Fallible<editor::Document> {
|
||||
if let Err(e) = self.require(Capability::EditorRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let path = self.table.get(&this)?.ctx.path.clone();
|
||||
Ok(match self.host.document(&path) {
|
||||
Some((version, text)) => Ok(editor::Document { version, text }),
|
||||
None => Err(wit::Error::NotFound(format!("{} is not open", path.display()))),
|
||||
})
|
||||
}
|
||||
|
||||
fn selections(&mut self, this: Resource<EditorRes>) -> Fallible<Vec<wit::Range>> {
|
||||
if let Err(e) = self.require(Capability::EditorRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(Ok(self.table.get(&this)?.ctx.selections.iter().map(range_to_wit).collect()))
|
||||
}
|
||||
|
||||
fn apply_edits(&mut self, this: Resource<EditorRes>, version: u64, edits: Vec<wit::TextEdit>) -> Fallible<u64> {
|
||||
if let Err(e) = self.require(Capability::EditorWrite) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if edits.len() > MAX_EDITS {
|
||||
return Ok(Err(wit::Error::Invalid(format!("more than {MAX_EDITS} edits"))));
|
||||
}
|
||||
let total: usize = edits.iter().map(|e| e.text.len()).sum();
|
||||
if total > crate::runtime::MAX_TEXT * 8 {
|
||||
return Ok(Err(wit::Error::Invalid("edits too large".into())));
|
||||
}
|
||||
let path = self.table.get(&this)?.ctx.path.clone();
|
||||
let edits: Vec<ide_core::TextEdit> = edits
|
||||
.into_iter()
|
||||
.map(|e| ide_core::TextEdit {
|
||||
range: ide_core::Range::new(pos(e.range.start), pos(e.range.end)),
|
||||
new_text: e.text,
|
||||
})
|
||||
.collect();
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.apply_edits(client.as_deref(), &path, version, edits).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<EditorRes>) -> wasmtime::Result<()> {
|
||||
self.table.delete(rep)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl invocation::Host for StoreData {}
|
||||
|
||||
impl invocation::HostInvocation for StoreData {
|
||||
fn project(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Option<Resource<ProjectRes>>> {
|
||||
match self.invocation.workspace_root.clone() {
|
||||
Some(root) => Ok(Some(self.push(ProjectRes { root })?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn editor(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Option<Resource<EditorRes>>> {
|
||||
match self.invocation.editor.clone() {
|
||||
Some(ctx) => Ok(Some(self.push(EditorRes { ctx })?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<InvocationRes>) -> wasmtime::Result<()> {
|
||||
// Owned by the host for the duration of the call.
|
||||
let _ = rep;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl vcs::Host for StoreData {
|
||||
fn status(&mut self, project: Resource<ProjectRes>) -> Fallible<Option<vcs::RepositoryStatus>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self
|
||||
.host
|
||||
.vcs_status(&root)
|
||||
.map(|s| {
|
||||
s.map(|s| vcs::RepositoryStatus {
|
||||
root: s.root,
|
||||
branch: s.branch,
|
||||
commit: s.commit,
|
||||
upstream: s.upstream,
|
||||
ahead: s.ahead,
|
||||
behind: s.behind,
|
||||
operation: s.operation,
|
||||
changed_files: s.changed_files,
|
||||
})
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
|
||||
fn fetch(&mut self, project: Resource<ProjectRes>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::VcsWrite) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
let token = self.cancel.token();
|
||||
Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from_wit(k: events::EventKind) -> EventKind {
|
||||
match k {
|
||||
events::EventKind::FileOpened => EventKind::FileOpened,
|
||||
events::EventKind::FileSaved => EventKind::FileSaved,
|
||||
events::EventKind::EditorFocused => EventKind::EditorFocused,
|
||||
events::EventKind::ActionPerformed => EventKind::ActionPerformed,
|
||||
events::EventKind::VcsChanged => EventKind::VcsChanged,
|
||||
events::EventKind::Scheduled => EventKind::Scheduled,
|
||||
}
|
||||
}
|
||||
|
||||
impl events::Host for StoreData {
|
||||
fn subscribe(&mut self, kind: events::EventKind) -> Fallible<()> {
|
||||
let kind = kind_from_wit(kind);
|
||||
if let Some(cap) = kind.capability()
|
||||
&& let Err(e) = self.require(cap)
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
self.host.subscribe(kind, true);
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, kind: events::EventKind) -> wasmtime::Result<()> {
|
||||
self.host.subscribe(kind_from_wit(kind), false);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl tasks::Host for StoreData {
|
||||
fn begin(&mut self, title: String, cancellable: bool) -> wasmtime::Result<Resource<TaskRes>> {
|
||||
let token = self.cancel.token();
|
||||
let id = self.host.task_begin(self.client(), clip(title, crate::runtime::MAX_TITLE), cancellable, token);
|
||||
self.open_tasks.push(id);
|
||||
// The call may now run for the background budget.
|
||||
let extended = self.call_started + self.limits.background;
|
||||
if extended > self.deadline {
|
||||
self.deadline = extended;
|
||||
self.timeout = self.limits.background;
|
||||
}
|
||||
self.push(TaskRes { id })
|
||||
}
|
||||
}
|
||||
|
||||
impl tasks::HostTask for StoreData {
|
||||
fn report(
|
||||
&mut self,
|
||||
this: Resource<TaskRes>,
|
||||
done: u32,
|
||||
total: Option<u32>,
|
||||
message: Option<String>,
|
||||
) -> wasmtime::Result<()> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
let now = Instant::now();
|
||||
// At most ~10 updates per second reach the UI.
|
||||
if self.last_report.is_none_or(|t| now.duration_since(t).as_millis() >= 100) || total.is_some_and(|t| done >= t)
|
||||
{
|
||||
self.last_report = Some(now);
|
||||
self.host.task_report(id, done, total, message.map(|m| clip(m, 256)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_cancelled(&mut self, this: Resource<TaskRes>) -> wasmtime::Result<bool> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
Ok(self.cancel.token().is_cancelled() || self.host.task_cancelled(id))
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<TaskRes>) -> wasmtime::Result<()> {
|
||||
let id = self.table.delete(rep)?.id;
|
||||
self.open_tasks.retain(|t| *t != id);
|
||||
self.host.task_end(id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreData {
|
||||
/// What is left of the running call's wall-clock budget (host operations
|
||||
/// can't be interrupted by the epoch callback, so they get this as timeout).
|
||||
fn remaining(&self) -> Duration {
|
||||
self.deadline.saturating_duration_since(Instant::now())
|
||||
}
|
||||
}
|
||||
|
||||
impl diagnostics::Host for StoreData {
|
||||
fn publish(&mut self, path: String, items: Vec<diagnostics::Diagnostic>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let source = self.plugin_id.clone();
|
||||
let items = items
|
||||
.into_iter()
|
||||
.take(MAX_DIAGNOSTICS)
|
||||
.map(|d| ide_core::Diagnostic {
|
||||
range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)),
|
||||
severity: match d.severity {
|
||||
diagnostics::Severity::Error => ide_core::Severity::Error,
|
||||
diagnostics::Severity::Warning => ide_core::Severity::Warning,
|
||||
diagnostics::Severity::Info => ide_core::Severity::Info,
|
||||
diagnostics::Severity::Hint => ide_core::Severity::Hint,
|
||||
},
|
||||
message: clip(d.message, 4096),
|
||||
source: Some(source.clone()),
|
||||
code: d.code.map(|c| clip(c, 128)),
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: Some(source.clone()),
|
||||
})
|
||||
.collect();
|
||||
Ok(self.host.publish_diagnostics(Path::new(&path), items).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl net::Host for StoreData {
|
||||
fn fetch(&mut self, req: net::Request) -> Fallible<net::Response> {
|
||||
let host = match net_host(&req.url) {
|
||||
Ok(h) => h,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
if let Err(e) = self.require(Capability::Net(host)) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if req.headers.iter().any(|h| h.value.contains("{{secret:"))
|
||||
&& let Err(e) = self.require(Capability::Secrets)
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if req.body.as_ref().is_some_and(|b| b.len() > MAX_REQUEST_BODY) {
|
||||
return Ok(Err(wit::Error::Invalid("request body larger than 8 MiB".into())));
|
||||
}
|
||||
let wanted = Duration::from_millis(u64::from(req.timeout_ms.unwrap_or(30_000)));
|
||||
let request = HttpRequest {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
|
||||
body: req.body,
|
||||
timeout: wanted.min(self.remaining()),
|
||||
};
|
||||
let token = self.cancel.token();
|
||||
Ok(self
|
||||
.host
|
||||
.fetch(request, &token)
|
||||
.map(|r| net::Response {
|
||||
status: r.status,
|
||||
headers: r.headers.into_iter().map(|(name, value)| net::Header { name, value }).collect(),
|
||||
body: r.body,
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl secrets::Host for StoreData {
|
||||
fn is_set(&mut self, name: String) -> wasmtime::Result<bool> {
|
||||
Ok(self.granted.iter().any(|g| g.covers(&Capability::Secrets)) && self.host.secret_is_set(&name))
|
||||
}
|
||||
}
|
||||
|
||||
impl process::Host for StoreData {
|
||||
fn run(
|
||||
&mut self,
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
cwd: Option<Resource<ProjectRes>>,
|
||||
stdin: Option<String>,
|
||||
) -> Fallible<process::Output> {
|
||||
if let Err(e) = self.require(Capability::Process(program.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let cwd = match &cwd {
|
||||
Some(p) => Some(self.table.get(p)?.root.clone()),
|
||||
None => None,
|
||||
};
|
||||
let token = self.cancel.token();
|
||||
let timeout = self.remaining();
|
||||
Ok(self
|
||||
.host
|
||||
.run_process(&program, args, cwd.as_deref(), stdin, &token, timeout)
|
||||
.map(|o| process::Output { status: o.status, stdout: o.stdout, stderr: o.stderr })
|
||||
.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl views::Host for StoreData {
|
||||
fn refresh(&mut self, view: String) -> wasmtime::Result<()> {
|
||||
self.host.refresh_view(&clip(view, 64));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn post_message(&mut self, view: String, message: String) -> Fallible<()> {
|
||||
if message.len() > MAX_VIEW_MESSAGE {
|
||||
return Ok(Err(wit::Error::Invalid(format!("messages are limited to {MAX_VIEW_MESSAGE} bytes"))));
|
||||
}
|
||||
Ok(self.host.post_view_message(&clip(view, 64), message).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn open_terminal(&mut self, title: String, program: String, args: Vec<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Process(program.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if args.len() > 256 || args.iter().any(|a| a.len() > 4096) {
|
||||
return Ok(Err(wit::Error::Invalid("too many or too long arguments".into())));
|
||||
}
|
||||
let cwd = self.invocation.workspace_root.clone();
|
||||
let client = self.invocation.client.clone();
|
||||
let title = clip(title, crate::runtime::MAX_TITLE);
|
||||
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl settings::Host for StoreData {
|
||||
fn get(&mut self, key: String) -> wasmtime::Result<Option<String>> {
|
||||
let root = self.invocation.workspace_root.clone();
|
||||
Ok(self.host.setting(root.as_deref(), &clip(key, 128)))
|
||||
}
|
||||
}
|
||||
|
||||
impl views_bindings::openidea::plugin::view_types::Host for StoreData {}
|
||||
|
||||
/// Largest message between a plugin and its view's frame.
|
||||
const MAX_VIEW_MESSAGE: usize = 1 << 20;
|
||||
Reference in new issue
Заблокувати користувача