Files
openidea/sdk/rust/openidea-plugin/src/host.rs
T
1c5a92f952 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]>
2026-11-04 02:54:31 +08:00

561 lines
18 KiB
Rust

//! The IDE functions and handles a plugin may use. Gated ones need the
//! capability in `plugin.toml` (`[capabilities] required = [...]`) and the
//! user's grant; without it they return [`Error::Denied`].
use crate::types::*;
#[cfg(target_arch = "wasm32")]
mod imp {
//! Calls through the `openidea:[email protected]` world.
use crate::bindings::openidea::plugin as wit;
use crate::types::*;
pub type InvocationRef<'a> = &'a wit::invocation::Invocation;
pub type ProjectRes = wit::workspace::Project;
pub type EditorRes = wit::editor::TextEditor;
pub type TaskRes = wit::tasks::Task;
pub fn error(e: wit::types::Error) -> Error {
use wit::types::Error as E;
match e {
E::Denied(c) => Error::Denied(c),
E::Stale => Error::Stale,
E::NotFound(m) => Error::NotFound(m),
E::Invalid(m) => Error::Invalid(m),
E::Cancelled => Error::Cancelled,
E::Failed(m) => Error::Failed(m),
}
}
fn range_in(r: wit::types::Range) -> Range {
Range::new(Position::new(r.start.line, r.start.character), Position::new(r.end.line, r.end.character))
}
fn range_out(r: Range) -> wit::types::Range {
wit::types::Range {
start: wit::types::Position { line: r.start.line, character: r.start.character },
end: wit::types::Position { line: r.end.line, character: r.end.character },
}
}
pub fn log(level: LogLevel, message: &str) {
use wit::types::LogLevel as L;
let level = match level {
LogLevel::Trace => L::Trace,
LogLevel::Debug => L::Debug,
LogLevel::Info => L::Info,
LogLevel::Warn => L::Warn,
LogLevel::Error => L::Error,
};
wit::log::log(level, message)
}
pub fn notify(level: NotificationLevel, title: &str, body: &str) {
use wit::types::NotificationLevel as N;
let level = match level {
NotificationLevel::Info => N::Info,
NotificationLevel::Warning => N::Warning,
NotificationLevel::Error => N::Error,
};
wit::ui::show_notification(level, title, body)
}
pub fn set_status_item(id: &str, item: &StatusItem) {
let item = wit::ui::StatusItem {
text: item.text.clone(),
tooltip: item.tooltip.clone(),
command: item.command.clone(),
};
wit::ui::set_status_item(id, &item)
}
pub fn remove_status_item(id: &str) {
wit::ui::remove_status_item(id)
}
pub fn storage_get(key: &str) -> Result<Option<String>, Error> {
wit::storage::get(key).map_err(error)
}
pub fn storage_set(key: &str, value: &str) -> Result<(), Error> {
wit::storage::set(key, value).map_err(error)
}
pub fn storage_delete(key: &str) -> Result<(), Error> {
wit::storage::delete(key).map_err(error)
}
pub fn storage_keys(prefix: &str) -> Result<Vec<String>, Error> {
wit::storage::keys(prefix).map_err(error)
}
pub fn projects() -> Result<Vec<ProjectRes>, Error> {
wit::workspace::projects().map_err(error)
}
pub fn project_root(p: &ProjectRes) -> Result<String, Error> {
p.root().map_err(error)
}
pub fn project_read(p: &ProjectRes, path: &str) -> Result<String, Error> {
p.read_file(path).map_err(error)
}
pub fn invocation_project(i: InvocationRef<'_>) -> Option<ProjectRes> {
i.project()
}
pub fn invocation_editor(i: InvocationRef<'_>) -> Option<EditorRes> {
i.editor()
}
pub fn editor_path(e: &EditorRes) -> String {
e.path()
}
pub fn editor_language(e: &EditorRes) -> String {
e.language()
}
pub fn editor_document(e: &EditorRes) -> Result<Document, Error> {
e.document().map(|d| Document { version: d.version, text: d.text }).map_err(error)
}
pub fn editor_selections(e: &EditorRes) -> Result<Vec<Range>, Error> {
e.selections().map(|s| s.into_iter().map(range_in).collect()).map_err(error)
}
pub fn editor_apply(e: &EditorRes, version: u64, edits: &[TextEdit]) -> Result<u64, Error> {
let edits: Vec<wit::types::TextEdit> =
edits.iter().map(|t| wit::types::TextEdit { range: range_out(t.range), text: t.text.clone() }).collect();
e.apply_edits(version, &edits).map_err(error)
}
pub fn vcs_status(p: &ProjectRes) -> Result<Option<RepositoryStatus>, Error> {
wit::vcs::status(p)
.map(|s| {
s.map(|s| 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(error)
}
pub fn vcs_fetch(p: &ProjectRes) -> Result<(), Error> {
wit::vcs::fetch(p).map_err(error)
}
fn kind(k: EventKind) -> wit::events::EventKind {
use wit::events::EventKind as K;
match k {
EventKind::FileOpened => K::FileOpened,
EventKind::FileSaved => K::FileSaved,
EventKind::EditorFocused => K::EditorFocused,
EventKind::ActionPerformed => K::ActionPerformed,
EventKind::VcsChanged => K::VcsChanged,
EventKind::Scheduled => K::Scheduled,
}
}
pub fn subscribe(k: EventKind) -> Result<(), Error> {
wit::events::subscribe(kind(k)).map_err(error)
}
pub fn unsubscribe(k: EventKind) {
wit::events::unsubscribe(kind(k))
}
pub fn begin_task(title: &str, cancellable: bool) -> TaskRes {
wit::tasks::begin(title, cancellable)
}
pub fn task_report(t: &TaskRes, done: u32, total: Option<u32>, message: Option<&str>) {
t.report(done, total, message)
}
pub fn task_cancelled(t: &TaskRes) -> bool {
t.is_cancelled()
}
pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result<Vec<String>, Error> {
p.find_files(glob, limit).map_err(error)
}
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
use wit::diagnostics::{Diagnostic as D, Severity as S};
let items: Vec<D> = items
.iter()
.map(|d| D {
range: range_out(d.range),
severity: match d.severity {
Severity::Error => S::Error,
Severity::Warning => S::Warning,
Severity::Info => S::Info,
Severity::Hint => S::Hint,
},
message: d.message.clone(),
code: d.code.clone(),
})
.collect();
wit::diagnostics::publish(path, &items).map_err(error)
}
pub fn fetch(req: &HttpRequest) -> Result<HttpResponse, Error> {
let r = wit::net::Request {
method: req.method.clone(),
url: req.url.clone(),
headers: req.headers.iter().map(|(n, v)| wit::net::Header { name: n.clone(), value: v.clone() }).collect(),
body: req.body.clone(),
timeout_ms: req.timeout_ms,
};
wit::net::fetch(&r)
.map(|r| HttpResponse {
status: r.status,
headers: r.headers.into_iter().map(|h| (h.name, h.value)).collect(),
body: r.body,
})
.map_err(error)
}
pub fn secret_is_set(name: &str) -> bool {
wit::secrets::is_set(name)
}
pub fn refresh_view(view: &str) {
wit::views::refresh(view)
}
pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> {
wit::views::post_message(view, message).map_err(error)
}
pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> {
wit::views::open_terminal(title, program, args).map_err(error)
}
pub fn setting(key: &str) -> Option<String> {
wit::settings::get(key)
}
pub fn run_process(
program: &str,
args: &[String],
cwd: Option<&ProjectRes>,
stdin: Option<&str>,
) -> Result<ProcessOutput, Error> {
wit::process::run(program, args, cwd, stdin)
.map(|o| ProcessOutput { status: o.status, stdout: o.stdout, stderr: o.stderr })
.map_err(error)
}
pub fn range_from(r: wit::types::Range) -> Range {
range_in(r)
}
pub fn range_to(r: Range) -> wit::types::Range {
range_out(r)
}
pub fn event(e: wit::events::Event) -> Event {
use wit::events::{ActionSource as S, Event as E};
match e {
E::FileOpened(p) => Event::FileOpened(p),
E::FileSaved(p) => Event::FileSaved(p),
E::EditorFocused(p) => Event::EditorFocused(p),
E::VcsChanged(p) => Event::VcsChanged(p),
E::Scheduled => Event::Scheduled,
E::ActionPerformed(a) => Event::ActionPerformed(ActionPerformed {
action: a.action,
title: a.title,
source: match a.source {
S::Keyboard => ActionSource::Keyboard,
S::Menu => ActionSource::Menu,
S::Toolbar => ActionSource::Toolbar,
S::ContextMenu => ActionSource::ContextMenu,
S::Search => ActionSource::Search,
S::Other => ActionSource::Other,
},
shortcut: a.shortcut,
}),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
use crate::mock::current as imp;
/// Where a command or event batch comes from: the window's project and
/// focused editor.
pub struct Invocation<'a> {
inner: imp::InvocationRef<'a>,
}
impl<'a> Invocation<'a> {
#[doc(hidden)]
pub fn __new(inner: imp::InvocationRef<'a>) -> Self {
Self { inner }
}
/// The window's project.
pub fn project(&self) -> Option<Project> {
imp::invocation_project(self.inner).map(|inner| Project { inner })
}
/// The window's focused editor.
pub fn editor(&self) -> Option<TextEditor> {
imp::invocation_editor(self.inner).map(|inner| TextEditor { inner })
}
}
/// A project open in the IDE.
pub struct Project {
inner: imp::ProjectRes,
}
impl Project {
/// Absolute path of the root. Needs `project.read`.
pub fn root(&self) -> Result<String, Error> {
imp::project_root(&self.inner)
}
/// Reads a UTF-8 file inside the project (absolute or root-relative);
/// unsaved editor contents win. Needs `project.read`.
pub fn read_file(&self, path: &str) -> Result<String, Error> {
imp::project_read(&self.inner, path)
}
/// The repository containing the project (`None`: not under git). Needs `vcs.read`.
pub fn vcs_status(&self) -> Result<Option<RepositoryStatus>, Error> {
imp::vcs_status(&self.inner)
}
/// Fetches all remotes. Needs `vcs.write`; long-running, so call it in a [`Task`].
pub fn vcs_fetch(&self) -> Result<(), Error> {
imp::vcs_fetch(&self.inner)
}
/// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`),
/// at most `limit`, as absolute paths; ignored files are skipped. Needs `project.read`.
pub fn find_files(&self, glob: &str, limit: u32) -> Result<Vec<String>, Error> {
imp::project_find(&self.inner, glob, limit)
}
/// Runs the approved `process:<program>` in this project's root.
pub fn run_process(&self, program: &str, args: &[String], stdin: Option<&str>) -> Result<ProcessOutput, Error> {
imp::run_process(program, args, Some(&self.inner), stdin)
}
}
/// Replaces the plugin's diagnostics for `path` (absolute, in an open
/// project); an empty slice clears them. Needs `project.read`.
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
imp::publish_diagnostics(path, items)
}
/// HTTP(S) to a host granted with `net:<host>` (plain http only to
/// localhost). `{{secret:<name>}}` in a header is filled in by the IDE (needs
/// `secrets`). `Error::Cancelled` when the call was superseded.
pub fn fetch(request: &HttpRequest) -> Result<HttpResponse, Error> {
imp::fetch(request)
}
/// Whether the user stored secret `name` (declared under `[[contributes.secrets]]`).
pub fn secret_is_set(name: &str) -> bool {
imp::secret_is_set(name)
}
/// The content of view (or custom editor) `view` changed: the IDE renders it again.
pub fn refresh_view(view: &str) {
imp::refresh_view(view)
}
/// Sends `message` to the script of view `view`'s HTML body.
pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> {
imp::post_view_message(view, message)
}
/// Opens a terminal tab in the invoking window running the approved
/// `process:<program>` in the project root: logs that keep coming,
/// interactive sessions (`kubectl exec -it …`).
pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> {
imp::open_terminal(title, program, args)
}
/// One of the plugin's settings (`[contributes.settings]`) as JSON: the
/// user's value (project over global) or the schema's default.
pub fn setting(key: &str) -> Option<String> {
imp::setting(key)
}
/// A boolean setting.
pub fn setting_bool(key: &str) -> Option<bool> {
setting(key).and_then(|v| v.parse().ok())
}
/// A string setting.
pub fn setting_string(key: &str) -> Option<String> {
let v = setting(key)?;
let inner = v.strip_prefix('"')?.strip_suffix('"')?;
Some(crate::text::unescape_json(inner))
}
/// A number setting.
pub fn setting_number(key: &str) -> Option<f64> {
setting(key).and_then(|v| v.parse().ok())
}
/// Runs the approved `process:<program>` in the plugin's data directory
/// (see [`Project::run_process`] to run it in a project).
pub fn run_process(program: &str, args: &[String], stdin: Option<&str>) -> Result<ProcessOutput, Error> {
imp::run_process(program, args, None, stdin)
}
/// Projects open in any window. Needs `project.read`.
pub fn projects() -> Result<Vec<Project>, Error> {
Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect())
}
/// A file open in an editor.
pub struct TextEditor {
inner: imp::EditorRes,
}
impl TextEditor {
pub fn path(&self) -> String {
imp::editor_path(&self.inner)
}
pub fn language(&self) -> String {
imp::editor_language(&self.inner)
}
/// Current version and text. Needs `editor.read`.
pub fn document(&self) -> Result<Document, Error> {
imp::editor_document(&self.inner)
}
/// Selections when the command was invoked. Needs `editor.read`.
pub fn selections(&self) -> Result<Vec<Range>, Error> {
imp::editor_selections(&self.inner)
}
/// Applies `edits` (in `version`'s coordinates, not overlapping) as one
/// undoable change; [`Error::Stale`] if the document changed meanwhile.
/// Returns the new version. Needs `editor.write`.
pub fn apply_edits(&self, version: u64, edits: &[TextEdit]) -> Result<u64, Error> {
imp::editor_apply(&self.inner, version, edits)
}
/// Replaces every selection with `f(selected text)` in one undoable
/// change, retrying once if the user typed in between. Cursors get
/// `f("")` inserted. Needs `editor.read` and `editor.write`.
pub fn transform_selections(&self, mut f: impl FnMut(&str) -> String) -> Result<u64, Error> {
let selections = self.selections()?;
let mut last = Error::Stale;
for _ in 0..2 {
let doc = self.document()?;
let mut edits = Vec::with_capacity(selections.len());
for r in &selections {
let old = doc.text_in(*r).ok_or_else(|| Error::Invalid("selection outside the document".into()))?;
let new = f(old);
if new != old {
edits.push(TextEdit { range: *r, text: new });
}
}
match self.apply_edits(doc.version, &edits) {
Err(Error::Stale) => last = Error::Stale,
other => return other,
}
}
Err(last)
}
}
/// Long-running work shown in the status bar; dropping it ends it. While a
/// task is open, the current call may run for up to 60 s instead of 5 s.
pub struct Task {
inner: imp::TaskRes,
}
impl Task {
/// `done` of `total` units (`None`: indeterminate), with a short status.
pub fn report(&self, done: u32, total: Option<u32>, message: Option<&str>) {
imp::task_report(&self.inner, done, total, message)
}
/// The user cancelled it: stop soon and return.
pub fn is_cancelled(&self) -> bool {
imp::task_cancelled(&self.inner)
}
}
/// Starts a task (see [`Task`]).
pub fn begin_task(title: &str, cancellable: bool) -> Task {
Task { inner: imp::begin_task(title, cancellable) }
}
/// Writes to the IDE log, tagged with the plugin id.
pub fn log(level: LogLevel, message: &str) {
imp::log(level, message)
}
/// Shows a notification balloon in the window the command or events came
/// from (every window when there is none).
pub fn notify(level: NotificationLevel, title: &str, body: &str) {
imp::notify(level, title, body)
}
/// Shows or updates the plugin's status bar item `id` (at most 8).
pub fn set_status_item(id: &str, item: &StatusItem) {
imp::set_status_item(id, item)
}
pub fn remove_status_item(id: &str) {
imp::remove_status_item(id)
}
/// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of
/// the session (also after the instance is reclaimed: an event activates the
/// plugin again). Needs `events` (`events.input` for `ActionPerformed`).
pub fn subscribe(kind: EventKind) -> Result<(), Error> {
imp::subscribe(kind)
}
pub fn unsubscribe(kind: EventKind) {
imp::unsubscribe(kind)
}
/// The plugin's persistent key-value store (≤ 1 MiB). Needs `storage`.
pub mod storage {
use super::imp;
use crate::types::Error;
pub fn get(key: &str) -> Result<Option<String>, Error> {
imp::storage_get(key)
}
pub fn set(key: &str, value: &str) -> Result<(), Error> {
imp::storage_set(key, value)
}
pub fn delete(key: &str) -> Result<(), Error> {
imp::storage_delete(key)
}
/// Keys starting with `prefix`, sorted.
pub fn keys(prefix: &str) -> Result<Vec<String>, Error> {
imp::storage_keys(prefix)
}
}
#[cfg(target_arch = "wasm32")]
#[doc(hidden)]
pub use imp::{event as __event, range_from as __range_from, range_to as __range_to};