feat(plugins): finalize plugin API before release, concurrent providers

Plugin API (breaking; the plugin system is unreleased):
- Remove the runtime `contributions` interface (register-command,
  register-language, register-theme). Commands, languages and themes are
  declared in plugin.toml only, so everything a plugin contributes goes
  through the install preview and consent. The registry drops runtime
  scope, `contribute` and `dispose_runtime`; inline `definition` in
  language and theme declarations is gone.
- `completion-item.kind` is a string (the 25 LSP kinds; unknown kinds show
  no icon), like the other open-ended kinds.
- `lifecycle.activate` and `commands.run` return `types.error` instead of a
  string. The host maps it to the matching error kind, and only `failed`
  counts against the plugin: cancelling, a missing grant or an unknown
  command does not. The SDK's `Plugin` returns `Result<_, Error>`; plain
  messages convert to `Error::Failed`.

Runtime:
- Editor providers of several plugins are called at the same time
  (`Actor::submit` + `Pending::wait`): the wait is the slowest plugin's,
  not the sum, so each gets the full edit-path budget.

Versioning and tooling:
- Components are checked at install and by `openidea-plugin check` for the
  plugin API they were built for; another major or a newer minor is
  refused with what to do.
- Tests: the SDK's WIT matches the host's, a simulated next-minor host
  links every shipped component, every host function has an SDK wrapper,
  released APIs only grow (no snapshot until the first release).
- plugins/build.sh builds every checked-in component, records a source
  fingerprint next to it, and `--check` fails on stale components; CI runs
  it plus fmt, clippy (host and wasm) and tests for the SDK, the official
  plugins and the fixtures.
- The API evolution rules and the checklist for a new host function are in
  plugins/README.md; the design notes under docs/ are removed.
Цей коміт міститься в:
l0ng-ai committed 2026-11-04 02:41:46 +08:00
1 джерело fa003016ef
коміт 78e0c7a23e
105 files changed
+1521 -1582

No files matched your search

+33
Переглянути файл
@@ -71,6 +71,39 @@ jobs:
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
# The plugin SDK, the official plugins and the test fixtures build in their
# own workspaces (the root excludes them), so the jobs above don't see them.
plugins:
name: Plugins (SDK, official, fixtures)
runs-on: ubuntu-22.04
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
- uses: Swatinem/rust-cache@v2
with:
key: plugins
cache-directories: target-plugins
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Format, lint (host and wasm) and test each crate
shell: bash
run: |
set -euo pipefail
for dir in sdk/rust/openidea-plugin plugins/*/ plugins/official/*/ crates/ide-plugin/tests/fixtures/misbehave; do
[ -f "$dir/Cargo.toml" ] || continue
echo "::group::$dir"
cargo fmt --check --manifest-path "$dir/Cargo.toml"
cargo clippy --locked --all-targets --manifest-path "$dir/Cargo.toml" -- -D warnings
cargo clippy --locked --target wasm32-wasip2 --manifest-path "$dir/Cargo.toml" -- -D warnings
cargo test --locked --manifest-path "$dir/Cargo.toml"
echo "::endgroup::"
done
# The host's tests run the checked-in components: they must be built from these sources.
- name: Checked-in components are up to date
run: plugins/build.sh --check
deny:
name: Cargo deny
runs-on: ubuntu-latest
Створено
+1 -1
Переглянути файл
@@ -4451,13 +4451,13 @@ dependencies = [
"ide-core",
"ide-extension",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
"tempfile",
"thiserror 2.0.21",
"tokio",
"toml 0.9.12+spec-1.1.0",
"tracing",
"wasmtime",
"wasmtime-wasi",
+1 -1
Переглянути файл
@@ -1149,7 +1149,7 @@ async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
}
}
/// Plan P3: a built-in language definition (GDScript) shipped by a plugin
/// A built-in language definition (GDScript) shipped by a plugin
/// instead: its debugger starts the program approved for the plugin's
/// `process:godot` grant, and refuses once that file changed.
#[cfg(unix)]
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
//! The contribution index (docs/plugin_plan.md 4.5): validated UI
//! The contribution index: validated UI
//! declarations of installed packages, kept on disk so start-up does not
//! validate them again (sanitising thousands of icon SVGs, say).
//!
+2 -3
Переглянути файл
@@ -1,5 +1,4 @@
//! The plugin system's kernel, independent of the Wasm runtime
//! (`docs/plugin_plan.md` 3–4):
//! The plugin system's kernel, independent of the Wasm runtime:
//!
//! - [`Manifest`]: `plugin.toml` (0.2 format; 0.1 still parses);
//! - [`Capability`]: what a plugin may do, with its [`Risk`] level;
@@ -32,6 +31,6 @@ pub use manifest::{
engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet, Scope};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
pub use when::validate_when;
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
//! Plugin distribution (docs/plugin_plan.md 4.7): the package file, its
//! Plugin distribution: the package file, its
//! signature, and the static marketplace index.
//!
//! - **Package** (`<id>-<version>.oiplugin`): a zip of the manifest, the files
+8 -87
Переглянути файл
@@ -9,14 +9,6 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
/// Manifest contributions are `Static` (re-committed on every start); `Runtime`
/// ones are registered by the plugin's code and withdrawn when it stops.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scope {
Static,
Runtime,
}
/// A contribution as the plugin details page lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -28,9 +20,7 @@ pub struct ContributionInfo {
struct Entry {
point: &'static str,
key: Option<String>,
decl: toml::Value,
label: String,
scope: Scope,
disposable: Disposable,
}
@@ -43,7 +33,7 @@ pub struct PreparedSet {
pub files: Vec<PathBuf>,
/// Declarations for extension points this host does not know (skipped).
pub warnings: Vec<String>,
items: Vec<(&'static str, toml::Value, Prepared)>,
items: Vec<(&'static str, Prepared)>,
}
impl PreparedSet {
@@ -122,7 +112,7 @@ impl ExtensionRegistry {
};
for (i, decl) in pkg.manifest.declarations(point_id).into_iter().enumerate() {
let prepared = point
.prepare(pkg, decl.clone())
.prepare(pkg, decl)
.map_err(|e| format!("contributes.{point_id}[{i}]: {}", strip_prefix(&e, point_id)))?;
for cap in &prepared.capabilities {
if !set.capabilities.contains(cap) {
@@ -134,7 +124,7 @@ impl ExtensionRegistry {
set.files.push(f.clone());
}
}
set.items.push((point.id(), decl, prepared));
set.items.push((point.id(), prepared));
}
}
Ok(set)
@@ -142,12 +132,12 @@ impl ExtensionRegistry {
/// Makes a prepared set take effect, all or nothing: if one commit fails,
/// the ones before it are disposed in reverse order.
pub fn commit(&self, set: PreparedSet, scope: Scope) -> Result<(), String> {
pub fn commit(&self, set: PreparedSet) -> Result<(), String> {
let mut committed: Vec<Entry> = Vec::with_capacity(set.items.len());
for (point, decl, prepared) in set.items {
for (point, prepared) in set.items {
let Prepared { key, label, commit, .. } = prepared;
match commit() {
Ok(disposable) => committed.push(Entry { point, key, decl, label, scope, disposable }),
Ok(disposable) => committed.push(Entry { point, key, label, disposable }),
Err(e) => {
tracing::error!(plugin = %set.plugin, "contributes.{point}: commit failed: {e}");
while let Some(entry) = committed.pop() {
@@ -177,31 +167,6 @@ impl ExtensionRegistry {
Ok(())
}
/// Registers one declaration at runtime (a plugin's `register-command`).
/// Returns `false` when an identical contribution with the same key exists.
pub fn contribute(&self, pkg: &Package, point_id: &str, decl: toml::Value) -> Result<bool, String> {
let point = self.point(point_id).ok_or_else(|| format!("unknown extension point {point_id:?}"))?;
let prepared = point.prepare(pkg, decl.clone())?;
if let Some(key) = &prepared.key {
let entries = self.entries.lock();
let same = entries.get(pkg.id()).is_some_and(|list| {
list.iter().any(|e| e.point == point.id() && e.key.as_ref() == Some(key) && e.decl == decl)
});
if same {
return Ok(false);
}
}
let set = PreparedSet {
plugin: pkg.id().to_owned(),
capabilities: prepared.capabilities.clone(),
files: Vec::new(),
warnings: Vec::new(),
items: vec![(point.id(), decl, prepared)],
};
self.commit(set, Scope::Runtime)?;
Ok(true)
}
/// Withdraws everything `plugin` contributed, newest first.
pub fn dispose_plugin(&self, plugin: &str) -> bool {
let list = self.entries.lock().remove(plugin).unwrap_or_default();
@@ -212,22 +177,6 @@ impl ExtensionRegistry {
any
}
/// Withdraws what `plugin`'s code registered, keeping its manifest contributions.
pub fn dispose_runtime(&self, plugin: &str) -> bool {
let removed: Vec<Entry> = {
let mut entries = self.entries.lock();
let Some(list) = entries.get_mut(plugin) else { return false };
let (runtime, keep): (Vec<Entry>, Vec<Entry>) = list.drain(..).partition(|e| e.scope == Scope::Runtime);
*list = keep;
runtime
};
let any = !removed.is_empty();
for entry in removed.into_iter().rev() {
entry.disposable.dispose();
}
any
}
pub fn contributions(&self, plugin: &str) -> Vec<ContributionInfo> {
self.entries
.lock()
@@ -243,14 +192,6 @@ impl ExtensionRegistry {
self.entries.lock().get(plugin).map_or(0, |list| list.iter().filter(|e| e.point == point).count())
}
/// Whether `plugin` has a contribution with `key` at `point`.
pub fn has_key(&self, plugin: &str, point: &str, key: &str) -> bool {
self.entries
.lock()
.get(plugin)
.is_some_and(|list| list.iter().any(|e| e.point == point && e.key.as_deref() == Some(key)))
}
/// Plugins with at least one contribution.
pub fn plugins(&self) -> Vec<String> {
self.entries.lock().iter().filter(|(_, l)| !l.is_empty()).map(|(id, _)| id.clone()).collect()
@@ -332,7 +273,7 @@ mod tests {
assert_eq!(set.len(), 3);
assert_eq!(set.files, [PathBuf::from("x.json")]);
assert!(live.lock().is_empty(), "prepare has no side effects");
reg.commit(set, Scope::Static).unwrap();
reg.commit(set).unwrap();
assert_eq!(*live.lock(), ["c", "a", "b"]);
assert_eq!(reg.count("p", "things"), 2);
assert_eq!(reg.contributions("p").len(), 3);
@@ -344,7 +285,7 @@ mod tests {
fn a_failed_commit_rolls_back() {
let (reg, live) = setup();
let p = pkg("[[contributes.things]]\nname = \"a\"\n[[contributes.things]]\nname = \"b\"\nfail = true");
let err = reg.commit(reg.prepare(&p).unwrap(), Scope::Static).unwrap_err();
let err = reg.commit(reg.prepare(&p).unwrap()).unwrap_err();
assert!(err.contains("boom"));
assert!(live.lock().is_empty(), "a was undone");
assert!(reg.contributions("p").is_empty());
@@ -361,24 +302,4 @@ mod tests {
assert_eq!(set.warnings.len(), 1);
assert!(set.warnings[0].contains("futureThing"));
}
#[test]
fn runtime_contributions_replace_by_key_and_dispose_separately() {
let (reg, live) = setup();
let p = pkg("[[contributes.things]]\nname = \"static\"");
reg.commit(reg.prepare(&p).unwrap(), Scope::Static).unwrap();
let decl = |name: &str| {
let mut t = toml::Table::new();
t.insert("name".into(), name.into());
t.insert("key".into(), true.into());
toml::Value::Table(t)
};
assert!(reg.contribute(&p, "things", decl("r1")).unwrap());
assert!(!reg.contribute(&p, "things", decl("r1")).unwrap(), "identical: no change");
assert!(reg.contribute(&p, "things", decl("r2")).unwrap());
assert_eq!(*live.lock(), ["static", "r2"], "same key replaced r1");
assert!(reg.contribute(&p, "nope", decl("x")).is_err());
assert!(reg.dispose_runtime("p"));
assert_eq!(*live.lock(), ["static"]);
}
}
+2 -2
Переглянути файл
@@ -216,7 +216,7 @@ pub fn to_json(v: &toml::Value) -> serde_json::Value {
mod tests {
use super::*;
use crate::manifest::Manifest;
use crate::registry::{ExtensionRegistry, Scope};
use crate::registry::ExtensionRegistry;
#[test]
fn ui_point_projects_into_the_store() {
@@ -233,7 +233,7 @@ mod tests {
[[contributes.badges]]\ntext = \"hi\"";
let pkg = Package::new(Manifest::parse(src).unwrap(), PathBuf::from("/x"));
let rev = store.revision();
reg.commit(reg.prepare(&pkg).unwrap(), Scope::Static).unwrap();
reg.commit(reg.prepare(&pkg).unwrap()).unwrap();
assert!(store.revision() > rev);
let items = store.items("badges", Some("p"));
assert_eq!(items.len(), 1);
+1 -2
Переглянути файл
@@ -1,5 +1,4 @@
//! `cargo bench -p ide-host`: the plugin runtime budgets of
//! docs/plugin_plan.md 4.5, checked by `pnpm perf:rust` against
//! `cargo bench -p ide-host`: the plugin runtime budgets, checked by `pnpm perf:rust` against
//! `perf/budgets.json` › rust (`plugins/…`).
//!
//! - `load_50`: start-up load of 50 installed plugins (20 with code, 10
+3 -12
Переглянути файл
@@ -338,19 +338,10 @@ fn check(dir: &Path) -> Result<(), String> {
if let Some(wasm) = &manifest.wasm {
let engine = PluginEngine::new()?;
let component = engine.compile(&dir.join(wasm))?;
let foreign: Vec<String> = engine
.imports(&component)
.into_iter()
.filter(|i| !(i.starts_with("openidea:plugin/") || i.starts_with("wasi:")))
.collect();
if !foreign.is_empty() {
return Err(format!(
"the component imports interfaces the IDE does not provide: {}",
foreign.join(", ")
));
}
let built_for = engine.check_component(&component)?;
let size = std::fs::metadata(dir.join(wasm)).map(|m| m.len()).unwrap_or(0);
println!(" code: {} ({})", wasm.display(), human_size(size));
let api = built_for.map(|v| format!(", plugin API {}.{}", v.major, v.minor)).unwrap_or_default();
println!(" code: {} ({}{api})", wasm.display(), human_size(size));
} else {
println!(" code: none (contributions only)");
}
+58 -21
Переглянути файл
@@ -79,28 +79,40 @@ async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static)
/// Items from plugins carry no server (resolve and commands don't apply).
const PLUGIN_SERVER: ServerId = ServerId(0);
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
fn plugin_completion_kind(kind: &str) -> Option<CompletionKind> {
Some(match kind {
"text" => CompletionKind::Text,
"method" => CompletionKind::Method,
"function" => CompletionKind::Function,
"constructor" => CompletionKind::Constructor,
"field" => CompletionKind::Field,
"variable" => CompletionKind::Variable,
"class" => CompletionKind::Class,
"interface" => CompletionKind::Interface,
"module" => CompletionKind::Module,
"property" => CompletionKind::Property,
"unit" => CompletionKind::Unit,
"value" => CompletionKind::Value,
"enum" => CompletionKind::Enum,
"keyword" => CompletionKind::Keyword,
"snippet" => CompletionKind::Snippet,
"color" => CompletionKind::Color,
"file" => CompletionKind::File,
"reference" => CompletionKind::Reference,
"folder" => CompletionKind::Folder,
"enum-member" => CompletionKind::EnumMember,
"constant" => CompletionKind::Constant,
"struct" => CompletionKind::Struct,
"event" => CompletionKind::Event,
"operator" => CompletionKind::Operator,
"type-parameter" => CompletionKind::TypeParameter,
_ => return None,
})
}
fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
let kind = item.kind.as_deref().and_then(|k| {
Some(match k {
"text" => CompletionKind::Text,
"method" => CompletionKind::Method,
"function" => CompletionKind::Function,
"constructor" => CompletionKind::Constructor,
"field" => CompletionKind::Field,
"variable" => CompletionKind::Variable,
"class" => CompletionKind::Class,
"interface" => CompletionKind::Interface,
"module" => CompletionKind::Module,
"property" => CompletionKind::Property,
"keyword" => CompletionKind::Keyword,
"snippet" => CompletionKind::Snippet,
"value" => CompletionKind::Value,
"constant" => CompletionKind::Constant,
"file" => CompletionKind::File,
"reference" => CompletionKind::Reference,
_ => return None,
})
});
let kind = item.kind.as_deref().and_then(plugin_completion_kind);
CompletionItem {
label: item.label,
label_detail: None,
@@ -715,3 +727,28 @@ pub(crate) fn register(d: &mut Dispatcher) {
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
let doc: String = wit
.lines()
.map(|l| l.trim().trim_start_matches("///").trim())
.skip_while(|l| !l.starts_with("Icon and sort group:"))
.take_while(|l| !l.starts_with("kind:"))
.collect::<Vec<_>>()
.join(" ");
let list = doc.strip_prefix("Icon and sort group:").unwrap().split(". Unknown").next().unwrap();
let list = list.replace(" or ", ", ");
let kinds: Vec<&str> = list.split(',').map(str::trim).filter(|k| !k.is_empty()).collect();
assert_eq!(kinds.len(), 25, "{kinds:?}");
for k in kinds {
assert!(plugin_completion_kind(k).is_some(), "{k:?}");
}
assert_eq!(plugin_completion_kind("nonsense"), None);
}
}
+3 -24
Переглянути файл
@@ -1,9 +1,9 @@
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
//! Capabilities were checked by the runtime before any of this runs.
use super::{COMMANDS, MAX_COMMANDS, PluginEvent, PluginEventSink};
use super::{PluginEvent, PluginEventSink};
use ide_core::{CancellationToken, TextEdit};
use ide_extension::{ExtensionRegistry, Package};
use ide_extension::Package;
use ide_language::ProgramPin;
use ide_plugin::{
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus,
@@ -143,7 +143,6 @@ struct TaskEntry {
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
pub registry: Arc<ExtensionRegistry>,
pub workspace: RwLock<Option<Arc<dyn PluginWorkspace>>>,
/// `<plugins>/.data`: one directory per plugin id.
pub data_dir: PathBuf,
@@ -163,7 +162,6 @@ pub(super) struct Shared {
impl Shared {
pub fn new(
sink: Arc<dyn PluginEventSink>,
registry: Arc<ExtensionRegistry>,
data_dir: PathBuf,
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
ui: Arc<ide_extension::UiStore>,
@@ -171,7 +169,6 @@ impl Shared {
Arc::new(Self {
ui,
sink,
registry,
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
pins,
@@ -320,18 +317,6 @@ impl HostApi for PluginApi {
.emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item });
}
fn contribute(&self, point: &str, decl: toml::Value) -> Result<bool, String> {
if point == COMMANDS {
let key = decl.get("id").and_then(|v| v.as_str()).unwrap_or_default();
if self.shared.registry.count(self.id(), COMMANDS) >= MAX_COMMANDS
&& !self.shared.registry.has_key(self.id(), COMMANDS, key)
{
return Err(format!("too many commands (max {MAX_COMMANDS})"));
}
}
self.shared.registry.contribute(&self.pkg, point, decl)
}
fn projects(&self) -> Vec<PathBuf> {
self.shared.workspace().map(|w| w.projects()).unwrap_or_default()
}
@@ -754,13 +739,7 @@ mod tests {
)
.unwrap();
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
let shared = Shared::new(
sink,
Arc::new(ExtensionRegistry::new()),
dir.join(".data"),
pins,
ide_extension::UiStore::new(),
);
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new());
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
+1 -1
Переглянути файл
@@ -1,5 +1,5 @@
//! Copying a package into the plugins directory without ever leaving the
//! plugin missing (docs/plugin_plan.md 4.7). Installed plugins are versioned:
//! plugin missing. Installed plugins are versioned:
//! `<plugins>/<id>/<version>/` plus an `active` pointer file (and `previous`,
//! the version to roll back to). A new version is written to a staging
//! directory, renamed into place, and only then made active by replacing the
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
//! The marketplace (docs/plugin_plan.md 4.7): a static index (`index.json` +
//! The marketplace: a static index (`index.json` +
//! packages, served from any URL or folder) lists publishers and their keys,
//! plugins, and revocations. The host keeps the last index it read
//! (`market-index.json`), checks signatures against it, and disables what it
+20 -22
Переглянути файл
@@ -1,4 +1,4 @@
//! The plugin host (`docs/plugin_plan.md` 3): assembles the extension
//! The plugin host: assembles the extension
//! registry (`ide-extension`), the grant ledger and the Wasm runtime
//! (`ide-plugin`). It runs where the project is — in the desktop app for local
//! projects, in `openidea-server` for remote ones; the desktop shell only adapts
@@ -32,7 +32,7 @@ pub mod views;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix};
pub use market::{INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin};
pub use points::{COMMANDS, CommandDecl, MAX_COMMANDS, register_builtin_points};
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
pub(crate) use providers::editor_providers as editor_providers_for;
pub use providers::{ProviderKind, set_editor_providers};
pub use theme::{ThemeDef, ThemeKind};
@@ -42,7 +42,7 @@ use ide_core::{IdeError, IdeResult, LanguageId};
use ide_extension::ledger::{self, BinaryGrant, Decision, Ledger, Request, Subject};
use ide_extension::{
ActivationEvent, Capability, CapabilityInfo, ContributionIndex, ExtensionRegistry, MANIFEST_FILE, Manifest,
Package, Scope, UiContribution, UiStore,
Package, UiContribution, UiStore,
};
use ide_language::plugin_point::PluginLanguages;
use ide_language::{LanguageConfig, ProgramPin};
@@ -192,8 +192,6 @@ struct Batches {
pending: u32,
/// Kinds of the batch being handled right now.
running: Option<BTreeSet<EventKind>>,
/// A finished batch changed contributions: the next flush announces it.
changed: bool,
}
impl LoadedPlugin {
@@ -352,8 +350,7 @@ impl PluginHost {
languages.set_process_policy(Arc::new(move |plugin, cap| {
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
}));
let shared =
api::Shared::new(sink.clone(), registry.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone());
let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone());
let market = RwLock::new(Self::load_market_index(&plugins_dir));
let host = Self {
market,
@@ -997,7 +994,7 @@ impl PluginHost {
self.declared_view(id, view)?;
let view = view.to_owned();
self.code_call(id, invocation, "handling the view's event", move |rt, inv| {
rt.view_event(&view, event.clone(), inv).map(|fx| ((), fx))
rt.view_event(&view, event.clone(), inv)
})
}
@@ -1016,7 +1013,7 @@ impl PluginHost {
id: &str,
invocation: Invocation,
what: &str,
f: impl Fn(&mut Runtime, Invocation) -> Result<(R, ide_plugin::CallEffects), CallError> + Clone + Send + 'static,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
let plugin = self.get(id)?;
let client = invocation.client.clone();
@@ -1061,16 +1058,17 @@ impl PluginHost {
let result = actor.call(None, move |rt| call(rt, inv));
if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) {
let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_))));
self.record_call(&plugin, started.elapsed(), result.is_ok(), !crashed);
// Cancelling or a missing grant is not the plugin failing.
let ok = match &result {
Ok(_) => true,
Err(ActorError::Call(CallError::Guest(e))) => !e.is_failure(),
Err(_) => false,
};
self.record_call(&plugin, started.elapsed(), ok, !crashed);
}
match result {
Ok((value, fx)) => {
if fx.contributions_changed {
self.changed();
}
return Ok(value);
}
Err(ActorError::Call(CallError::Guest(e))) => return Err(IdeError::internal(e)),
Ok(value) => return Ok(value),
Err(ActorError::Call(CallError::Guest(e))) => return Err(e.into()),
Err(ActorError::Call(CallError::Trap(e))) => {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::internal(format!("plugin {id:?} {e}")));
@@ -1274,10 +1272,9 @@ impl PluginHost {
for ((id, client), events) in due {
changed |= self.deliver(&id, client, events);
}
// Batches that finished since: contributions they changed, crashes.
// Batches that crashed since.
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
for p in plugins {
changed |= std::mem::take(&mut p.batches.lock().changed);
let dead = p.runtime.lock().clone().filter(|a| !a.is_alive());
if let Some(actor) = dead {
self.reap(&p, &actor, None);
@@ -1354,7 +1351,6 @@ impl PluginHost {
let mut batches = finish.lock();
batches.pending = batches.pending.saturating_sub(1);
batches.running = None;
batches.changed |= result.is_ok_and(|fx| fx.contributions_changed);
},
);
activated
@@ -1590,7 +1586,9 @@ impl PluginHost {
)));
}
// Precompiled on the way: the installed copy has the same bytes.
self.engine.compile_cached(&src.join(wasm), &self.component_cache()).map_err(IdeError::invalid)?;
let component =
self.engine.compile_cached(&src.join(wasm), &self.component_cache()).map_err(IdeError::invalid)?;
self.engine.check_component(&component).map_err(IdeError::invalid)?;
}
Ok((pkg, set))
}
@@ -1689,7 +1687,7 @@ impl PluginHost {
return;
}
};
if let Err(e) = self.registry.commit(set, Scope::Static) {
if let Err(e) = self.registry.commit(set) {
return self.set_failed(plugin, e);
}
{
-2
Переглянути файл
@@ -10,8 +10,6 @@ use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub const COMMANDS: &str = "commands";
/// Most commands one plugin may register.
pub const MAX_COMMANDS: usize = 256;
const MAX_TITLE: usize = 256;
/// `[[contributes.commands]]`: an action `plugin.<plugin-id>.<id>` in Search
+63 -53
Переглянути файл
@@ -1,4 +1,4 @@
//! Editor providers of plugins (docs/plugin_plan.md P6): completion, inline
//! Editor providers of plugins: completion, inline
//! completion, code actions, go to definition and gutter line markers, for
//! the languages a plugin declares. The host calls only plugins whose
//! declaration matches, so the others stay unloaded.
@@ -160,8 +160,8 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
use super::{LoadedPlugin, PluginHost, PluginStatus};
use ide_core::CancellationToken;
use ide_plugin::{
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument,
Runtime,
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
ProviderDocument, Runtime,
};
use std::sync::Weak;
use std::time::{Duration, Instant};
@@ -198,20 +198,38 @@ impl PluginHost {
.collect()
}
/// One edit-path call (soft / hard deadline); `None` when it failed or was late.
fn edit_call<R: Send + 'static>(
/// Edit-path calls (soft / hard deadline) to every usable plugin that
/// declares `kind` for `language`, in plugin order, leaving out those that
/// failed or were late. Each plugin runs on its own thread, so the calls
/// run at the same time: the wait is the slowest plugin's, not the sum.
fn edit_calls<R: Send + 'static>(
&self,
plugin: &Arc<LoadedPlugin>,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Send + 'static,
) -> Option<R> {
let (actor, activated) = self.ensure_active(plugin, None).ok()?;
if activated {
self.changed();
}
let started = Instant::now();
let hard = self.limits.edit_hard;
let result = actor.call(Some(self.limits.edit_soft), move |rt| f(rt, hard));
self.finish_provider_call(plugin, &actor, started, result)
kind: ProviderKind,
language: &str,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard);
let queued: Vec<_> = self
.provider_plugins(kind, language)
.into_iter()
.filter_map(|plugin| {
let (actor, activated) = self.ensure_active(&plugin, None).ok()?;
if activated {
self.changed();
}
let f = f.clone();
let started = Instant::now();
let call = actor.submit(Some(soft), move |rt| f(rt, hard));
Some((plugin, actor, started, call))
})
.collect();
queued
.into_iter()
.filter_map(|(plugin, actor, started, call)| {
let result = call.and_then(Pending::wait);
self.finish_provider_call(&plugin, &actor, started, result).map(|r| (plugin, r))
})
.collect()
}
pub(super) fn finish_provider_call<R>(
@@ -233,7 +251,7 @@ impl PluginHost {
}
Err(ActorError::Cancelled) => None,
Err(ActorError::Call(CallError::Guest(e))) => {
self.record_call(plugin, elapsed, false, true);
self.record_call(plugin, elapsed, !e.is_failure(), true);
tracing::debug!(plugin = %plugin.id(), "provider: {e}");
None
}
@@ -251,16 +269,13 @@ impl PluginHost {
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
let mut out = Vec::new();
for p in self.provider_plugins(ProviderKind::Completion, &doc.language) {
let (d, t) = (doc.clone(), trigger.map(str::to_owned));
if let Some(items) =
self.edit_call(&p, move |rt, timeout| rt.provide_completion(&d, at, t.as_deref(), timeout))
{
out.extend(items.into_iter().map(|i| (p.id().to_owned(), i)));
}
}
out
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
})
.into_iter()
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
.collect()
}
pub fn provide_code_actions(
@@ -268,38 +283,33 @@ impl PluginHost {
doc: &ProviderDocument,
selection: ide_core::Range,
) -> Vec<(String, CodeAction)> {
let mut out = Vec::new();
for p in self.provider_plugins(ProviderKind::CodeActions, &doc.language) {
let d = doc.clone();
if let Some(actions) =
self.edit_call(&p, move |rt, timeout| rt.provide_code_actions(&d, selection, timeout))
{
out.extend(actions.into_iter().map(|a| (p.id().to_owned(), a)));
}
}
out
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::CodeActions, &doc.language, move |rt, timeout| {
rt.provide_code_actions(&d, selection, timeout)
})
.into_iter()
.flat_map(|(p, actions)| actions.into_iter().map(move |a| (p.id().to_owned(), a)))
.collect()
}
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
let mut out = Vec::new();
for p in self.provider_plugins(ProviderKind::Definition, &doc.language) {
let d = doc.clone();
if let Some(locations) = self.edit_call(&p, move |rt, timeout| rt.provide_definition(&d, at, timeout)) {
out.extend(locations);
}
}
out
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
rt.provide_definition(&d, at, timeout)
})
.into_iter()
.flat_map(|(_, locations)| locations)
.collect()
}
pub fn provide_line_markers(&self, doc: &ProviderDocument) -> Vec<(String, LineMarker)> {
let mut out = Vec::new();
for p in self.provider_plugins(ProviderKind::LineMarkers, &doc.language) {
let d = doc.clone();
if let Some(markers) = self.edit_call(&p, move |rt, timeout| rt.provide_line_markers(&d, timeout)) {
out.extend(markers.into_iter().map(|m| (p.id().to_owned(), m)));
}
}
out
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::LineMarkers, &doc.language, move |rt, timeout| {
rt.provide_line_markers(&d, timeout)
})
.into_iter()
.flat_map(|(p, markers)| markers.into_iter().map(move |m| (p.id().to_owned(), m)))
.collect()
}
/// Ghost text from the first plugin that has some. Not bound by the
+1 -2
Переглянути файл
@@ -1,5 +1,4 @@
//! Plugin languages of a remote project (docs/plugin_plan.md 3, placement
//! rules): `Workspace` contributions take effect where the project is, so the
//! Plugin languages of a remote project: `Workspace` contributions take effect where the project is, so the
//! desktop pushes its plugins' language definitions to `openidea-server`
//! (`plugin_sync_languages`) together with the `process:` programs the user
//! granted. The server resolves each granted program on its own `PATH`, pins
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
//! Code plugins of remote projects (docs/plugin_plan.md 3, placement rule 1):
//! Code plugins of remote projects:
//! Wasm runs where the project is, so `openidea-server` hosts the desktop's
//! code plugins and they read, search, run processes and publish diagnostics
//! on the remote machine.
+1 -2
Переглянути файл
@@ -1,6 +1,5 @@
//! The `runDetectors` extension point: run configurations a plugin offers in
//! projects that contain a marker file (docs/plugin_plan.md P3, "open
//! `ide-run` detection").
//! projects that contain a marker file.
//!
//! ```toml
//! [[contributes.runDetectors]]
+4 -12
Переглянути файл
@@ -96,11 +96,9 @@ impl ThemesPoint {
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct Decl {
path: Option<PathBuf>,
path: PathBuf,
#[serde(default)]
editor_scheme: Option<PathBuf>,
/// The theme JSON inline (runtime `register-theme`).
definition: Option<String>,
}
impl ExtensionPoint for ThemesPoint {
@@ -114,15 +112,9 @@ impl ExtensionPoint for ThemesPoint {
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let decl: Decl = decode(POINT_ID, decl)?;
let mut files = Vec::new();
let mut theme = match (&decl.path, &decl.definition) {
(Some(path), None) => {
files.push(path.clone());
ThemeDef::parse(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?
}
(None, Some(def)) => ThemeDef::parse(def)?,
_ => return Err("give either `path` or `definition`".into()),
};
let path = &decl.path;
let mut theme = ThemeDef::parse(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
let mut files = vec![path.clone()];
if let Some(scheme) = &decl.editor_scheme {
files.push(scheme.clone());
theme.merge_editor_scheme(&pkg.read_text(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?;
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
//! Declarative UI contributions (docs/plugin_plan.md P7): menus, key
//! Declarative UI contributions: menus, key
//! bindings, status bar items and settings (L0, shown before any code runs),
//! and the views and custom editors whose content plugin code renders (L2).
//! Everything is validated here; the client only projects the JSON.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
//! Declarative views and custom editors (docs/plugin_plan.md P7) as the
//! Declarative views and custom editors as the
//! window commands `plugin_view_render` / `plugin_view_event` serve them —
//! the desktop for local projects, `openidea-server` for remote ones.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
//! Plan P8: versioned installs with automatic rollback, signed packages from
//! Versioned installs with automatic rollback, signed packages from
//! a static marketplace index, and revocation.
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, Publisher, PublisherKey, Revocation};
+84 -46
Переглянути файл
@@ -6,6 +6,7 @@
//! `plugins/build.sh crates/ide-plugin/tests/fixtures/misbehave` after
//! changing their source or `wit/`.
use ide_core::error::ErrorKind;
use ide_host::plugins::*;
use ide_plugin::{EditorContext, HostError, Invocation, Limits};
use parking_lot::Mutex;
@@ -292,14 +293,17 @@ fn hello_plugin_commands() {
// Without an editor it fails cleanly; guest errors don't disable the plugin.
assert!(f.run("hello", "word-count", None).unwrap_err().contains("no file is open"));
assert!(f.run_in(&editor, "hello", "nope").unwrap_err().contains("unknown command"));
let inv = Invocation { editor: Some(editor.clone()), ..f.inv() };
let err = f.host.run_command("hello", "nope", None, inv).unwrap_err();
assert_eq!(err.kind, ErrorKind::NotFound, "{err:?}");
assert_eq!(f.status("hello"), PluginStatus::Active);
let stats = f.host.info_of("hello").unwrap().stats;
assert_eq!(stats.activations, 1);
assert!(stats.calls >= 4 && stats.errors >= 2, "{stats:?}");
// "no file is open" is the plugin failing; an unknown command is not.
assert!(stats.calls >= 4 && stats.errors == 1, "{stats:?}");
}
/// Plan P5: edits name the document version they were computed for.
/// Edits name the document version they were computed for.
#[test]
fn edits_are_refused_when_the_document_changed() {
use ide_plugin::{HostApi, Runtime, RuntimeSetup};
@@ -344,20 +348,26 @@ fn read_file_is_confined_to_the_project() {
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
/// Plan P5: a missing capability is an error the plugin can handle, not a trap.
/// A missing capability is an error the plugin can handle, not a trap.
#[test]
fn ungranted_capabilities_are_denied_not_trapped() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
let editor = f.workspace.open("src/notes.txt", &[]);
let err = f.run_in(&editor, "misbehave", "insert").unwrap_err();
assert!(err.contains("Denied") && err.contains("editor.write"), "{err}");
let inv = Invocation { editor: Some(editor.clone()), ..f.inv() };
let err = f.host.run_command("misbehave", "insert", None, inv).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
assert!(err.message.contains("editor.write"), "{err:?}");
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes), (PluginStatus::Active, 0), "the instance lives on");
assert_eq!(info.stats.errors, 0, "a missing grant is not the plugin failing");
let err = f.host.run_command("misbehave", "cancel", None, f.inv()).unwrap_err();
assert_eq!(err.kind, ErrorKind::Cancelled, "{err:?}");
assert_eq!(f.host.info_of("misbehave").unwrap().stats.errors, 0, "nor is the user cancelling");
assert_eq!(f.workspace.text("src/notes.txt"), "one two three\nfour five\n");
}
/// Plan P4: a trap drops the instance, which restarts on next use after a
/// A trap drops the instance, which restarts on next use after a
/// backoff that doubles; only consecutive crashes stop the plugin.
#[test]
fn crashes_restart_with_backoff_until_the_circuit_opens() {
@@ -457,7 +467,7 @@ fn memory_limit_traps() {
assert_eq!(f.host.info_of("misbehave").unwrap().stats.crashes, 1);
}
/// Plan P4: a running call can be interrupted from another thread; the
/// A running call can be interrupted from another thread; the
/// instance is rebuilt on next use without counting as a crash.
#[test]
fn running_calls_can_be_cancelled() {
@@ -493,7 +503,7 @@ fn misbehave_with_language(dir: &Path) -> PathBuf {
pkg
}
/// Plan P4: edit-path calls stop waiting at the soft deadline (the result is
/// Edit-path calls stop waiting at the soft deadline (the result is
/// dropped, the instance kept) and are interrupted at the hard one.
#[test]
fn edit_path_calls_have_soft_and_hard_deadlines() {
@@ -537,7 +547,56 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
assert_eq!(host.info_of("misbehave").unwrap().stats.activations, 2);
}
/// Plan P4: idle instances are reclaimed; the plugin activates again on its
/// Editor providers of several plugins are asked at the same time: the wait
/// is the slowest plugin's, not the sum, so each has the whole budget.
#[test]
fn providers_of_several_plugins_run_concurrently() {
let tmp = tempfile::tempdir().unwrap();
let limits = Limits {
edit_soft: Duration::from_millis(300),
edit_hard: Duration::from_secs(2),
fuel: u64::MAX,
..Limits::default()
};
let host =
Arc::new(PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap());
host.load_all();
host.startup_finished();
for id in ["slow-a", "slow-b"] {
let pkg = tmp.path().join(id);
copy_dir(&misbehave_pkg(), &pkg);
std::fs::write(
pkg.join("plugin.toml"),
format!(
"id = \"{id}\"\nname = \"{id}\"\nversion = \"0.0.1\"\nengines.openidea = \"^1.0\"\n\
wasm = \"dist/plugin.wasm\"\nactivation = [\"on-language:text\"]\n\
[[contributes.providers]]\nkinds = [\"completion\"]\nlanguages = [\"text\"]\n"
),
)
.unwrap();
host.install_from_dir(&pkg, &Consent::all()).unwrap();
}
let doc = |text: &str| ide_plugin::ProviderDocument {
path: "/p/a.txt".into(),
language: "text".into(),
version: 1,
text: text.into(),
};
let at = ide_core::Pos::new(0, 0);
// Activation is not what is measured.
assert_eq!(host.provide_completion(&doc("fast"), at, None).len(), 2);
let started = Instant::now();
let items = host.provide_completion(&doc("sleep:200"), at, None);
let elapsed = started.elapsed();
let mut from: Vec<&str> = items.iter().map(|(p, _)| p.as_str()).collect();
from.sort();
assert_eq!(from, ["slow-a", "slow-b"], "both within the budget");
assert!(elapsed < Duration::from_millis(350), "one after the other would take 400ms: {elapsed:?}");
assert_eq!(host.info_of("slow-b").unwrap().stats.slow_calls, 0);
}
/// Idle instances are reclaimed; the plugin activates again on its
/// next use, and `on-schedule` activations don't keep it alive.
#[test]
fn idle_instances_are_reclaimed_and_schedules_reactivate() {
@@ -553,7 +612,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
assert_eq!(f.host.instances(), 0);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
assert_eq!(f.command_titles("misbehave").len(), 9, "runtime contributions stay");
assert_eq!(f.command_titles("misbehave").len(), 8, "contributions stay");
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
@@ -575,27 +634,6 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
assert_eq!(f.status("hello"), PluginStatus::Inactive, "reclaimed again");
}
#[test]
fn runtime_language_and_theme_registration() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
let lang = "id = \"zz\"\nname = \"ZZ\"\nextensions = [\"zz\"]";
f.run("misbehave", "language", Some(lang)).unwrap();
assert!(f.run("misbehave", "language", Some("not toml [")).unwrap_err().contains("invalid language"));
assert_eq!(f.host.languages().iter().map(|l| l.id.to_string()).collect::<Vec<_>>(), ["zz"]);
assert_eq!(f.host.languages()[0].extensions, ["zz"]);
let theme = r##"{"id":"t1","name":"T1","kind":"light","tokens":{"--bg-app":"#fff"}}"##;
f.run("misbehave", "theme", Some(theme)).unwrap();
assert!(
f.run("misbehave", "theme", Some(r#"{"id":"t2","name":"x","kind":"dark","tokens":{"--a":"url(x)"}}"#)).is_err()
);
assert_eq!(f.theme_kinds("misbehave"), [("t1".to_owned(), ThemeKind::Light)]);
let active = f.host.set_theme(Some(("misbehave", "t1"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#fff");
assert!(f.host.set_theme(Some(("misbehave", "nope"))).is_err());
}
#[test]
fn contribution_only_plugins() {
let f = fixture(Limits::default());
@@ -953,7 +991,7 @@ fn replaced_binaries_are_refused() {
assert!(err.message.contains("confirm"), "{}", err.message);
}
/// Plan P3: a declaration-only language plugin reaches detection and LSP of
/// A declaration-only language plugin reaches detection and LSP of
/// every project; its server starts only while `process:` is granted, from
/// the approved file.
#[cfg(unix)]
@@ -1005,7 +1043,7 @@ fn plugin_languages_join_the_registry_and_start_only_granted_servers() {
assert_eq!(project.detect(Path::new("/p/a.toy"), None), LanguageId::plain_text());
}
/// Plan P3: a plugin provides the syntax of a language the IDE has no grammar
/// A plugin provides the syntax of a language the IDE has no grammar
/// for (the official Solidity plugin, built with the SDK), activated on first use.
#[test]
fn plugin_syntax_for_languages_without_a_grammar() {
@@ -1050,7 +1088,7 @@ fn plugin_syntax_for_languages_without_a_grammar() {
ide_language::set_external_syntax(None);
}
/// Plan P3: plugins add run configurations to `ide-run`'s detection; the
/// Plugins add run configurations to `ide-run`'s detection; the
/// command runs only while granted, from the approved file.
#[cfg(unix)]
#[test]
@@ -1102,7 +1140,7 @@ fn run_detectors_offer_granted_commands_in_marked_projects() {
assert!(detection.launch(config).unwrap_err().contains("no longer enabled"));
}
/// Plan P4: compiled components are cached across runs (keyed by module and
/// Compiled components are cached across runs (keyed by module and
/// engine), so a cold activation only deserializes.
#[test]
fn precompiled_components_make_cold_activation_cheap() {
@@ -1261,7 +1299,7 @@ fn official_material_icons_install() {
);
}
/// Plan P1: an icon theme with thousands of SVGs (sanitising, package size, start-up).
/// An icon theme with thousands of SVGs (sanitising, package size, start-up).
#[test]
fn thousands_of_icons_install_and_reload_within_budget() {
const ICONS: usize = 3000;
@@ -1374,7 +1412,7 @@ fn official(name: &str) -> PathBuf {
repo().join("plugins/official").join(name)
}
/// Plan P5, String Tools: 26 commands show before the code loads; one command
/// String Tools: 26 commands show before the code loads; one command
/// edits every selection as one versioned change.
#[test]
fn official_string_tools() {
@@ -1395,7 +1433,7 @@ fn official_string_tools() {
assert_eq!(f.workspace.document(&editor.path).unwrap().0, before + 1, "both selections in one change");
}
/// Plan P5, Key Promoter: action events are batched; an idle plugin is
/// Key Promoter: action events are batched; an idle plugin is
/// woken once per batch, and its counts persist in storage.
#[test]
fn official_key_promoter() {
@@ -1456,7 +1494,7 @@ fn git(dir: &Path, args: &[&str]) {
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
/// Plan P5 acceptance: Git Toolbox's scheduled fetch still runs after the
/// Git Toolbox's scheduled fetch still runs after the
/// instance was reclaimed while idle, and the status bar follows.
#[test]
fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
@@ -1574,7 +1612,7 @@ fn http_server(
(port, seen)
}
/// Plan P6, AI Completion: typing on cancels the previous request (the fetch
/// AI Completion: typing on cancels the previous request (the fetch
/// returns `cancelled`, the instance stays); the plugin's own work stays
/// within the edit-path budget.
#[test]
@@ -1629,7 +1667,7 @@ fn official_ai_completion_cancels_superseded_requests() {
assert!(times[9] < Duration::from_millis(500), "{times:?}");
}
/// Plan P6, Checkstyle: a save runs the approved `checkstyle` and publishes
/// Checkstyle: a save runs the approved `checkstyle` and publishes
/// diagnostics; quick fixes come through the code-action provider; saving
/// again cancels the check still running.
#[cfg(unix)]
@@ -1695,7 +1733,7 @@ fn official_checkstyle_checks_on_save() {
assert_eq!(info.stats.crashes, 0);
}
/// Plan P6, MyBatis Navigator: interface ↔ XML through the providers, on a
/// MyBatis Navigator: interface ↔ XML through the providers, on a
/// real project tree (`find-files`), within the edit-path budget.
#[test]
fn official_mybatis_navigates_between_interface_and_xml() {
@@ -1749,7 +1787,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
assert_eq!(f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls, 0);
}
/// Plan P7, CSV Editor: a custom editor renders the document as a table (the
/// CSV Editor: a custom editor renders the document as a table (the
/// rows asked for), and edits the document itself — versioned, so a text edit
/// made meanwhile is never overwritten.
#[test]
@@ -1784,7 +1822,7 @@ fn official_csv_editor_edits_the_shared_document() {
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
}
/// Plan P7 acceptance: the sample plugin makes a tool window with a tree view.
/// The sample plugin makes a tool window with a tree view.
/// Its menus, shortcut and status bar item are declarations, listed before
/// any code runs; the tree is rendered by the code, which loads on first show.
#[test]
@@ -1826,7 +1864,7 @@ fn sample_plugin_tool_window_with_a_tree() {
}
}
/// Plan P7, Kubernetes: the cluster tree through `kubectl` (pinned), logs in
/// Kubernetes: the cluster tree through `kubectl` (pinned), logs in
/// the terminal of the invoking window.
#[test]
fn official_kubernetes_browses_the_cluster_and_opens_logs() {
+3 -3
Переглянути файл
@@ -1,5 +1,5 @@
//! Plan P6 remote acceptance: code plugins of a remote project run on the
//! server (docs/plugin_plan.md 3). The desktop's plugin host syncs its live
//! Code plugins of a remote project run on the
//! server. The desktop's plugin host syncs its live
//! code plugins over the remote protocol; Checkstyle then checks the file on
//! the server's disk when it is saved, and secrets come from the desktop.
@@ -207,7 +207,7 @@ async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
plugins.host().shutdown();
}
/// Plan P7 remote acceptance: CSV Editor and Kubernetes in a remote project.
/// CSV Editor and Kubernetes in a remote project.
/// The table edits the document on the server; the cluster is the server's
/// `kubectl`'s, and its log terminal opens in the remote window.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+1 -1
Переглянути файл
@@ -169,7 +169,7 @@ pub struct DebuggerConfig {
pub pinned: Option<ProgramPin>,
}
/// The program a plugin's `process:` grant approved (docs/plugin_plan.md 4.2):
/// The program a plugin's `process:` grant approved:
/// a plugin language's server or debug adapter starts from this exact path,
/// and only while the file still has the approved content. Set by the plugin
/// host, never read from a definition.
+7 -26
Переглянути файл
@@ -128,9 +128,7 @@ impl LanguagesPoint {
#[serde(deny_unknown_fields)]
struct Decl {
/// A `languages/<id>.toml`-style file in the package.
path: Option<PathBuf>,
/// The same, inline (runtime `register-language`).
definition: Option<String>,
path: PathBuf,
}
/// Parses and checks one language definition.
@@ -179,18 +177,8 @@ impl ExtensionPoint for LanguagesPoint {
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let decl: Decl = decode(POINT_ID, decl)?;
let (src, file) = match (decl.path, decl.definition) {
(Some(path), None) => {
let src = pkg.read_text(&path)?;
(src, Some(path))
}
(None, Some(def)) => (def, None),
_ => return Err("give either `path` or `definition`".into()),
};
let cfg = parse_definition(&src).map_err(|e| match &file {
Some(f) => format!("{}: {e}", f.display()),
None => e,
})?;
let src = pkg.read_text(&decl.path)?;
let cfg = parse_definition(&src).map_err(|e| format!("{}: {e}", decl.path.display()))?;
let caps = required_capabilities(&cfg);
let (store, plugin, key, label) = (
self.store.clone(),
@@ -198,10 +186,7 @@ impl ExtensionPoint for LanguagesPoint {
cfg.id.to_string(),
format!("{} (.{})", cfg.name, cfg.extensions.join(", .")),
);
let mut prepared = Prepared::new(label, move || Ok(store.add(plugin, cfg))).key(key);
if let Some(f) = file {
prepared = prepared.file(f);
}
let mut prepared = Prepared::new(label, move || Ok(store.add(plugin, cfg))).key(key).file(decl.path);
for cap in caps {
prepared = prepared.capability(cap);
}
@@ -212,7 +197,7 @@ impl ExtensionPoint for LanguagesPoint {
#[cfg(test)]
mod tests {
use super::*;
use ide_extension::{ExtensionRegistry, Manifest, Scope};
use ide_extension::{ExtensionRegistry, Manifest};
#[test]
fn languages_point_registers_and_requires_process_capabilities() {
@@ -233,16 +218,12 @@ mod tests {
let set = reg.prepare(&pkg).unwrap();
assert_eq!(set.capabilities, [Capability::Process("solc".into())]);
assert_eq!(set.files, [PathBuf::from("l/sol.toml")]);
reg.commit(set, Scope::Static).unwrap();
reg.commit(set).unwrap();
assert_eq!(store.all()[0].id.as_str(), "solidity");
reg.dispose_plugin("p");
assert!(store.all().is_empty());
let bad = |def: &str| {
let mut t = toml::Table::new();
t.insert("definition".into(), def.into());
reg.contribute(&pkg, POINT_ID, toml::Value::Table(t)).unwrap_err()
};
let bad = |def: &str| parse_definition(def).unwrap_err();
assert!(bad("not toml [").contains("invalid language"));
assert!(bad("id = \"\"\nname = \"x\"").contains("must not be empty"));
assert!(bad("id = \"a b\"\nname = \"x\"").contains("invalid language id"));
+1 -1
Переглянути файл
@@ -1,5 +1,5 @@
//! Syntax from outside this crate for languages without a built-in grammar: a
//! plugin's `syntax-provider` (docs/plugin_p0.md 4.1). Providers return flat
//! plugin's `syntax-provider`. Providers return flat
//! results in UTF-8 byte offsets; they are validated and converted here into
//! the same [`OutlineItem`], [`FoldRange`] and highlight data the grammars
//! produce, so the editor can't tell the difference. Results are memoized per
+1 -1
Переглянути файл
@@ -12,10 +12,10 @@ blake3.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
toml.workspace = true
thiserror.workspace = true
tracing.workspace = true
parking_lot.workspace = true
semver = "1"
wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation", "async"] }
wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] }
+39 -13
Переглянути файл
@@ -1,4 +1,4 @@
//! One thread per active plugin owning its [`Runtime`] (docs/plugin_plan.md 4.5).
//! 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
@@ -33,6 +33,32 @@ pub enum ActorError {
Cancelled,
}
/// A call queued with [`Actor::submit`].
pub struct Pending<R> {
result: mpsc::Receiver<Result<R, CallError>>,
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.
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(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)?,
};
outcome.map_err(ActorError::Call)
}
}
pub struct Actor {
tx: Mutex<Option<mpsc::Sender<Job>>>,
thread: Mutex<Option<JoinHandle<()>>>,
@@ -97,6 +123,17 @@ impl Actor {
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());
@@ -119,18 +156,7 @@ impl Actor {
let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) };
tx.send(job).map_err(|_| ActorError::Stopped)?;
}
let outcome = match give_up {
Some(t) => match result.recv_timeout(t.saturating_duration_since(Instant::now())) {
Ok(r) => r,
Err(mpsc::RecvTimeoutError::Timeout) => return Err(ActorError::SoftTimeout),
Err(mpsc::RecvTimeoutError::Disconnected) => {
// Skipped as too late, or the actor ended.
return Err(if self.is_alive() { ActorError::SoftTimeout } else { ActorError::Stopped });
}
},
None => result.recv().map_err(|_| ActorError::Stopped)?,
};
outcome.map_err(ActorError::Call)
Ok(Pending { result, give_up, alive: self.alive.clone() })
}
/// Like [`call`](Self::call) without a soft deadline, but `cancel` (the
+3 -2
Переглянути файл
@@ -14,9 +14,10 @@ mod sandbox;
mod types;
mod wit_host;
pub use actor::{Actor, ActorError};
pub use actor::{Actor, ActorError, Pending};
pub use runtime::{
API_VERSION, CANCELLED, CallCancel, CallEffects, CallError, HostApi, PluginEngine, Runtime, RuntimeSetup,
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
component_api, host_api,
};
pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root};
pub use types::*;
+162 -82
Переглянути файл
@@ -31,7 +31,7 @@ use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above.
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.0";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
@@ -41,16 +41,12 @@ const STDERR_CAPACITY: usize = 256 << 10;
pub const CANCELLED: &str = "was cancelled";
/// What plugin code may ask of the IDE, per plugin. Implemented by the host,
/// which routes notifications and status items to windows, edits to open
/// documents and runtime registrations through the extension registry.
/// Capabilities are already checked when these are called.
/// which routes notifications and status items to windows and edits to open
/// documents. Capabilities are already checked when these are called.
pub trait HostApi: Send + Sync {
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
/// Sets (`Some`) or removes the plugin's status bar item `id`.
fn status_item(&self, id: &str, item: Option<StatusItem>);
/// Registers a declaration with an extension point at runtime
/// (`commands`, `themes`, `languages`). Returns whether anything changed.
fn contribute(&self, point: &str, decl: toml::Value) -> Result<bool, String>;
/// Roots of the open projects.
fn projects(&self) -> Vec<std::path::PathBuf>;
/// Unsaved text of an open editor's file.
@@ -151,8 +147,7 @@ impl PluginEngine {
Component::new(&self.engine, bytes).map_err(|e| format!("not a valid plugin component: {e}"))
}
/// Compiles `wasm`, or loads it precompiled from `cache_dir` (docs/plugin_plan.md
/// 4.5): the key is the module's hash plus the engine's compatibility hash,
/// Compiles `wasm`, or loads it precompiled from `cache_dir`: the key is the module's hash plus the engine's compatibility hash,
/// so an engine upgrade simply misses and recompiles. Cache failures only
/// cost the compilation.
pub fn compile_cached(&self, wasm: &Path, cache_dir: &Path) -> Result<Component, String> {
@@ -200,12 +195,77 @@ impl PluginEngine {
&self.engine
}
/// Interfaces a component imports (`openidea:plugin/[email protected]`, `wasi:…`).
pub fn imports(&self, component: &Component) -> Vec<String> {
component.component_type().imports(&self.engine).map(|(name, _)| name.to_owned()).collect()
/// Rejects a component this host can't run, with a message that says what
/// to do: one importing interfaces outside the plugin API and WASI, or one
/// built for a plugin API this host doesn't implement (another major, or a
/// newer minor). Returns the API version the component was built for
/// (`None`: it uses no plugin interface).
pub fn check_component(&self, component: &Component) -> Result<Option<semver::Version>, String> {
let ty = component.component_type();
let imports: Vec<String> = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect();
let foreign: Vec<&str> = imports
.iter()
.map(String::as_str)
.filter(|i| !(i.starts_with(PLUGIN_PACKAGE_PREFIX) || i.starts_with("wasi:")))
.collect();
if !foreign.is_empty() {
return Err(format!("the component imports interfaces the IDE does not provide: {}", foreign.join(", ")));
}
let exports = ty.exports(&self.engine).map(|(name, _)| name.to_owned()).collect::<Vec<_>>();
let built_for = component_api(imports.iter().chain(&exports).map(String::as_str))?;
if let Some(v) = &built_for {
check_api(v, &host_api())?;
}
Ok(built_for)
}
}
/// Interface names of the plugin API start with this (`openidea:plugin/[email protected]`).
const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/";
/// The plugin API this host implements: the version of the `wit/` package.
pub fn host_api() -> semver::Version {
semver::Version::parse(ide_extension::ENGINE_VERSION).expect("ENGINE_VERSION is a semantic version")
}
/// The plugin API version a component was built for: the highest version on
/// its `openidea:plugin/*` imports and exports. An unversioned plugin
/// interface is an error, since nothing could tell compatible hosts apart.
pub fn component_api<'a>(names: impl IntoIterator<Item = &'a str>) -> Result<Option<semver::Version>, String> {
let mut highest: Option<semver::Version> = None;
for name in names {
let Some(rest) = name.strip_prefix(PLUGIN_PACKAGE_PREFIX) else { continue };
let Some((_, version)) = rest.split_once('@') else {
return Err(format!("the component uses the unversioned plugin interface {name:?}"));
};
let version =
semver::Version::parse(version).map_err(|e| format!("bad plugin API version in {name:?}: {e}"))?;
if highest.as_ref().is_none_or(|h| version > *h) {
highest = Some(version);
}
}
Ok(highest)
}
/// A component built for `built_for` runs on a host implementing `host` when
/// the major versions match and the host's minor is at least as new (1.x only
/// adds; patches change no interface; 0.x makes no promise, so there every
/// minor is its own API).
pub fn check_api(built_for: &semver::Version, host: &semver::Version) -> Result<(), String> {
let (b, h) = ((built_for.major, built_for.minor), (host.major, host.minor));
let same_line = b.0 == h.0 && (h.0 > 0 || b.1 == h.1);
if same_line && b <= h {
return Ok(());
}
let newer = b > h;
let (b, h) = (format!("{}.{}", b.0, b.1), format!("{}.{}", h.0, h.1));
Err(if newer {
format!("the component was built for plugin API {b}, newer than this IDE's {h}: update OpenIDEA")
} else {
format!("the component was built for plugin API {b}; this IDE runs {h}: rebuild it with the {h} SDK")
})
}
fn store_precompiled(component: &Component, file: &Path) {
let Ok(bytes) = component.serialize() else { return };
if let Some(dir) = file.parent() {
@@ -217,13 +277,6 @@ fn store_precompiled(component: &Component, file: &Path) {
}
}
/// Side effects of one call the host acts on afterwards.
#[derive(Default, Debug)]
pub struct CallEffects {
/// A runtime registration changed contributions.
pub contributions_changed: bool,
}
/// Interrupts the running call: the epoch callback traps on `flag`, host
/// operations (a fetch) watch `token`. Reset when a call starts.
#[derive(Default)]
@@ -275,7 +328,6 @@ pub struct StoreData {
pub(crate) granted: Vec<Capability>,
pub(crate) host: Arc<dyn HostApi>,
pub(crate) invocation: Invocation,
pub(crate) effects: CallEffects,
/// Tasks begun by the running call and not ended yet.
pub(crate) open_tasks: Vec<u64>,
pub(crate) last_report: Option<Instant>,
@@ -312,10 +364,76 @@ pub(crate) fn clip(mut s: String, max: usize) -> String {
/// trapped / ran out of budget (the instance is poisoned and must be dropped).
#[derive(Debug)]
pub enum CallError {
Guest(String),
/// The plugin returned an error.
Guest(GuestError),
/// The instance trapped (panic, fuel, memory, timeout) and is unusable.
Trap(String),
}
impl CallError {
fn missing_export(interface: &str) -> Self {
Self::Guest(GuestError::Failed(format!("the plugin does not export {interface}")))
}
}
impl std::fmt::Display for CallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Guest(e) => e.fmt(f),
Self::Trap(e) => f.write_str(e),
}
}
}
/// An error a plugin returned (the API's `error` variant).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GuestError {
/// A capability it needs is not granted.
Denied(String),
Stale,
NotFound(String),
Invalid(String),
/// The user cancelled, or the plugin is being stopped.
Cancelled,
Failed(String),
}
impl GuestError {
/// Whether this is the plugin failing, as opposed to an outcome the
/// user or the host caused (cancelling, a missing grant, bad input).
pub fn is_failure(&self) -> bool {
matches!(self, Self::Failed(_))
}
}
impl std::fmt::Display for GuestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Denied(c) => write!(f, "permission {c} was not granted"),
Self::Stale => f.write_str("the document changed meanwhile"),
Self::NotFound(m) => write!(f, "not found: {m}"),
Self::Invalid(m) => write!(f, "invalid: {m}"),
Self::Cancelled => f.write_str("cancelled"),
Self::Failed(m) => f.write_str(m),
}
}
}
impl From<GuestError> for ide_core::IdeError {
fn from(e: GuestError) -> Self {
use ide_core::error::ErrorKind;
let kind = match &e {
GuestError::Denied(_) => ErrorKind::PermissionDenied,
GuestError::Stale => ErrorKind::OutOfSync,
GuestError::NotFound(_) => ErrorKind::NotFound,
GuestError::Invalid(_) => ErrorKind::InvalidInput,
GuestError::Cancelled => ErrorKind::Cancelled,
GuestError::Failed(_) => ErrorKind::Internal,
};
ide_core::IdeError::new(kind, e.to_string())
}
}
pub struct RuntimeSetup {
pub plugin_id: String,
pub version: String,
@@ -397,7 +515,6 @@ impl Runtime {
granted: setup.granted,
host: setup.host,
invocation: Invocation::default(),
effects: CallEffects::default(),
open_tasks: Vec::new(),
last_report: None,
};
@@ -512,10 +629,9 @@ impl Runtime {
f: impl FnOnce(&wit_syntax::Guest, &mut Store<StoreData>) -> wasmtime::Result<R>,
) -> Result<R, CallError> {
if self.exports.syntax.is_none() {
return Err(CallError::Guest("the plugin does not provide syntax".into()));
return Err(CallError::missing_export("syntax-provider"));
}
self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store))
.map(|(r, _)| r)
}
/// The component exports `providers`.
@@ -529,10 +645,9 @@ impl Runtime {
f: impl FnOnce(&wit_prov::Guest, &mut Store<StoreData>) -> wasmtime::Result<R>,
) -> Result<R, CallError> {
if self.exports.providers.is_none() {
return Err(CallError::Guest("the plugin does not export providers".into()));
return Err(CallError::missing_export("providers"));
}
self.call(Invocation::default(), timeout, |e, store| f(e.providers.as_ref().expect("checked"), store))
.map(|(r, _)| r)
}
pub fn provide_completion(
@@ -550,7 +665,7 @@ impl Runtime {
.map(|i| CompletionItem {
label: clip(i.label, MAX_TITLE),
detail: i.detail.map(|d| clip(d, MAX_TITLE)),
kind: i.kind.map(|k| completion_kind(k).to_owned()),
kind: i.kind.map(|k| clip(k, 32)),
insert_text: clip(i.insert_text, 64 << 10),
replace: i.replace.map(range_from_wit),
sort_text: i.sort_text.map(|t| clip(t, 64)),
@@ -634,7 +749,7 @@ impl Runtime {
.collect())
}
pub fn activate(&mut self) -> Result<CallEffects, CallError> {
pub fn activate(&mut self) -> Result<(), CallError> {
let data = self.store.data();
let ctx = wit_types::Context {
plugin_id: data.plugin_id.clone(),
@@ -645,7 +760,7 @@ impl Runtime {
self.call(Invocation::default(), self.limits.timeout, |e, store| {
e.plugin.openidea_plugin_lifecycle().call_activate(store, &ctx)
})
.and_then(|(r, fx)| r.map(|()| fx).map_err(CallError::Guest))
.and_then(|r| r.map_err(|e| CallError::Guest(e.into())))
}
pub fn run_command(
@@ -653,8 +768,8 @@ impl Runtime {
id: &str,
arg: Option<&str>,
invocation: Invocation,
) -> Result<(Option<String>, CallEffects), CallError> {
let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| {
) -> Result<Option<String>, CallError> {
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let r = e.plugin.openidea_plugin_commands().call_run(
&mut *store,
@@ -665,7 +780,7 @@ impl Runtime {
store.data_mut().drop_invocation(ctx);
r
})?;
result.map(|v| (v, fx)).map_err(CallError::Guest)
result.map_err(|e| CallError::Guest(e.into()))
}
/// The component exports `view-provider`.
@@ -679,12 +794,12 @@ impl Runtime {
view: &str,
viewport: Option<Viewport>,
invocation: Invocation,
) -> Result<(View, CallEffects), CallError> {
) -> Result<View, CallError> {
if self.exports.views.is_none() {
return Err(CallError::Guest("the plugin does not export view-provider".into()));
return Err(CallError::missing_export("view-provider"));
}
let viewport = viewport.map(|v| wit_vt::Viewport { first_row: v.first_row, rows: v.rows.min(MAX_TABLE_ROWS) });
let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| {
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let views = e.views.as_ref().expect("checked");
let r =
@@ -693,38 +808,29 @@ impl Runtime {
r
})?;
match result {
Ok(v) => Ok((view_from_wit(v), fx)),
Err(e) => Err(CallError::Guest(crate::wit_host::error_text(e))),
Ok(v) => Ok(view_from_wit(v)),
Err(e) => Err(CallError::Guest(e.into())),
}
}
/// A user action in a view or custom editor.
pub fn view_event(
&mut self,
view: &str,
event: ViewEvent,
invocation: Invocation,
) -> Result<CallEffects, CallError> {
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
if self.exports.views.is_none() {
return Err(CallError::Guest("the plugin does not export view-provider".into()));
return Err(CallError::missing_export("view-provider"));
}
let event = event_to_wit_view(event);
let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| {
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let views = e.views.as_ref().expect("checked");
let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
result.map(|()| fx).map_err(|e| CallError::Guest(crate::wit_host::error_text(e)))
result.map_err(|e| CallError::Guest(e.into()))
}
/// Delivers a batch of events (from the window in `invocation`).
pub fn handle_events(
&mut self,
events: Vec<WorkbenchEvent>,
invocation: Invocation,
) -> Result<CallEffects, CallError> {
pub fn handle_events(&mut self, events: Vec<WorkbenchEvent>, invocation: Invocation) -> Result<(), CallError> {
let events: Vec<wit_events::Event> = events.into_iter().take(MAX_BATCH).map(event_to_wit).collect();
self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
@@ -736,16 +842,13 @@ impl Runtime {
store.data_mut().drop_invocation(ctx);
r
})
.map(|((), fx)| fx)
}
/// Best effort; errors are logged.
pub fn deactivate(&mut self) {
if let Err(CallError::Trap(e) | CallError::Guest(e)) =
self.call(Invocation::default(), self.limits.timeout, |e, store| {
e.plugin.openidea_plugin_lifecycle().call_deactivate(store)
})
{
if let Err(e) = self.call(Invocation::default(), self.limits.timeout, |e, store| {
e.plugin.openidea_plugin_lifecycle().call_deactivate(store)
}) {
tracing::warn!(plugin = %self.store.data().plugin_id, "deactivate failed: {e}");
}
}
@@ -761,7 +864,7 @@ impl Runtime {
invocation: Invocation,
timeout: Duration,
f: impl FnOnce(&Exports, &mut Store<StoreData>) -> wasmtime::Result<R>,
) -> Result<(R, CallEffects), CallError> {
) -> Result<R, CallError> {
arm(&mut self.store, &self.limits, timeout).map_err(|e| CallError::Trap(e.to_string()))?;
self.store.data_mut().invocation = invocation;
let result = f(&self.exports, &mut self.store);
@@ -771,13 +874,12 @@ impl Runtime {
for id in std::mem::take(&mut data.open_tasks) {
data.host.task_end(id);
}
let effects = std::mem::take(&mut data.effects);
let stderr = data.drain_stderr();
if !stderr.trim().is_empty() {
tracing::info!(plugin = %data.plugin_id, "stderr: {}", stderr.trim_end());
}
match result {
Ok(r) => Ok((r, effects)),
Ok(r) => Ok(r),
Err(e) => {
let mut msg = describe_trap(&e, &self.limits);
// Typically the panic message: "panicked at src/lib.rs:1:2:\nboom".
@@ -824,28 +926,6 @@ fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document {
}
}
fn completion_kind(k: wit_prov::CompletionKind) -> &'static str {
use wit_prov::CompletionKind as K;
match k {
K::Text => "text",
K::Method => "method",
K::Function => "function",
K::Constructor => "constructor",
K::Field => "field",
K::Variable => "variable",
K::Class => "class",
K::Interface => "interface",
K::Module => "module",
K::Property => "property",
K::Keyword => "keyword",
K::Snippet => "snippet",
K::Value => "value",
K::Constant => "constant",
K::File => "file",
K::Reference => "reference",
}
}
/// Resets the budgets for the next call: fuel, deadline, cancellation.
fn arm(store: &mut Store<StoreData>, limits: &Limits, timeout: Duration) -> wasmtime::Result<()> {
store.set_fuel(limits.fuel)?;
+1 -2
Переглянути файл
@@ -173,8 +173,7 @@ pub struct RepositoryStatus {
pub changed_files: u32,
}
/// Resource limits and supervision policy for plugin code
/// (docs/plugin_plan.md 4.5).
/// Resource limits and supervision policy for plugin code.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
/// Wasm fuel (≈ instructions) per host → plugin call.
+13 -42
Переглянути файл
@@ -33,7 +33,6 @@ pub(crate) mod syntax_bindings {
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/log": super::bindings::openidea::plugin::log,
"openidea:plugin/contributions": super::bindings::openidea::plugin::contributions,
"openidea:plugin/ui": super::bindings::openidea::plugin::ui,
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
@@ -61,7 +60,6 @@ pub(crate) mod providers_bindings {
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/log": super::bindings::openidea::plugin::log,
"openidea:plugin/contributions": super::bindings::openidea::plugin::contributions,
"openidea:plugin/ui": super::bindings::openidea::plugin::ui,
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
@@ -89,7 +87,6 @@ pub(crate) mod views_bindings {
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/log": super::bindings::openidea::plugin::log,
"openidea:plugin/contributions": super::bindings::openidea::plugin::contributions,
"openidea:plugin/ui": super::bindings::openidea::plugin::ui,
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
@@ -109,8 +106,8 @@ pub(crate) mod views_bindings {
}
use bindings::openidea::plugin::{
contributions, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
types as wit, ui, vcs, views, workspace,
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
vcs, views, workspace,
};
/// A project handle: its root.
@@ -150,14 +147,17 @@ pub(crate) fn to_wit(e: HostError) -> wit::Error {
}
/// A guest's `error` as text (`denied: net:example.com`, `stale`, …).
pub(crate) fn error_text(e: wit::Error) -> String {
match e {
wit::Error::Denied(c) => format!("permission {c} was not granted"),
wit::Error::Stale => "the document changed meanwhile".into(),
wit::Error::NotFound(m) => format!("not found: {m}"),
wit::Error::Invalid(m) => format!("invalid: {m}"),
wit::Error::Cancelled => "cancelled".into(),
wit::Error::Failed(m) => m,
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)),
}
}
}
@@ -172,10 +172,6 @@ fn range_to_wit(r: &ide_core::Range) -> wit::Range {
}
}
fn table(entries: &[(&str, toml::Value)]) -> toml::Value {
toml::Value::Table(entries.iter().map(|(k, v)| ((*k).to_owned(), v.clone())).collect())
}
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())) }
@@ -185,12 +181,6 @@ impl StoreData {
self.invocation.client.as_deref()
}
fn contribute(&mut self, point: &str, decl: toml::Value) -> Result<(), wit::Error> {
let changed = self.host.contribute(point, decl).map_err(wit::Error::Invalid)?;
self.effects.contributions_changed |= changed;
Ok(())
}
fn push<T: Send + 'static>(&mut self, value: T) -> wasmtime::Result<Resource<T>> {
Ok(self.table.push(value)?)
}
@@ -221,25 +211,6 @@ impl log::Host for StoreData {
}
}
impl contributions::Host for StoreData {
fn register_command(&mut self, id: String, title: String, category: Option<String>) -> Fallible<()> {
let max = crate::runtime::MAX_TITLE;
let mut entries = vec![("id", toml::Value::String(id)), ("title", toml::Value::String(clip(title, max)))];
if let Some(c) = category.map(|c| clip(c, max)).filter(|c| !c.is_empty()) {
entries.push(("category", toml::Value::String(c)));
}
Ok(self.contribute("commands", table(&entries)))
}
fn register_language(&mut self, definition: String) -> Fallible<()> {
Ok(self.contribute("languages", table(&[("definition", toml::Value::String(definition))])))
}
fn register_theme(&mut self, definition: String) -> Fallible<()> {
Ok(self.contribute("themes", table(&[("definition", toml::Value::String(definition))])))
}
}
impl ui::Host for StoreData {
fn show_notification(
&mut self,
+270
Переглянути файл
@@ -0,0 +1,270 @@
//! Plugin API versioning (plugins/README.md, "Evolving the plugin API").
//!
//! - The `wit/` package version, `ENGINE_VERSION` and `API_VERSION` agree.
//! - The version check accepts components the host can run and explains the rest.
//! - Components built for 1.0 link against a host of a later 1.x minor that
//! adds functions and interfaces (wasmtime resolves `@1.0.0` names to a
//! semver-compatible definition), and the host finds their exports under
//! the newer name; a 2.0 host does not link them. The newer host is
//! simulated with a linker that stubs every plugin import the component has
//! at the newer version, so no copy of the WIT is needed.
//!
//! Components: the checked-in `dist/plugin.wasm` of `plugins/hello`, the
//! official plugins (`plugins/build.sh` rebuilds them).
use ide_plugin::{API_VERSION, PluginEngine, check_api, component_api, host_api};
use std::path::{Path, PathBuf};
use wasmtime::component::types::ComponentItem;
use wasmtime::component::{Component, Linker, ResourceTable, ResourceType};
use wasmtime::{Config, Engine};
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
/// Every component the repository ships: `hello` and the official plugins with code.
fn components() -> Vec<PathBuf> {
let mut out = vec![repo().join("plugins/hello/dist/plugin.wasm")];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
dirs.sort();
out.extend(dirs.into_iter().map(|d| d.join("dist/plugin.wasm")).filter(|p| p.is_file()));
assert!(out.len() >= 10, "expected hello and the official plugins, found {out:?}");
out
}
fn v(s: &str) -> semver::Version {
semver::Version::parse(s).unwrap()
}
#[test]
fn wit_package_engine_and_api_versions_agree() {
let host = host_api();
assert_eq!(API_VERSION, format!("{}.{}", host.major, host.minor));
let want = format!("package openidea:plugin@{host};");
let mut files = 0;
for entry in std::fs::read_dir(repo().join("wit")).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "wit") {
let src = std::fs::read_to_string(&path).unwrap();
assert!(src.lines().any(|l| l.trim() == want), "{} does not declare `{want}`", path.display());
files += 1;
}
}
assert!(files > 1);
}
/// The SDK ships its own copy of `wit/` (it builds outside this repository);
/// it must be the API this host implements.
#[test]
fn the_sdk_carries_the_hosts_wit() {
let read = |dir: PathBuf| {
let mut files: Vec<(String, String)> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|e| e == "wit"))
.map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read_to_string(&p).unwrap()))
.collect();
files.sort();
files
};
let host = read(repo().join("wit"));
let sdk = read(repo().join("sdk/rust/openidea-plugin/wit"));
let names = |f: &[(String, String)]| f.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>();
assert_eq!(names(&sdk), names(&host), "sdk/rust/openidea-plugin/wit has other files than wit/");
for ((name, a), (_, b)) in host.iter().zip(&sdk) {
assert!(a == b, "sdk/rust/openidea-plugin/wit/{name} differs from wit/{name}: copy wit/ into the SDK");
}
}
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.3.0");
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.4.0"), &host).unwrap_err();
assert!(newer.contains("1.4") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.3 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
assert!(check_api(&v("0.3.0"), &v("0.2.0")).is_err());
}
#[test]
fn a_components_api_is_the_highest_plugin_interface_version() {
let names = ["wasi:io/[email protected]", "openidea:plugin/[email protected]", "openidea:plugin/[email protected]"];
assert_eq!(component_api(names), Ok(Some(v("1.2.0"))));
assert_eq!(component_api(["wasi:cli/[email protected]"]), Ok(None));
assert!(component_api(["openidea:plugin/log"]).unwrap_err().contains("unversioned"));
}
#[test]
fn shipped_components_are_built_for_this_api() {
let engine = PluginEngine::new().unwrap();
let host = host_api();
for wasm in components() {
let component = engine.compile(&wasm).unwrap();
let built_for = engine.check_component(&component).unwrap_or_else(|e| panic!("{}: {e}", wasm.display()));
let built_for = built_for.unwrap_or_else(|| panic!("{} uses no plugin interface", wasm.display()));
assert_eq!((built_for.major, built_for.minor), (host.major, host.minor), "{}", wasm.display());
}
}
// --- a later host, simulated -------------------------------------------------------------
struct Data {
wasi: WasiCtx,
table: ResourceTable,
}
impl WasiView for Data {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.wasi, table: &mut self.table }
}
}
/// Distinct host types for the stubbed resources (a component may import several).
struct Stub<const N: usize>;
fn stub_resource(n: usize) -> ResourceType {
macro_rules! pick { ($($i:literal)*) => { match n { $($i => ResourceType::host::<Stub<$i>>(),)* _ => panic!("more than 16 resources") } } }
pick!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15)
}
enum Host<'a> {
/// Defines everything the component imports, plus a function and an
/// interface the component doesn't know.
Superset,
/// Leaves out one function, so linking must fail.
Without(&'a str),
}
/// A linker with WASI and every `openidea:plugin/*` interface `component`
/// imports, defined at `version` with functions that are never called.
fn host_at(engine: &Engine, component: &Component, version: &str, host: Host<'_>) -> Linker<Data> {
let mut linker = Linker::new(engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker).unwrap();
// The component's resource → its stub. A resource re-exported through
// `use` (`invocation` uses `editor.text-editor`) is the same type, so it
// must get the same stub.
let mut resources: Vec<(ResourceType, ResourceType)> = Vec::new();
for (name, item) in component.component_type().imports(engine) {
let Some(rest) = name.strip_prefix("openidea:plugin/") else { continue };
let iface = rest.split_once('@').map_or(rest, |(i, _)| i);
let ComponentItem::ComponentInstance(instance) = item.ty else { panic!("{name} is not an instance") };
let mut defined = linker.instance(&format!("openidea:plugin/{iface}@{version}")).unwrap();
for (export, item) in instance.exports(engine) {
match item.ty {
ComponentItem::ComponentFunc(_) if !matches!(host, Host::Without(f) if f == export) => {
defined.func_new(export, |_, _, _, _| Err(wasmtime::Error::msg("stub"))).unwrap();
}
ComponentItem::Resource(ty) => {
let stub = match resources.iter().find(|(r, _)| *r == ty) {
Some((_, stub)) => *stub,
None => {
let stub = stub_resource(resources.len());
resources.push((ty, stub));
stub
}
};
defined.resource(export, stub, |_, _| Ok(())).unwrap();
}
_ => {}
}
}
if matches!(host, Host::Superset) {
defined.func_new("added-in-a-later-minor", |_, _, _, _| Ok(())).unwrap();
}
}
if matches!(host, Host::Superset) {
let mut added = linker.instance(&format!("openidea:plugin/added-interface@{version}")).unwrap();
added.func_new("call", |_, _, _, _| Ok(())).unwrap();
}
linker
}
fn engine() -> Engine {
let mut config = Config::new();
config.wasm_component_model(true);
Engine::new(&config).unwrap()
}
/// The next minor of the API this host implements.
fn next_minor() -> String {
let host = host_api();
format!("{}.{}.0", host.major, host.minor + 1)
}
#[test]
fn components_link_against_a_later_minor_that_adds_functions_and_interfaces() {
let engine = engine();
let next = next_minor();
for wasm in components() {
let component = Component::from_file(&engine, &wasm).unwrap();
let linker = host_at(&engine, &component, &next, Host::Superset);
linker.instantiate_pre(&component).unwrap_or_else(|e| panic!("{}: {e:?}", wasm.display()));
let exports: Vec<String> = component
.component_type()
.exports(&engine)
.map(|(n, _)| n.to_owned())
.filter(|n| n.starts_with("openidea:plugin/"))
.collect();
assert!(!exports.is_empty(), "{} exports no plugin interface", wasm.display());
for name in exports {
let iface = name.split_once('@').unwrap().0;
let newer = format!("{iface}@{next}");
assert!(
component.get_export_index(None, &newer).is_some(),
"{}: a {next} host can't find export {name}",
wasm.display()
);
}
}
}
#[test]
fn components_do_not_link_against_another_major() {
let engine = engine();
let next_major = format!("{}.0.0", host_api().major + 1);
let wasm = repo().join("plugins/hello/dist/plugin.wasm");
let component = Component::from_file(&engine, &wasm).unwrap();
let linker = host_at(&engine, &component, &next_major, Host::Superset);
assert!(linker.instantiate_pre(&component).is_err());
let iface = component
.component_type()
.exports(&engine)
.map(|(n, _)| n.to_owned())
.find(|n| n.starts_with("openidea:plugin/"))
.unwrap();
let newer = format!("{}@{next_major}", iface.split_once('@').unwrap().0);
assert!(component.get_export_index(None, &newer).is_none());
}
#[test]
fn a_later_minor_that_drops_a_function_does_not_link() {
// Keeps the test above honest: the stub host really is type-checked.
let engine = engine();
let wasm = repo().join("plugins/hello/dist/plugin.wasm");
let component = Component::from_file(&engine, &wasm).unwrap();
let (iface, func) = component
.component_type()
.imports(&engine)
.filter(|(n, _)| n.starts_with("openidea:plugin/"))
.find_map(|(n, item)| match item.ty {
ComponentItem::ComponentInstance(i) => i
.exports(&engine)
.find(|(_, it)| matches!(it.ty, ComponentItem::ComponentFunc(_)))
.map(|(f, _)| (n.to_owned(), f.to_owned())),
_ => None,
})
.expect("hello imports a plugin function");
let linker = host_at(&engine, &component, &next_minor(), Host::Without(&func));
let err = linker.instantiate_pre(&component).err().unwrap_or_else(|| panic!("{iface} without {func} linked"));
assert!(format!("{err:?}").contains(&func), "{err:?}");
}
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
0ad2e4c6977a576b7eb27fc1e535fd056dd9da64bdfe6cfda9ca9f57537d0384
+32
Переглянути файл
@@ -8,3 +8,35 @@ activation = ["on-command:*"]
[capabilities]
required = ["project.read"]
[[contributes.commands]]
id = "echo"
title = "echo"
[[contributes.commands]]
id = "panic"
title = "panic"
[[contributes.commands]]
id = "spin"
title = "spin"
[[contributes.commands]]
id = "alloc"
title = "alloc"
[[contributes.commands]]
id = "read"
title = "read"
[[contributes.commands]]
id = "root"
title = "root"
[[contributes.commands]]
id = "insert"
title = "insert"
[[contributes.commands]]
id = "cancel"
title = "cancel"
+75 -32
Переглянути файл
@@ -1,28 +1,35 @@
//! Test fixture for the ide-plugin sandbox. Never shipped.
// `plugin` with syntax and editor providers.
wit_bindgen::generate!({
world: "plugin-syntax",
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.0.0;
export openidea:plugin/providers@1.0.0;
}
",
path: "../../../../../wit",
world: "fixture:misbehave/misbehave",
generate_all,
});
use exports::openidea::plugin::providers::{self, CodeAction, CompletionItem, Document, InlineSuggestion, LineMarker};
use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token};
use exports::openidea::plugin::{commands, event_handler, lifecycle};
use openidea::plugin::{contributions, invocation::Invocation, types};
use openidea::plugin::{invocation::Invocation, types};
struct Misbehave;
fn err(e: types::Error) -> String {
format!("{e:?}")
fn failed(message: &str) -> types::Error {
types::Error::Failed(message.into())
}
impl lifecycle::Guest for Misbehave {
fn activate(ctx: types::Context) -> Result<(), String> {
fn activate(ctx: types::Context) -> Result<(), types::Error> {
// A deliberately broken build (rollback tests): `version = "…-broken"`.
if ctx.plugin_version.ends_with("-broken") {
return Err("this build is broken".into());
}
for id in ["echo", "panic", "spin", "alloc", "read", "root", "insert", "language", "theme"] {
contributions::register_command(id, id, None).map_err(err)?;
return Err(failed("this build is broken"));
}
Ok(())
}
@@ -31,7 +38,7 @@ impl lifecycle::Guest for Misbehave {
}
impl commands::Guest for Misbehave {
fn run(id: String, ctx: &Invocation, arg: Option<String>) -> Result<Option<String>, String> {
fn run(id: String, ctx: &Invocation, arg: Option<String>) -> Result<Option<String>, types::Error> {
match id.as_str() {
"echo" => Ok(arg),
"panic" => panic!("boom"),
@@ -49,18 +56,18 @@ impl commands::Guest for Misbehave {
}
}
"read" => {
let project = ctx.project().ok_or("no project is open")?;
project.read_file(&arg.unwrap_or_default()).map(Some).map_err(err)
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
project.read_file(&arg.unwrap_or_default()).map(Some)
}
"root" => ctx.project().map(|p| p.root().map_err(err)).transpose(),
"root" => ctx.project().map(|p| p.root()).transpose(),
// `editor.write` is not granted: an error, not a trap.
"insert" => {
let editor = ctx.editor().ok_or("no editor")?;
editor.apply_edits(0, &[]).map(|_| None).map_err(err)
let editor = ctx.editor().ok_or_else(|| failed("no editor"))?;
editor.apply_edits(0, &[]).map(|_| None)
}
"language" => contributions::register_language(&arg.unwrap_or_default()).map(|_| None).map_err(err),
"theme" => contributions::register_theme(&arg.unwrap_or_default()).map(|_| None).map_err(err),
other => Err(format!("unknown command: {other}")),
// The user gave up: not the plugin failing.
"cancel" => Err(types::Error::Cancelled),
other => Err(types::Error::NotFound(format!("command {other}"))),
}
}
}
@@ -70,22 +77,26 @@ impl event_handler::Guest for Misbehave {
}
/// Edit-path budgets: the text says how to behave. `sleep:<ms>` busy-waits
/// that long, `spin` never returns, `panic` traps; anything else is one
/// keyword token over the whole text.
/// that long, `spin` never returns, `panic` traps.
fn behave(text: &str) {
if let Some(ms) = text.strip_prefix("sleep:").and_then(|n| n.trim().parse::<u64>().ok()) {
let until = std::time::Instant::now() + std::time::Duration::from_millis(ms);
while std::time::Instant::now() < until {
std::hint::black_box(0);
}
} else if text == "spin" {
loop {
std::hint::black_box(0);
}
} else if text == "panic" {
panic!("boom");
}
}
/// One keyword token over the whole text, after [`behave`].
impl syntax_provider::Guest for Misbehave {
fn highlights(_language: String, text: String) -> Vec<Token> {
if let Some(ms) = text.strip_prefix("sleep:").and_then(|n| n.trim().parse::<u64>().ok()) {
let until = std::time::Instant::now() + std::time::Duration::from_millis(ms);
while std::time::Instant::now() < until {
std::hint::black_box(0);
}
} else if text == "spin" {
loop {
std::hint::black_box(0);
}
} else if text == "panic" {
panic!("highlight boom");
}
behave(&text);
vec![Token { start: 0, end: text.len() as u32, kind: "keyword".into() }]
}
@@ -98,4 +109,36 @@ impl syntax_provider::Guest for Misbehave {
}
}
/// Completion: one item, labelled with the document's path, after [`behave`].
impl providers::Guest for Misbehave {
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
behave(&doc.text);
vec![CompletionItem {
label: doc.path.clone(),
detail: None,
kind: Some("keyword".into()),
insert_text: doc.path,
replace: None,
sort_text: None,
documentation: None,
}]
}
fn inline_completion(_doc: Document, _at: types::Position) -> Option<InlineSuggestion> {
None
}
fn code_actions(_doc: Document, _selection: types::Range) -> Vec<CodeAction> {
Vec::new()
}
fn definition(_doc: Document, _at: types::Position) -> Vec<types::Location> {
Vec::new()
}
fn line_markers(_doc: Document) -> Vec<LineMarker> {
Vec::new()
}
}
export!(Misbehave);
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
/// P0 probe (docs/plugin_p0.md): optional exports, async calls and
/// Runtime probe: optional exports, async calls and
/// cancellation, and a WASI p2 task model built on `wasi:io/poll`.
/// Test fixture only; not part of the plugin API.
package openidea:[email protected];
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
//! P0 experiments (docs/plugin_p0.md), kept as regression tests: they pin the
//! Runtime experiments, kept as regression tests: they pin the
//! wasmtime behaviour the plugin runtime is designed around.
//!
//! Components: `tests/fixtures/p0-probe/dist/{min,full}.wasm`, rebuilt with
+2 -2
Переглянути файл
@@ -1,5 +1,5 @@
//! Run configurations detected outside this crate: plugins' `runDetectors`
//! (docs/plugin_plan.md P3). Their ids start with [`EXTERNAL_PREFIX`]; before
//! Run configurations detected outside this crate: plugins' `runDetectors`.
//! Their ids start with [`EXTERNAL_PREFIX`]; before
//! one is started the provider names the program to run (the file approved
//! for the plugin's `process:` capability) or refuses.
-159
Переглянути файл
@@ -1,159 +0,0 @@
# 插件系统 P0 验证结论
对应 `docs/plugin_plan.md` 第 5 节 P0。实验代码保留为回归测试:
`crates/ide-plugin/tests/p0_probe.rs`,探针组件在 `crates/ide-plugin/tests/fixtures/p0-probe/`
(`build.sh` 生成 `dist/min.wasm` 与 `dist/full.wasm`)。环境:wasmtime 49.0.5、wasmtime-wasi 49(p2)、
wit-bindgen 0.63、`wasm32-wasip2`,Apple Silicon,debug 构建(release 数字括注)。
## 1. 可选导出能否按接口绑定 —— 可以
测试:`optional_exports_bind_per_interface`。
- `Component::get_export_index(None, "openidea:probe/[email protected]")` 在**编译后、实例化前**就能判断
组件有没有导出某个接口。安装时据此算出插件实现了哪些 Provider,写进贡献索引,启动时不需要实例化。
- `bindgen!` 生成的整 world 绑定(`ProbeHostPre::new`)要求所有导出都在,缺一个就失败;
但每个导出接口各有一个 `GuestIndices::new(&instance_pre)`,缺失时返回 `Err`,存在时正常 `load` 和调用。
- 结论:**不需要退回“基础 world + 多个组合 world”**。宿主用一个包含全部可选导出的 world 生成绑定,
只用到每个接口的 `GuestIndices`,不用整 world 的 `*Pre`。插件作者侧:SDK 为每个 Provider 提供
feature 或宏,只导出实现了的接口(wit-bindgen 按 world 生成,SDK 内置几个常用组合 world 即可,
宿主不关心组合方式)。
对设计的影响:无,4.3 节的“可选导出”成立。
## 2. async 调用、取消与实例复用
测试:`spinning_guest_yields_to_the_executor`、`cancelled_instance_cannot_be_reused_but_reinstantiates_fast`、
`async_host_import_does_not_block`。
- wasmtime 49 里 `Config::async_support` 已废弃且无效果,async 能力由 `async` cargo feature 和调用方式决定。
- `store.epoch_deadline_async_yield_and_update(1)` + `call_async`:死循环的 guest 每个 epoch tick 让出一次,
**单线程**执行器上的其他任务照常推进。长调用不再独占线程。
- async 宿主导入(`imports: { "...sleep-ms": async }`)等待期间不阻塞线程。
- guest 返回 `Err`(不是 trap)后实例照常可用。
- **取消(丢弃 future)后实例不能复用**:下一次调用返回 `Trap::CannotEnterComponent`。
无论取消发生在 guest 计算中还是 async 宿主导入的等待中,结果相同。原因:wasmtime 在 fiber 被
dispose 时把错误传回 `catch_traps`,组件 store 被标记为 `trapped`(`may_enter() == false`)。
这是 wasmtime 的有意行为,不能绕开。
- **重建很便宜**:从已有的 `InstancePre` 新建 store 并实例化,加上第一次调用,约 **50µs**(release 约 40µs)。
代价在于丢失实例内存里的状态,而不是时间。
结论与对 4.5 节的修订:
1. 取消 = 丢弃 future + 丢弃 store;下次调用前从 `InstancePre` 重建。不需要冷编译。
2. 两段式超时仍然保留,但理由从“避免冷启动”改为“保留实例状态”:
- **软超时**(编辑路径 50ms):调用方不再等待、结果丢弃,调用在 actor 内继续执行完,实例和状态保留;
同一插件的后续调用排队,新请求到来时旧请求如仍在排队则直接丢弃(不进入 guest)。
- **硬超时**(编辑路径 500ms,命令 5s):丢弃 future,重建实例,计入熔断计数。
3. 用户主动取消(例如 AI Completion 的旧请求)优先走**协作式取消**(见第 3 节),实例不受影响;
协作式取消在宽限期内没有返回时,再升级为硬取消。
4. 插件需要跨调用保留的状态一律走 `storage`;实例内存只当缓存。SDK 文档写明这一点。
5. fuel 保留作为死循环兜底;与 epoch 让出同时启用没有冲突。
## 3. WASI p2 上的流式与任务模型 —— 可行,不依赖 p3
测试:`pollable_task_streams_and_cancels_cooperatively`。
- 宿主定义 `task` 资源,方法 `next-ready() -> pollable` 返回 `wasi:io/poll.pollable`(宿主用
`wasmtime_wasi::p2::subscribe` 包装自定义 `Pollable`)。guest 调 `pollable.block()` 时,wasmtime 在
async 宿主里等待该 future,执行器线程不被占用。
- guest 循环“等待 → 取数据 → `report` 进度 → 检查 `is-cancelled`”,宿主推送数据并在最后设置取消标志;
guest 正常返回,**实例保持可用**。
- 流式输出(例如 AI Completion 逐块返回、Kubernetes 日志)用同一模式的反方向:宿主提供
`output-stream` 风格的资源(`write(chunk)` 是 async 宿主导入),或者 guest 通过 `task.emit(chunk)` 推送;
两者都只需要 p2 资源 + pollable。
- 结论:4.3 节 `tasks` 接口定为:
```wit
resource task {
report: func(done: u32, total: option<u32>);
is-cancelled: func() -> bool;
/// 取消或有新输入时就绪,供 guest 在等待时让出。
cancelled: func() -> pollable;
}
```
`net` 的流式响应体直接复用 `wasi:io/streams.input-stream`,不自造类型。
## 4. `syntax`、`index` 接口
依据:`ide-syntax`(`StructureService`:`node_at`、`syntax_errors`、`locals`、`identifiers_named`、
`structural_ranges`,全部基于 `ide-language` 的 `SyntaxCache` 增量树,0 基行、UTF-16 列)和 `ide-index`
(`ProjectIndex`:`find_files`、`find_symbols`、`symbols_named`,后台 worker + redb 持久化)。
Solidity 与 MyBatis Navigator 两个原型需要的能力如下,接口按它们的最小需求定稿。
### 4.1 `syntax`
两个方向,分开设计:
- **读宿主的树(import)**:适用于宿主有 grammar 的语言(MyBatis Navigator 读 Java、XML)。
```wit
interface syntax {
resource tree {
/// 0 基行、UTF-16 列;返回包含该位置的最内层命名节点。
node-at: func(pos: position) -> option<node>;
/// tree-sitter 查询语法;只返回被捕获的节点,最多 `limit` 个。
query: func(source: string, range: option<range>, limit: u32) -> result<list<capture>, error>;
errors: func(limit: u32) -> list<range>;
}
record node { kind: string, range: range, text: string, parent-kind: option<string> }
record capture { name: string, node: node }
/// 打开的文档用增量缓存树;未打开的文件现场解析(受 `project.read` 约束)。
parse: func(path: string) -> result<tree, error>;
}
```
不暴露节点句柄的游走 API(`parent`/`children` 逐个跨边界调用代价高);查询一次返回结果列表。
- **插件提供语法树(export,P3)**:宿主没有 grammar 的语言(Solidity)。宿主不加载插件提供的原生
grammar;插件在 Wasm 里自带解析器,导出 `syntax-provider`,按需返回**扁平结果**而不是树:
```wit
interface syntax-provider {
highlights: func(text: string) -> list<token>; // range + 语义类别(映射到 --syn-*)
outline: func(text: string) -> list<outline-item>;
folding: func(text: string) -> list<range>;
}
```
宿主把结果转换成现有的 `OutlineItem`、`FoldRange`、`SyntaxHighlights`,编辑器侧无感知。
这条路径在编辑路径上,适用 4.5 节的编辑路径预算。
原因:tree-sitter 的 Wasm grammar 需要 `tree-sitter` 的 wasm 特性和额外的 wasmtime 实例,
且 grammar 不能表达大纲和折叠;扁平结果把实现方式留给插件。
### 4.2 `index`
- **查宿主的符号索引(import)**:
```wit
interface index {
find-symbols: func(query: string, types-only: bool, limit: u32) -> list<symbol>;
symbols-named: func(name: string, kind: option<symbol-kind>, limit: u32) -> list<symbol>;
find-files: func(query: string, limit: u32) -> list<string>;
/// 插件自定义索引的查询,见下。
lookup: func(index: string, key: string, limit: u32) -> list<entry>;
}
record entry { key: string, value: string, path: string, range: range }
```
- **插件自定义索引(export + 声明)**:MyBatis Navigator 要“Mapper 接口方法 ↔ XML 语句 id”的跨文件映射,
宿主的符号索引不认识 XML 语句。做法:
```toml
[[contributes.indexes]]
id = "statements"
files = ["**/*Mapper.xml", "**/*Mapper.java"]
version = 1 # 改变索引格式时递增,宿主据此重建
```
插件导出 `indexer.index-file(path, text) -> list<entry>`;宿主在**后台**(复用 `ide-index` 的 worker
调度和 redb 存储,按插件 id + 索引 id 分表)对匹配文件调用,文件变化时增量更新,插件禁用或卸载时删表。
插件运行时只能 `lookup` 自己的索引。索引任务是后台任务,适用后台预算并上报进度。
对设计的影响:4.3 节的 `syntax`、`index` 按上面两节定稿;新增 `contributes.indexes` 扩展点(P6)。
## 5. 文档修正
`plugins/README.md` 第 4 节原文暗示插件语言里的 `language-servers`、`debugger` 会生效。实际上插件语言
只经 `languages` 命令进入前端的语言列表(文件检测、注释、缩进、借用的 CodeMirror 模式),不进入后端
`LanguageRegistry`,因此 LSP、DAP、后端语言检测都不生效。README 已改为如实说明,P3 完成后再恢复。
## 6. 对计划的修订汇总
| 位置 | 原设计 | 修订 |
| -------------------- | ------------------------ | ------------------------------------------------------------ |
| 4.3 可选导出 | 待验证,备选组合 world | 按接口 `GuestIndices` 绑定,安装时用 `get_export_index` 记录 |
| 4.5 超时 | 两段式用于避免冷启动 | 重建约 50µs;两段式用于保留实例状态;用户取消优先协作式 |
| 4.3 `tasks` | `report`、`is-cancelled` | 增加 `cancelled() -> pollable` |
| 4.3 `syntax`/`index` | 待定 | 见第 4 节 |
以上修订已同步到 `docs/plugin_plan.md`。
-601
Переглянути файл
@@ -1,601 +0,0 @@
# 插件系统重构计划
状态:P0–P8 完成(P4 release 实测在预算内),WIT 冻结为 `openidea:[email protected]`。涉及 `crates/ide-plugin`、`crates/ide-host`、`wit/`、
`src-tauri/src/commands/plugin.rs`、`src/features/plugins`、`src/workbench/contributions.ts`。
## 1. 目标
- 新增贡献类型时不改内核:领域 crate 或前端 feature 注册一个扩展点即可。
- 插件能做出实质功能:Provider、事件、长任务、视图、自定义编辑器、外部工具链。
- 第三方代码默认隔离,能力需声明并由用户逐个插件授权。
- 已安装插件不拖慢启动:没有被触发的插件不实例化 Wasm。
- 本地和远程项目使用同一套宿主,从 P3 起每阶段都在远程项目上验收。
- 用 10 个官方插件验证设计,只走公开 SDK。
### 非目标
- 不把第一方 feature 改造成插件。它们和插件共用注册表即可。
- 不兼容 VS Code 插件。
- API 1.0 不支持插件间的代码依赖,只能通过命令 id 协作。
- 不提供服务端市场,先用静态索引。
## 2. 现状
### 已有,可直接复用
| 能力 | 位置 | 本计划中的用途 |
| ----------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| 项目信任 | `crates/ide-host/src/trust.rs`;`create_host` 通过 `set_project_policy` 接入,未受信项目走 `restrict_project` | 项目插件加载的前置门槛 |
| when 上下文求值 | `src/platform/context.ts` | 菜单、键位、状态栏的 `when` 条件,不另造表达式语言 |
| 后台符号索引 | `crates/ide-index` | `index` 接口在其上开放,不从零设计 |
| epoch + fuel 限额 | `crates/ide-plugin/src/runtime.rs` | 保留作为兜底,调用模型改为 async(见 4.5) |
| 性能预算 | `perf/budgets.json` | 新增插件相关指标 |
### 问题
| 问题 | 证据 | 后果 |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| 贡献类型写死 | 新增一类要改 `Contributions`、`Registrations`、`PluginInfo`、`CatalogPlugin`、`static_contributions`、`files()`、`bridge.ts` | 扩展成本线性增长 |
| 内部扩展点没开放 | 前端有 48 个 `register*` 函数,插件只能贡献命令、语言、主题 | 插件做不出实质功能 |
| 插件语言是半成品 | `PluginHost::languages()` 只被 `commands/app.rs` 的 `languages` 命令使用,没有进入 `LanguageRegistry`;语法树只能来自内置 grammar | `plugins/README.md` 写的 `language-servers`、`debugger` 实际不生效 |
| 只有拉模型 | guest 只导出 `activate`、`deactivate`、`run-command`,同步调用,单次上限 5s,超时即 trap | 无事件、无 Provider、无长任务、无取消 |
| 启动全量激活 | `load_all` 逐个编译并激活,没有预编译缓存 | 插件越多启动越慢 |
| 信任只有项目一级 | 受信项目里 `.openidea/plugins/*` 全部自动加载,无逐插件确认;首次迁移时最近项目被整体标为受信;已安装插件声明即授权 | 开放进程或网络后,打开一个受信仓库就会执行其中任意插件 |
| 宿主位置不对 | `PluginHost` 在 `src-tauri`,`ide-host` 和 `openidea-server` 都没有 | 远程项目无法在项目所在机器运行插件 |
| 安装非原子 | `lib.rs` 安装时先 `remove_dir_all` 旧目录再 `rename` | 中途失败插件丢失;无版本回滚 |
| 分发不完整 | 无打包格式、签名、市场 | 无法形成生态 |
| 编辑无版本校验 | `insert-text`、`replace-selection` 是发完不管的事件 | 存在竞态,插件也拿不到结果 |
## 3. 目标架构
```text
主 WebView(只跑第一方代码)
贡献投影层:已校验的声明 → 现有 register*(不会失败)
声明式视图渲染器(src/ui)| 沙箱 iframe(逃生口)
│ 快照/增量 + 视图树 ▲ 用户事件
────────┼────────────────────────────┼────────── 本地 IPC 或远程协议
ide-host(本地与 openidea-server 共用,运行在项目所在机器)
ExtensionRegistry:扩展点 + 按插件记账
CapabilityBroker:授权账本 + 项目信任
ActivationManager:激活事件 + 空闲回收
WasmRuntime:每插件一个 actor,async 调用 + 分级预算
│ 只通过能力接口
project / editor / syntax / index / lsp / net / process / storage
│ 需要本机的能力(secrets、通知、剪贴板)
本地代理(客户端侧)
```
依赖方向:`ide-core` ← `ide-extension`(manifest、注册表、记账、能力模型,不依赖 wasmtime)←
各领域 crate(实现扩展点)与 `ide-plugin`(Wasm 运行时)← `ide-host`(组装)。
### 放置规则
现在就定下来,不等到远程阶段:
1. **Wasm 一律运行在项目所在机器**(本地项目在本机,远程项目在 `openidea-server`)。
2. **UI 只做声明投影**:插件不直接接触 WebView,视图树和用户事件通过协议往返。
3. **`Placement` 属于扩展点,不属于插件**:`Ui` 类声明(菜单、键位、主题、图标)投影到客户端;
`Workspace` 类声明(语言、LSP、运行配置)在宿主侧生效。
4. **只有本机才有的能力走本地代理**:`secrets` 存在客户端的系统钥匙串,宿主按需转发;
`net` 从项目所在机器发出(与 LSP 一致),在授权页上注明。
5. 编辑路径 Provider 在远程模式下的预算另计网络往返,单独设指标。
## 4. 关键设计
### 4.1 扩展点
```rust
pub trait ExtensionPoint: Send + Sync {
fn id(&self) -> &'static str; // "languages"
fn placement(&self) -> Placement; // Ui | Workspace
/// 只解析和校验,无副作用;同时算出这条声明需要的能力。
/// Ui 类扩展点也在这里做完全部校验(JSON schema),前端投影不允许失败。
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String>;
}
pub struct Prepared {
pub capabilities: Vec<Capability>, // 带 LSP 的语言 → process:<binary>
pub commit: Box<dyn FnOnce() -> Result<Disposable, String> + Send>,
}
```
- 原子生效:全部 `prepare` 成功才开始 `commit`;任一 `commit` 失败,把已 commit 的 `Disposable` 逆序撤销。
- 前后端一致:所有校验都在 Rust 侧完成,前端只做投影。投影抛错时记为缺陷,不做部分生效。
- 干净卸载:贡献记在所属插件名下,禁用或卸载时统一 dispose。
- 前向兼容:不认识的 `contributes.<point>` 告警并跳过,不让整包失败。
- 前端投影:`defineUiPoint("menus", decl => registerMenuGroup(...))`,复用现有注册函数;
`when` 条件交给 `src/platform/context.ts` 求值。
- `LanguageRegistry` 改为“内置 + 插件覆盖层”的可变结构;内置 `languages/*.toml` 走同一扩展点。
### 4.2 能力分级
按实现形式分:
| 级别 | 形式 | 用途 |
| ----------- | --------------------------- | --------------------------------------------------------- |
| L0 声明 | 纯数据 | 主题、图标主题、语言、片段、键位、模板、设置 schema、i18n |
| L1 逻辑 | Wasm 组件 | 命令、Provider、事件订阅 |
| L2 视图 | 声明式视图树;必要时 iframe | 工具窗、面板、自定义编辑器 |
| L3 外部进程 | LSP、DAP、CLI | 语言工具链 |
按风险分(决定授权页怎么展示):
| 风险 | 能力 | 授权方式 |
| ---- | -------------------------------------------------------------------- | -------------------------------------------- |
| 低 | `project.read`、`editor.read`、`storage`、事件订阅(不含按键) | 安装页列出,一次确认 |
| 中 | `editor.write`、`vcs.write`、`net:<host>`、`secrets`、按键与输入事件 | 安装页单独列出并说明用途 |
| 高 | `process:<binary>`、`fs.read:<项目外路径>` | 单独弹窗,明确提示“等同于在本机执行任意代码” |
`process:<binary>` 说明:
- 它是完全的代码执行权限,参数无法有效限制(例如 `git -c core.sshCommand=...`),不把它包装成细粒度权限。
- 二进制在授权时按绝对路径解析并写入账本;运行时路径或文件哈希变化要重新确认。不在运行时按 PATH 查找,
防止仓库在 PATH 前面放同名程序冒充。
- 声明也算能力:带 `language-servers.command` 的语言包同样需要 `process:` 授权,否则语言注册修好后就成了沙箱绕过口。
`net:<host>` 由宿主的 HTTP 客户端实现,每一跳重定向都校验主机;不向插件暴露原始 socket。
默认用声明式视图树(tree、list、form、toolbar),由 `src/ui` 渲染,自动跟随主题和无障碍规范。
iframe 只用于富内容:严格 CSP、仅 `postMessage`、不暴露 Tauri API。第三方代码不进主 WebView。
### 4.3 WIT API
开发期版本号用 `openidea:[email protected]`,**P8 远程验收通过后才定为 1.0**(已于 P8 冻结为 `1.0.0`)。
视图和远程两块最可能逼出破坏性改动,提前承诺 1.0 没有意义。
目前只有样例和主题包,0.1 不写适配层;0.2.x 期间允许破坏性修改;1.0 之后 minor 只做增量,major 由宿主保留旧版适配。
```wit
package openidea:plugin@0.2.0;
interface workspace { resource project { root: func() -> string;
read-file: func(path: string) -> result<string, error>; } }
interface editor { resource text-editor {
apply-edits: func(version: u64, edits: list<text-edit>) -> result<_, error>; } }
interface syntax { resource tree { node-at, query, errors } parse: func(path) -> result<tree, error>; }
interface index { find-symbols, symbols-named, find-files, lookup /* 插件自定义索引 */ }
interface events { subscribe: func(kind: event-kind) -> subscription; }
interface tasks { resource task { report: func(done: u32, total: option<u32>);
is-cancelled: func() -> bool;
cancelled: func() -> pollable; } }
// 另有 ui、vcs、net、process、storage、secrets
interface commands { run: func(id: string, ctx: borrow<invocation>, arg: option<string>)
-> result<option<string>, error>; }
// 可选导出:code-actions、completion、inline-completion、run-detector、view-provider、
// syntax-provider、indexer …
```
`syntax`、`index`、`tasks` 的定稿与理由见 `docs/plugin_p0.md` 第 3、4 节。
- 窗口和项目作为句柄传入,去掉隐式的 `Invocation` 调用态。
- P5 实施的偏差:`events.subscribe` 不返回 `subscription` 资源,订阅记在宿主、会话内有效,因为实例会被
空闲回收而订阅要继续生效(`on-event:<kind>` 声明同样效果);`tasks.task` 暂无 `cancelled() -> pollable`,
调用是同步的线程 actor,`is-cancelled` 轮询即可,等 P6 流式接口一起加;guest 侧语法提供者用只导出
`syntax-provider` 的 `syntax` world 与 `plugin` 合并(wit-bindgen 对同一导出接口会生成重名宏)。
- P6 实施的偏差:Provider 不靠探测可选导出,而由 manifest `[[contributes.providers]]` 声明种类和语言,
宿主只调用声明匹配的插件(未匹配的不加载);导出放在 `providers` 接口、`plugin-providers` world。
`net.fetch` 一次返回完整响应(≤ 8 MiB),没有流式 body;内联补全的取消由宿主中断调用完成
(`call_cancellable`,宽限 250ms),仍不需要 `pollable`。密钥不经 guest:请求头写 `{{secret:<name>}}`,
宿主发出前替换(这就是“本地代理”的做法);远程项目里由服务器按需向桌面端请求。
- 权限失败返回 `error::denied`,不再 trap,插件可以降级运行。
- 安装时读取组件 import 列表并与 manifest 比对,使用了未声明接口的拒绝安装。
- 编辑带文档版本号,版本不符返回 `error::stale`,由插件决定重试。
- 坐标继续遵守 0 基行号和 UTF-16 列。
### 4.4 Manifest
```toml
id = "acme.rust-tools" # 带发布者命名空间
version = "1.2.0"
engines.openidea = "^0.2"
wasm = "plugin.wasm"
activation = ["on-language:rust", "on-command:acme.check"]
[capabilities]
required = ["project.read", "editor.write"]
optional = ["net:crates.io", "process:cargo"]
[[contributes.commands]]
id = "check"
title = "%check.title%"
[[contributes.menus]]
menu = "editor.context"
command = "check"
when = "editorLang == rust" # 由 src/platform/context.ts 求值
[[contributes.languages]]
path = "languages/rust.toml"
[contributes.settings]
schema = "settings.schema.json" # 自动生成设置页并接入设置搜索
[[contributes.views]]
id = "crates"
location = "toolWindow.right"
```
菜单、键位、状态栏只靠声明显示,用户首次触发时才激活插件。
激活事件:`on-command`、`on-language`、`on-file`、`on-view`、`on-schedule`、`on-startup-finished`。
`optional` 能力在用户拒绝后插件照常加载,调用时返回 `error::denied`。
### 4.5 运行时
- 启动只读缓存的贡献索引,不实例化 Wasm。
- 用 `Component::serialize` 把编译结果落盘,以 wasm 哈希加引擎版本作为键;引擎升级后后台重新编译。
- ~~调用改为 async~~(P4 实施时改为线程 actor,见下一条):async 方案要把宿主绑定全部改成 async,
而一个实例同一时刻只执行一个调用,换来的只是少几个线程。
- 每个活动插件一个 actor 线程独占 `Store`,调用方把任务投进邮箱、在回复通道上等待,不再跨调用持锁;
软超时由调用方停止等待实现,硬超时和取消由 store 的 `epoch_deadline_callback`(每个 epoch tick 检查
截止时间和取消标志)中断。fuel 保留作为死循环兜底。空闲回收后线程随之结束。
- 超时与实例复用(P0 已验证):丢弃 future 后实例被 wasmtime 标记为不可再进入,必须重建;从 `InstancePre`
重建约 50µs。编辑路径 Provider 用“软超时丢结果、硬超时才重建”的两段式,目的是保留实例状态;
用户主动取消优先走协作式取消(`task.is-cancelled` / `cancelled()`),宽限期后再硬取消。
- 分级预算:编辑路径 Provider 软超时 50ms(丢弃结果),硬超时 500ms(重建实例);命令 5s;
后台任务时限更长,但必须上报进度。
- 故障恢复:指数退避重启,连续失败才熔断,不再一次 trap 就停用整个会话。
- 空闲后销毁实例,持久状态通过 `storage` 接口保存;`on-schedule` 到点时重新激活,不阻止回收。
- 每插件统计激活耗时、调用延迟、错误数,在设置页显示,并写入 `perf/budgets.json`。
性能预算(P4 已用 release 实测确认,数字见 P4 完成情况):
| 指标 | 初值 |
| ------------------------------------------- | ------ |
| 安装 50 个插件时 `startup.readyMs` 增量 | ≤ 20ms |
| 启动阶段实例化的 Wasm 数 | 0 |
| 有预编译缓存时单插件冷激活 | ≤ 30ms |
| 编辑路径 Provider p95(本地) | ≤ 50ms |
| 编辑路径 Provider p95(远程,扣除网络 RTT) | ≤ 50ms |
| 已回收插件的常驻内存 | 0 实例 |
### 4.6 信任与授权
- 授权账本:记录插件 id、版本、包哈希、已授权能力,以及高风险能力对应的二进制绝对路径和文件哈希。
- 安装时展示能力清单,确认结果写入账本;更新后能力有增加时要重新确认,能力不变时静默升级。
- 项目插件 `.openidea/plugins/*`:项目受信只是前提,每个插件还要单独确认后才加载。
首次迁移标为受信的项目也遵守这一条。
- 开发插件:“链接开发插件”按**目录路径**授权,不按包哈希授权,避免每次构建都弹窗;能力有增加时仍然重新确认。
- 已安装插件“声明即授权”的旧行为,在账本上线时一次性迁移:已有插件只自动授予低风险能力,其余首次使用时再确认。
### 4.7 分发
- 包格式:单文件 zip,内含 manifest、wasm、资源、签名。
- 签名:Ed25519。信任根是官方索引里登记的发布者公钥;发布者的 id 前缀(`acme.`)与公钥绑定;
发布者自行轮换密钥,旧密钥签发的版本继续有效,直到被吊销。
- 未签名包:只能通过“从文件安装”或“链接开发插件”安装,安装页显示明确警告。
- 吊销:静态索引附带吊销和下架列表,宿主每天检查一次;命中的插件禁用并通知用户。
- 目录:`plugins/<id>/<version>/` 加 active 指针,支持原子升级和回滚;数据放 `data/<id>/`。
- 市场:静态索引(Git 仓库 + JSON + CDN)。
### 4.8 SDK 与工具
“只走公开 SDK”是验收前提,所以 SDK 本身就是交付物:
- `openidea-plugin` Rust crate:封装 wit-bindgen 生成的绑定,提供命令、Provider、视图的辅助类型。
- 脚手架:`new`(模板)、`build`(组件化并校验 import 与 manifest 一致)、`link`(链接开发插件)、`pack`(打包与签名)。
- mock host:插件作者在 `cargo test` 里不启动 IDE 就能测命令和 Provider。
- 官方插件全部使用这套工具构建,宿主测试也通过 mock host 覆盖。
### 4.9 否决的方案
| 方案 | 否决原因 |
| ------------------------------------ | -------------------------------------------------- |
| JS 扩展宿主进程 | 没有真正的沙箱,要多维护一个 Node 进程,API 面巨大 |
| 第三方 JS 直接进主 WebView | 能调用全部 Tauri IPC,完全绕过权限 |
| Rust `dylib` 原生插件 | ABI 不稳定,没有隔离 |
| 第一方 feature 全部插件化 | 大量重写,换不来用户价值 |
| 通用 JSON-RPC `handle(method, json)` | 丢掉类型安全和 WIT 契约 |
| 一开始就定为 API 1.0 | 视图和远程没验证,大概率要破坏性修改 |
| `process:` 运行时按 PATH 解析 | 仓库可以在 PATH 前面放同名程序冒充 |
代价:Wasm 生态较小。可提供 TS SDK(`jco componentize`)缓解,但单包体积会明显增大。
## 5. 实施阶段
每阶段都可以单独发布。从 P3 起,每阶段的验收同时在本地和远程项目上运行。
### P0 验证
- [x] 验证 wasmtime 49 能否按接口绑定可选导出;不行就退回“基础 world + 多个组合 world”(可以,按接口绑定)
- [x] 验证 async 调用:epoch 让出、丢弃 future 取消、**取消或超时后实例能否复用**(不能复用,重建约 50µs)
- [x] 验证 WASI p2 上的流式和任务模型(`pollable` + `task` 资源),API 不依赖 p3
- [x] Solidity、MyBatis Navigator 最小原型,基于 `ide-syntax` 和 `ide-index` 定稿 `syntax`、`index` 接口
- [x] 修正 `plugins/README.md` 中不生效的语言能力说明
结论见 `docs/plugin_p0.md`。
验收:四个验证项都有书面结论;结论推翻了第 4 节设计的,先改文档再进入 P1。
### P1 内核
- [x] 新增 `ide-extension`:manifest、`ExtensionPoint`、注册表、记账、commit 失败回滚
- [x] 迁移命令、主题、语言到扩展点
- [x] 前端贡献投影层,`when` 接入 `src/platform/context.ts`
- [x] 宿主移入 `ide-host`,`src-tauri` 只保留适配
- [x] 安装改为先写临时目录、再原子替换,不先删旧版
- [x] SDK crate、`new`/`build`/`link` 脚手架、mock host
- [x] 官方插件:Material Icons
验收:现有 `crates/ide-plugin/tests/host.rs` 场景全部通过;新增 `iconThemes` 不改内核;
Material Icons 用 SDK 脚手架构建。
完成情况:`host.rs` 迁为 `crates/ide-host/tests/plugins.rs`,原场景全部保留并通过,`hello` 改用 SDK
重新构建;`iconThemes` 只在 `ide-host/src/plugins/{icons,points}.rs` 注册,`ide-extension` 未改。
SDK 在 `sdk/rust/openidea-plugin`(`Plugin` trait、`register_plugin!`、原生目标上的 `mock::MockHost`);
脚手架是 `ide-host` 的第二个二进制 `openidea-plugin`(`new --template command|theme|icon-theme|language`、
`build`、`check`、`link`/`unlink`/`links`),`check` 走与安装相同的校验并拒绝导入 WIT 以外接口的组件。
Material Icons 在 `plugins/official/material-icons`,由 `new --template icon-theme` 生成,
106 个原创图标,文件夹图标同样生效(有角色的目录保留内置图标)。规模测试:3000 个 SVG 的主题
安装约 1.2s、启动加载约 0.28s(release;debug 为 1.7s / 0.6s),启动时逐个重新净化,P4 的贡献索引缓存应消除这部分。
### P2 授权
- [x] 授权账本、安装确认页、能力变更时重新确认
- [x] 项目插件逐个确认;开发插件按路径授权
- [x] 已安装插件的授权迁移
- [x] 风险分级展示;`process:` 绝对路径解析和哈希校验
验收:打开受信仓库时,未确认的项目插件一律不执行;能力增加时要求重新确认;
替换授权过的二进制后,调用被拒绝并提示重新确认。
完成情况:账本在 `ide-extension/src/ledger.rs`(`grants.json`,按已安装 id 或目录路径记账);三条验收
分别由 `plugins.rs` 的 `untrusted_or_unconfirmed_project_plugins_never_run`、
`new_capabilities_require_consent_again`、`replaced_binaries_are_refused` 覆盖。0.1 授权只自动迁移低风险能力,
其余在首次调用时弹出确认(`ConsentRequested`)。语言声明推导出的 `process:` 视为可选,拒绝后语言照常可用。
### P3 语言全链路
- [x] `LanguageRegistry` 覆盖层,插件语言进入语言检测和 LSP
- [x] LSP、DAP 声明,接入 `process:` 授权
- [x] 插件提供语法树
- [x] 开放 `ide-run` 的运行配置检测
- [x] 官方插件:Solidity
验收:纯声明插件能启动语言服务器,本地和远程都可用;拒绝 `process:` 后语言高亮照常、LSP 不启动;
把一个内置语言定义迁成插件,跑通 DAP。
完成情况:
- **覆盖层**:`LanguageRegistry::attach_plugins` 挂上 `PluginLanguages`,按 revision 惰性重建,项目 fork 自动跟随;
内置和用户定义在 id、扩展名、文件名上都优先,插件不能抢占。插件语言变化时发 `LanguagesChanged`,
宿主重新检测已打开文档、重配 LSP,前端刷新语言表。
- **`process:` 授权**:服务器和调试器只在授权后出现在有效配置里,并带 `ProgramPin`(授权时的绝对路径
- blake3);`ide-lsp`、`ide-dap` 启动前校验,文件被替换即拒绝(`PermissionDenied`)。用户在
Settings › Language Servers 自己改了命令则不再受插件授权约束。
- **远程**:客户端把插件语言和已授权程序名经 `plugin_sync_languages` 推给 `openidea-server`,服务器在本机
`PATH` 上解析并钉住(`plugin-pins.json`),撤销授权即删除钉住;连接建立和语言/授权变化时同步。
- **语法树**:WIT 新增可选导出 `syntax-provider`(`plugin-syntax` world,扁平 token/symbol/fold,UTF-8 字节偏移);
运行时按接口绑定,旧组件不受影响;`ide-language` 的 `ExternalSyntax` 在没有内置 grammar 时接管,
结果按文档版本缓存;SDK 提供 `SyntaxProvider` 和 `register_plugin!(T, syntax)`。远程项目暂不运行插件 Wasm(P4/P5)。
- **运行配置**:`ide-run` 的 `ExternalDetection` 钩子 + `runDetectors` 扩展点(标记文件 → 命令),
命令同样是 `process:` 能力,启动前校验钉住的程序。
- **Solidity**:`plugins/official/solidity`,SDK 构建,手写词法器提供高亮、结构视图、折叠;声明
Nomic Foundation 语言服务器和 `solc --lsp`(回退),Foundry / Hardhat 运行配置。
- **DAP 迁移**:`ide-dap` 测试把内置 `gdscript.toml` 作为插件语言加载(无内置定义),经钉住的程序跑通调试会话,
替换程序后拒绝启动。
### P4 运行时
- [x] 贡献索引与惰性激活
- [x] 预编译缓存
- [x] ~~async 调用~~线程 actor、分级预算、熔断、空闲回收
- [x] 每插件统计;4.5 节的预算写入 `perf/budgets.json`
验收:安装 50 个插件时达到 4.5 节预算,启动不实例化 Wasm。
完成情况:
- **贡献索引**:`ide-extension::ContributionIndex`,已安装副本(有 `.package-hash`)的 UI 声明校验结果按
包哈希 + 扩展点 + 声明 + IDE 版本落盘(`plugins/.cache/contributions`),启动直接复用,不再逐个净化 SVG;
链接和项目插件不进索引。后台预热时清理不再使用的条目。
- **预编译缓存**:`PluginEngine::compile_cached`,键为模块 blake3 + `precompile_compatibility_hash`
(`plugins/.cache/components`),安装校验时顺带写入,启动完成后后台补齐并清理。
- **运行时**:`ide-plugin::Actor`(见 4.5),`Limits` 增加 `edit_soft`/`edit_hard`/`restart_backoff`/
`max_failures`/`idle_timeout`。trap 后丢弃实例,下次使用时按 1s、2s… 退避重建,连续 3 次才标记 Failed;
正常返回即清零。用户取消(`PluginHost::cancel`)不计入熔断。`PluginHost::tick`(桌面端每 5s)
负责回收空闲 5 分钟的实例、上报无人等待的崩溃、触发到期的 `on-schedule`(激活后照样会被回收)。
“从 InstancePre 重建”改为从预编译缓存重新实例化,冷激活同样很便宜,回收后不必常驻已编译组件。
- **统计**:激活次数与耗时、调用数、错误数、最慢与近 128 次 p95、崩溃、编辑路径超时、回收次数,
设置页显示。`crates/ide-host/benches/plugins.rs` 测 50 个插件启动加载、冷激活、编辑路径调用,
预算在 `perf/budgets.json` › rust › `plugins/*`,`pnpm perf:rust` 检查。
- **实测**(2026-11-01,release bench profile,Apple Silicon,`cargo bench -p ide-host --bench plugins`,
criterion 均值及 95% 区间):
| bench id | 实测 | 预算 (`perf/budgets.json`) | 占比 |
| ---------------------------------------------- | ---------------------- | -------------------------- | ---- |
| `plugins/load_50`(50 插件建宿主 + 加载) | 16.93ms(16.51–17.49) | 20ms | 85% |
| `plugins/cold_activation`(预编译,激活+调用) | 0.313ms(0.293–0.337) | 30ms | 1% |
| `plugins/syntax_highlights_2k_lines` | 1.69ms(1.67–1.73) | 50ms | 3% |
50 插件为 20×hello、10×solidity、10×material-icons、10×纯声明插件;启动不实例化 Wasm 由 bench 内断言保证。
三项均在预算内,验收通过。预算值不收紧:`load_50` 已占 85%,另两项是面向用户的体验上限而非回归门限,
余量留给更慢的机器和远程场景。`load_50` 量的是插件宿主加载耗时,作为 `startup.readyMs` 增量的上界近似;
语法高亮测的是整篇 2k 行单次调用,比编辑路径的单次 Provider 调用更重。
### P5 事件与任务
- [x] WIT 拆分、资源句柄、事件、任务、存储、VCS
- [x] 带版本校验的编辑接口
- [x] `hello` 迁移到 0.2 API
- [x] 官方插件:String Tools、Key Promoter、Git Toolbox
验收:三个插件只用公开 SDK 完成;Git Toolbox 的定时 fetch 在空闲回收后仍能按时触发。
完成情况:
- **WIT 0.2**(`wit/`,按接口分文件):`types`(`error` 变体)、`log`、`contributions`、`ui`(通知、状态栏项)、
`storage`、`workspace`(`project` 资源、`projects()`)、`editor`(`text-editor` 资源)、`invocation`、`vcs`、
`events`、`tasks`;导出 `lifecycle`、`commands`(`run(id, borrow<invocation>, arg)`)、`event-handler`,
可选 `syntax-provider`。缺能力一律返回 `error::denied`,不再 trap。0.1 组件不再运行(无适配层,见 4.3)。
- **编辑**:`apply-edits(version, edits)` 在宿主经 `Documents::apply_edits_at` 于文档锁内比对版本,
不符返回 `stale`;成功即一次 `DocPatched`,编辑器里是一步撤销。命令调用时前端带上焦点编辑器的路径、
语言和选区(UTF-16 列)。
- **事件**:前端按插件关心的种类(`plugin_event_kinds`)上报打开/保存文件、焦点编辑器、动作(带来源和快捷键)、
仓库变化;宿主按插件和窗口排队,运行中的插件即时投递,空闲插件攒 2s 再唤醒。订阅经 `subscribe` 或
manifest `on-event:<kind>`,整个会话有效、跨空闲回收。`on-schedule` 到点投递 `scheduled`。
- **任务**:`tasks.begin` 进入前端后台任务列表(可取消,取消同时取消宿主侧的 fetch),打开任务的调用改用
60s 后台预算;任务不跨调用。
- **存储**:`plugins/.data/<id>/storage.json`,1 MiB,卸载时删除。**VCS**:`ide-vcs` 读状态,fetch 仅在受信项目。
- **SDK**:`Invocation`/`Project`/`TextEditor`/`Task` 句柄、`transform_selections`、`text` 坐标工具;
mock host 有带版本的编辑器、存储、订阅、任务、VCS。脚手架模板随之更新。
- **官方插件**(`plugins/official/`):String Tools 26 条命令分 4 组、多光标一步撤销;Key Promoter 由
`on-event:action-performed` 激活、计数存 storage;Git Toolbox `on-schedule:300` 抓取所有项目、状态栏显示
分支和 ↑↓。宿主端到端测试 `official_*`,验收由 `official_git_toolbox_fetches_on_schedule_after_idle_reclaim`
(本地 bare 远端、回收后 `tick` 到点、状态栏变为 `main ↓1`)覆盖。
### P6 Provider 与网络
- [x] 通用 Provider:诊断、快速修复、补全、内联补全、导航、行标记
- [x] 网络、密钥(经本地代理)、一次性进程
- [x] 官方插件:Checkstyle、AI Completion、MyBatis Navigator
验收:三个插件只用公开 SDK 完成;AI Completion 在连续输入时旧请求能被取消,达到编辑路径预算;
Checkstyle 在远程项目上读到的是远端文件。
完成情况:
- **WIT**:新增 `io.wit`(`diagnostics.publish`、`net.fetch`、`secrets.is-set`、`process.run`)、
`providers.wit`(`completion`、`inline-completion`、`code-actions`、`definition`、`line-markers`),
`workspace.project.find-files`(`ignore` + glob,遵守 `.gitignore`)。偏差见 4.3。
- **Provider**:manifest `[[contributes.providers]] kinds/languages` 声明;宿主 `provide_*` 分发,
结果并入现有 LSP 操作(补全列表、⌥⏎ 快速修复、⌘B、内联补全),新增 `plugin_line_markers` 操作和编辑器
行标记栏(有标记时才出现)。编辑路径走 50ms 软 / 500ms 硬预算;内联补全不受 50ms 限制,但请求被取代即中断。
- **网络**:共享 reqwest 客户端;仅 https(`localhost` 可 http)、按 `net:<host>` 授权、拒绝跨主机重定向、
8 MiB 上限、随调用取消。**密钥**:`[[contributes.secrets]]` 在 设置 › 插件 中录入,存 macOS 钥匙串
(其他平台为 0600 文件);guest 读不到值。**进程**:只运行 `process:` 授权时固定的程序(路径 + 哈希),
工作目录为受信项目根,4 MiB 输出,取消或超时即杀。**诊断**:按插件和文件替换,进 Problems 视图。
- **远程**(放置规则 1、4):`openidea-server` 内置插件宿主(`plugins::remote_host`)。桌面端在连接时和插件
变化后(800ms 防抖)同步已启用的代码插件:先发摘要和授权,再只上传服务器缺少的包;服务器按桌面端授权
记录,`process:` 在服务器 `PATH` 上解析并固定。窗口的运行时命令(运行命令、事件、状态栏项、任务、行标记)
与桌面命令同名,远程窗口自动路由到服务器;插件事件以 `plugin` 通知回到该窗口;密钥由服务器以
`plugin_secret` 请求向桌面端按需索取。项目插件(`.openidea/plugins`)暂不在远程运行,服务器侧 storage
与桌面端不同步。
- **官方插件**:AI Completion(Ollama / OpenAI,密钥只发往 api.openai.com);Checkstyle(保存时检查,
新保存取消旧任务,三种快速修复,项目级配置 `checkstyle.xml` / `config/checkstyle/checkstyle.xml` 优先);
MyBatis Navigator(接口 ↔ XML 跳转、行标记、XML 中 statement id 补全)。
- **验收**:`official_ai_completion_cancels_superseded_requests`(连续输入时旧请求在宽限内被中断,结果在编辑
路径预算内);`official_checkstyle_checks_on_save`;`official_mybatis_navigates_between_interface_and_xml`
(p95 远低于 50ms);远程验收由 `remote_projects_run_the_desktops_code_plugins_on_the_server` 覆盖:
经远程协议同步 Checkstyle 与 AI Completion,保存只存在于服务器上的文件,Checkstyle 在服务器上运行并读到该
文件,诊断经协议回到客户端;密钥向客户端索取;桌面端停用后服务器卸载。
### P7 UI 贡献
- [x] 菜单、键位、状态栏、设置 schema
- [x] 声明式视图、自定义编辑器
- [x] 最后做 iframe
- [x] 官方插件:Kubernetes、CSV Editor
验收:示例插件能做出带树视图的工具窗;CSV Editor 与文本视图双向同步并共享撤销;
两个插件在远程项目上都可用。
完成情况:
- **L0 声明**(`plugins::ui_points`,全部在 Rust 侧校验,前端只投影):`menus`(编辑器、项目视图、编辑器标签
的上下文菜单,以及原生 Tools 菜单的动态槽 `pluginTools`)、`keymaps`(插件默认键位排在预设和用户覆盖之下,
已被占用的按键丢弃;必须带 Ctrl/Alt/Meta 或功能键)、`statusBar`(声明即显示,代码用同 id 的
`set-status-item` 更新)、`settings`(JSON schema 子集:boolean/string/number/integer、enum、默认值;生成
设置页、进设置搜索、项目可覆盖;值存在 `Settings.plugins[<id>]`,插件经 `settings.get` 读取,类型不符回落到默认值)。
菜单、键位、状态栏只能引用本插件声明过的命令。
- **L2 视图**(`wit/views.wit`):视图是数据——工具栏、body(tree、list、form、table、message、html)、状态行;
可选导出 `view-provider.render/handle`,用户操作以 `view-event` 回传,代码用 `views.refresh` 请求重绘。
WIT 不能递归,树是带 `parent` 的扁平列表,`expandable` 的节点展开时才取子节点;表格只取可见行(`viewport`),
大文件不整体传输。`views` 成为工具窗,显示即激活(`on-view`)。
- **自定义编辑器**:`customEditors` 按文件名模式接管打开(`option` 只出现在 Open With);编辑器标签挂到该文件的
共享文档上(`attachDocument`),插件经带版本的 `apply-edits` 改文档,所以文本标签同步显示、撤销是同一条
历史(有文本标签时记在它那里,否则记在自定义编辑器自己的历史),保存、脏标记、关闭提示都走文档;
“Open as Text” 打开文本编辑器。
- **iframe**:`html` body 放进 `sandbox="allow-scripts"`(不透明源)的 `srcdoc`,CSP 只允许内联脚本、样式和
`data:` 图片,无网络、无 IDE API;只经 `openidea.postMessage/onMessage` 与插件交换消息(`view-event::message`、
`views.post-message`)。边界:声明式 body 能表达的一律用声明式,html 只用于富内容。
- **终端**:`views.open-terminal(title, program, args)` 在调用窗口打开终端标签运行 `process:` 固定的程序,
长连接流(`kubectl logs -f`)和交互会话(`kubectl exec -it`)都走它,不另加流式进程接口。
- **远程**:`plugin_view_render`、`plugin_view_event` 与桌面命令同名,远程窗口路由到服务器;`viewChanged`、
`viewMessage`、`openTerminal` 经 `plugin` 通知回到窗口,终端在服务器上运行。
- **官方插件**:CSV Editor(RFC 4180、引号与换行、短记录补齐、插入/删除行、表头与分隔符设置、按版本缓存解析);
Kubernetes(命名空间 → Pods/Deployments/Services 逐级加载、状态摘要、日志和 shell 在终端、context/
命名空间/日志行数/shell 设置、Tools 菜单刷新)。`hello` 增加带树视图的工具窗和菜单、键位、状态栏声明。
- **验收**:`sample_plugin_tool_window_with_a_tree`(声明在代码加载前就列出,显示即激活,展开触发 refresh,
停用后声明全部撤销);`official_csv_editor_edits_the_shared_document`;`attachment.test.ts` 的共享撤销用例
(无文本标签与有文本标签两种情况);`official_kubernetes_browses_the_cluster_and_opens_logs`;远程验收
`remote_projects_get_plugin_views_and_custom_editors`(表格编辑的是服务器上的文档,集群用服务器的 kubectl,
日志终端开在远程窗口)。
### P8 分发与 1.0
- [x] 包格式、签名、发布者公钥登记、吊销列表
- [x] 版本目录、原子升级、回滚
- [x] 静态市场索引与市场页
- [x] 冻结 WIT,发布 `openidea:[email protected]`
验收:升级失败能自动回滚;命中吊销列表的插件被禁用;10 个官方插件只改 `engines.openidea` 和 WIT 包版本即可切到 1.0。
完成情况:
- **包与签名**(`ide_extension::package`):`<id>-<version>.oiplugin` 是 zip(manifest、声明引用的文件、组件、
许可证文件、可选的 `SIGNATURE.json`);解包拒绝越界路径、链接和超限内容。签名是 Ed25519,签的是
`openidea-plugin/1\n<id>\n<version>\n<摘要>`,摘要覆盖 manifest 与包内文件。`openidea-plugin keygen`、
`pack --key --publisher`、`index`(由一批包生成索引,保留已有吊销和旧公钥)。
- **信任与吊销**:静态索引(`index.json`,格式 1)登记发布者及其公钥(信任根;发布者 `acme` 只能发布
`acme.*`)、插件条目(含摘要)和吊销(某些版本或全部版本;公钥可标为吊销)。签名不成立(被改过、
不是该发布者的公钥、越权前缀、公钥已吊销)的包拒绝安装;无签名包从磁盘安装时提示。宿主保存最近一次读到
的索引(`market-index.json`),启动时、每次刷新(桌面端每天一次)都重新评估:命中的插件停用并给出原因、
通知用户,`set_enabled` 无法绕过;解除吊销后自动恢复。
- **版本目录与回滚**(`plugins::install`):`<plugins>/<id>/<version>/` 加 `active`、`previous` 指针,新版本
写完后才原子替换指针;更新后**新版本代码首次启动失败**(实例化失败、`activate` 出错或 trap)时自动回滚到
被替换的版本、删掉失败的版本、恢复旧的授权记录并通知用户;加载即失败的更新在安装时就回滚。设置页可手动
“回滚到 …”。旧的平铺安装在下次更新时迁入版本目录;中断的迁移由 `recover` 复原。
- **市场页**:设置 › 插件 › Available 填入索引 URL(`https://…/index.json` 或本地路径)后列出市场插件,
安装/更新时下载、对照索引条目核对摘要和发布者签名,再走同一个授权安装页;已安装插件显示“由 … 签名 /
未签名”。没有配置市场时仍显示本机可用的包。
- **冻结 WIT 1.0**:包名改为 `openidea:[email protected]`,宿主 `ENGINE_VERSION`/`API_VERSION` 为 1.0;0.x 组件
不再运行(与 4.3 的约定一致:1.0 之后 minor 只做增量,major 由宿主保留旧版适配)。
- **验收**:`a_failing_update_rolls_back_automatically`(`2.0.0-broken` 首次启动失败 → 回到 1.0.0、失败版本
删除、用户收到通知、之后正常;成功的更新可手动回滚;重启后仍是生效版本);
`signed_marketplace_plugins_install_and_revocations_disable_them`(签名安装、篡改与越权签名被拒、版本吊销
与公钥吊销都会停用且重启后仍生效、解除后恢复);`installs_from_before_versioning_keep_working_and_move_on_update`;
10 个官方插件的源码未改,只把 `engines.openidea` 改为 `^1.0`、用 1.0 的 SDK 重新构建,`openidea-plugin
check` 全部通过,宿主与远程测试全部通过。
## 6. 官方验证插件
第二列是功能原型,不进入产品名、代码和提交信息;不移植原插件的代码。
| # | 官方插件 | 功能原型 | 级别 | 主要检验 | 阶段 |
| --- | ----------------- | ---------------------------------- | ------- | -------------------------------------------------------------------------------- | ---------------- |
| 1 | Material Icons | Atom Material Icons | L0 | 新扩展点只需一处注册;上千个 SVG 的净化、包体积、首屏加载;SDK 脚手架 | P1 |
| 2 | Solidity | Solidity | L0 + L3 | 声明式语言包走通 LSP;`process:` 授权;开放 `detect_*`;插件语法树 | P0 原型,P3 完成 |
| 3 | String Tools | String Manipulation | L1 | 大量命令声明和菜单分组;未激活也能显示;多光标编辑合并撤销;SDK 体验 | P5 |
| 4 | Key Promoter | Key Promoter X | L1 | 高频事件批量投递,避免频繁唤醒;按键事件属于中风险授权;持久化统计 | P5 |
| 5 | Git Toolbox | GitToolBox(不含内置已有的 blame) | L1 + UI | 定时 fetch 与空闲回收的配合;VCS 读写权限;状态栏刷新 | P5 |
| 6 | Checkstyle | CheckStyle-IDEA | L1 + L3 | 一次性进程;诊断和快速修复;保存时触发并取消旧任务;项目级设置;远程 | P6 |
| 7 | AI Completion | GitHub Copilot | L1 | 按键级延迟和取消;流式网络;密钥经本地代理;内联补全从 LSP 专用改为通用 Provider | P6 |
| 8 | MyBatis Navigator | MyBatisX | L1 | Mapper 与 XML 互跳、行标记、基于 `ide-index` 的跨文件索引、SQL 补全 | P0 原型,P6 完成 |
| 9 | Kubernetes | Kubernetes | L2 + L3 | 声明式树视图的能力上限;日志长连接流;`kubectl exec` 接内嵌终端;项目外访问授权 | P7 |
| 10 | CSV Editor | CSV Editor | L2 | 自定义编辑器双向同步、共享撤销、大文件;划定声明式视图与 iframe 的边界 | P7 |
候补:Grep Console(运行控制台过滤,首选)、Translation(简单网络场景)、PlantUML(预览 + 外部渲染)。
DAP 不在这 10 个之内:P3 把一个内置语言定义迁成插件来测。
### 验收规则
1. 只用公开 SDK:代码放在 `plugins/official/`,不依赖内部 crate,宿主里没有按插件 id 判断的逻辑。
2. 缺 API 就加通用扩展点,并记录有没有第二个使用者;只有一个使用者的 API 要重新审视。
3. 10 个全部安装后,启动不实例化 Wasm,指标符合 4.5 节预算。
4. 自动化测试覆盖禁用和卸载:菜单、视图、语言、进程都清理干净。
5. 每个插件都跑 panic、超时、拒绝授权三类测试:IDE 不受影响,插件能降级运行。
6. 从 P3 起,每个插件都在远程项目上跑一遍。
## 7. 风险与待定项
| 项 | 影响 | 处理 |
| ------------------------------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `syntax`、`index` 接口 | 决定语言类和框架类插件能否成立 | 已在 P0 定稿(`docs/plugin_p0.md` 第 4 节),P3、P6 实现时验证 |
| 取消或超时后实例能否复用 | 决定编辑路径 Provider 的成本 | P0 结论:不能复用,重建约 50µs;用两段式超时保留状态 |
| 可选导出的绑定方式 | 影响 Provider 的接入形式 | P0 结论:按接口 `GuestIndices` 绑定 |
| 流式和任务模型 | AI Completion、Kubernetes 依赖 | P0 结论:p2 资源 + `pollable` 可行 |
| 远程模式下编辑路径的延迟 | 补全类 Provider 在远程项目上可能不可用 | P4 起单独设远程指标;不达标时允许 Provider 声明“仅本地” |
| `on-schedule` 与空闲回收的关系 | Git Toolbox 依赖 | P4 实现,P5 用 Git Toolbox 验证 |
| 项目外文件访问:`fs.read:<path>` 还是只给 `process:<binary>` | 影响能力模型 | 已定(P7):Kubernetes 只要 `process:kubectl`,kubeconfig 由 kubectl 自己读,插件不读项目外文件;`fs.read` 仍保留为高风险能力,暂无使用者 |
| 发布者密钥丢失或泄露 | 影响已发布版本的可信度 | 吊销列表 + 发布者重新登记,P8 写成流程文档 |
| Wasm 单包体积(尤其 TS SDK) | 影响分发和内存 | P4 纳入性能预算观测 |
+1 -1
Переглянути файл
@@ -1,5 +1,5 @@
{
"$comment": "Upper bounds, lower is better. ui: `<scenario>.<metric>` from `pnpm perf` (medians of the measured runs; enforced at CPU 1x only). Latencies are input → start of the first frame showing the result, so ~16.7 ms is 'next frame'. rust: criterion bench id → mean in microseconds, from `pnpm perf:rust` (`plugins/…`: the runtime budgets of docs/plugin_plan.md 4.5).",
"$comment": "Upper bounds, lower is better. ui: `<scenario>.<metric>` from `pnpm perf` (medians of the measured runs; enforced at CPU 1x only). Latencies are input → start of the first frame showing the result, so ~16.7 ms is 'next frame'. rust: criterion bench id → mean in microseconds, from `pnpm perf:rust` (`plugins/…`: the plugin runtime budgets).",
"regressionTolerance": 0.2,
"regressionFloor": 1,
"ui": {
+109 -38
Переглянути файл
@@ -30,8 +30,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:[email protected]`, world
`plugin`); the design and roadmap are in
[`docs/plugin_plan.md`](../docs/plugin_plan.md).
`plugin`).
## 1. Quick start
@@ -207,13 +206,13 @@ panic = "abort"
`src/lib.rs`:
```rust
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
#[derive(Default)]
struct WordTools;
impl Plugin for WordTools {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
let editor = ctx.editor().ok_or("no file is open")?;
match id {
"count" => {
@@ -226,7 +225,7 @@ impl Plugin for WordTools {
editor.transform_selections(|s| s.to_uppercase())?;
Ok(None)
}
_ => Err(format!("unknown command {id}")),
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
}
@@ -261,31 +260,30 @@ then run `openidea-plugin check`.
### The API
| Rust SDK | WIT | Notes |
| ------------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Plugin::run_command(id, ctx, arg)` | export `commands.run` | `ctx` is the window: `ctx.project()`, `ctx.editor()`. `Err(msg)` shows _"<title> failed: msg"_; the plugin keeps running. |
| `Plugin::handle_events(events, ctx)` | export `event-handler.handle` | Batches of subscribed events, oldest first, per window. |
| `Plugin::activate` / `deactivate` | export `lifecycle` | Per instance: after a crash or idle reclaim a new one is activated. |
| `TextEditor::document()` → `{version, text}` | `editor.text-editor.document` | `selections()` are those when the command ran (0-based lines, UTF-16 columns). |
| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. |
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
| `Project::vcs_status()`, `vcs_fetch()` | `vcs` | Fetch runs git, so only in trusted projects; call it inside a task. |
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
| `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. |
| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). |
| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
| `host::register_command`, `register_language`, `register_theme` | `contributions` | Run-time contributions; declaring them in `plugin.toml` is better (they show before the code loads). Max 256 commands. |
| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. |
| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. |
| `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. |
| `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. |
| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. |
| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. |
| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. |
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
| Rust SDK | WIT | Notes |
| ------------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Plugin::run_command(id, ctx, arg)` | export `commands.run` | `ctx` is the window: `ctx.project()`, `ctx.editor()`. An error shows _"<title> failed: …"_; the plugin keeps running. `"msg".into()` is `Error::Failed`; `Cancelled` and `Denied` don't count as the plugin failing. |
| `Plugin::handle_events(events, ctx)` | export `event-handler.handle` | Batches of subscribed events, oldest first, per window. |
| `Plugin::activate` / `deactivate` | export `lifecycle` | Per instance: after a crash or idle reclaim a new one is activated. |
| `TextEditor::document()` → `{version, text}` | `editor.text-editor.document` | `selections()` are those when the command ran (0-based lines, UTF-16 columns). |
| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. |
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
| `Project::vcs_status()`, `vcs_fetch()` | `vcs` | Fetch runs git, so only in trusted projects; call it inside a task. |
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
| `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. |
| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). |
| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. |
| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. |
| `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. |
| `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. |
| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. |
| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. |
| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. |
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
`run-command` receives an optional string argument: `executeAction("plugin.hello.insert-date", "arg")`
from another feature, or nothing when run from Search Everywhere / a shortcut.
@@ -321,9 +319,17 @@ Then `register_plugin!(MyPlugin, providers)` and implement
`inline_completion`, `code_actions`, `definition`, `line_markers`. Each gets
the document (path, language, version, text). Results join the language
servers' (completion lists, ⌥⏎ quick fixes, ⌘B, gutter icons; a code action
carries edits for any files, applied as one change). Editor providers run
under the edit-path budget: 50 ms, after which the result is dropped, and an
interruption at 500 ms. Inline completion is exempt from the 50 ms (it may
carries edits for any files, applied as one change). A completion item's
`kind` (`CompletionKind` in the SDK, a string in the WIT) picks its icon.
Editor providers run under the edit-path budget: 50 ms, after which the
result is dropped, and an interruption at 500 ms. Every plugin providing a
kind is asked at the same time, so each has the whole budget.
Providers run in the plugin's one instance, with its commands, events and
views: they see the same memory (Checkstyle answers quick fixes from the
results of its last check) but also wait behind them. Keep commands and event
handlers short where you can; a provider call queued behind a long one is
dropped at 50 ms like a slow one. Inline completion is exempt from the 50 ms (it may
need the network) but is cancelled as soon as the user types on: check
`Task::is_cancelled` or just let the host interrupt the call.
@@ -627,12 +633,77 @@ and the publisher's key, then shows the install page.
## 9. Testing the host
The host's integration tests (`crates/ide-host/tests/plugins.rs`) load the
checked-in `hello` and test-fixture components. Rebuild them after changing the
SDK or the WIT file:
checked-in components of `hello`, the official plugins and the test fixtures.
Rebuild them after changing a plugin, the SDK or the WIT files:
```sh
cargo run -p ide-host --bin openidea-plugin -- build plugins/hello
cargo run -p ide-host --bin openidea-plugin -- build plugins/official/solidity
plugins/build.sh crates/ide-plugin/tests/fixtures/misbehave
plugins/build.sh # all of them
plugins/build.sh official/solidity # one
plugins/build.sh --check # what CI runs: nothing stale, everything builds
cargo test -p ide-host --test plugins
```
Next to each `dist/plugin.wasm`, `dist/plugin.wasm.sources` fingerprints the
sources it was built from (the plugin, the SDK and `wit/`); builds are not
byte-reproducible across machines, so `--check` compares fingerprints.
The SDK carries a copy of `wit/` in `sdk/rust/openidea-plugin/wit`; keep them
identical (a test in `crates/ide-plugin/tests/api_compat.rs` checks). Changing
the API itself follows _Evolving the plugin API_ below.
## 10. Evolving the plugin API
Until the plugin system's first release the API may still change freely. From
then on wasmtime decides what stays compatible: it resolves interface names
by semver (a component importing `openidea:plugin/[email protected]` links against a
host defining `@1.1.0`, and the host finds its exports the same way), but
function types must match exactly — a record with another field count, or a
variant or enum with another case count, doesn't link. So a released 1.x
only grows:
1. **Released types and signatures never change**, whichever way the data
flows: no new record fields, variant or enum cases, renames or parameters.
Extend with new types and new functions (or interfaces). `types.error` is
used everywhere and is no exception; new situations use `failed(string)`
or `invalid(string)`. Sets that keep growing (completion, token, symbol and
fold kinds, icons) are strings for this reason.
2. **Host interfaces (imports) may gain functions and interfaces**: older
components don't import them.
3. **Plugin interfaces (exports) never gain functions** once released: the
host would not find them in older components. New capabilities get new
interfaces (e.g. a `hover-provider`) in new optional worlds.
4. **The host knows each component's API version** (the highest version of
its `openidea:plugin/*` imports and exports, `PluginEngine::check_component`).
Installing and `openidea-plugin check` refuse another major or a newer
minor with a message that says to update the IDE or rebuild with the SDK.
5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and
`API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`.
Checked automatically:
- SDK `tests/api_frozen.rs`: `tests/api/<version>/` holds the WIT of each
released version; every interface, type, function and world import or
export in it must still exist unchanged in `wit/` (rules 1–3). Copy `wit/`
there when releasing; there is no snapshot before the first release.
- `crates/ide-plugin/tests/api_compat.rs`: the versions agree, the SDK's copy
matches, the version check (rules 4, 5); a simulated next-minor host (every
import stubbed at the newer version, plus extra functions and an extra
interface) links every shipped component and finds its exports; another
major doesn't link; a host missing one function doesn't link.
A new host function touches:
| Where | Checked by |
| --------------------------------------------------------- | ---------------------------------------------- |
| `wit/*.wit`, copied to `sdk/rust/openidea-plugin/wit` | `api_compat.rs::the_sdk_carries_the_hosts_wit` |
| `crates/ide-plugin/src/wit_host.rs`: binding, capability | the compiler (bindgen's traits) |
| `crates/ide-plugin/src/runtime.rs`: `HostApi` method | the compiler |
| `crates/ide-host/src/plugins/api.rs`: implementation | the compiler (desktop and `openidea-server`) |
| SDK `src/host.rs`: the wasm and the mock `imp` | the compiler (CI builds both targets) |
| SDK public wrapper | SDK `tests/wit_coverage.rs` |
| Capability: `ide-extension/src/capability.rs`, consent UI | review; a test that a missing grant is denied |
| The API table above | review |
Then run `plugins/build.sh`; CI's `plugins` job runs `plugins/build.sh --check`
plus fmt, clippy (host and wasm) and the tests of the SDK, the official
plugins and the fixtures.
+77 -12
Переглянути файл
@@ -1,30 +1,95 @@
#!/usr/bin/env bash
# Builds Rust plugins into WebAssembly components (wasm32-wasip2 emits a
# component directly; no cargo-component / wasm-tools needed).
# Builds the repository's Rust plugins into WebAssembly components
# (wasm32-wasip2 emits a component directly; no cargo-component / wasm-tools).
#
# plugins/build.sh # all plugins with a Cargo.toml
# plugins/build.sh hello # one plugin
# plugins/build.sh # every component the repo checks in
# plugins/build.sh hello # one plugin (a name under plugins/, or a path)
# plugins/build.sh --check [...] # change nothing; fail if a checked-in component
# # is stale or a plugin doesn't build (CI)
#
# Output: <plugin>/dist/plugin.wasm (referenced by `wasm = ...` in plugin.toml).
# Output: <plugin>/dist/plugin.wasm (referenced by `wasm = ...` in plugin.toml)
# and <plugin>/dist/plugin.wasm.sources, a fingerprint of the sources it was
# built from: the plugin (minus target/ and dist/), the SDK and wit/. Builds
# are not byte-reproducible across machines, so --check compares fingerprints,
# not bytes. CARGO_TARGET_DIR, when set, is shared by all plugins.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(cd "$here/.." && pwd)"
rustup target list --installed | grep -qx wasm32-wasip2 || rustup target add wasm32-wasip2
check=0
if [ "${1:-}" = "--check" ]; then
check=1
shift
fi
sha256() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; }
# Lists "<path> <sha256>" for the files git sees (tracked, or new and not
# ignored) under the given paths of directory $1, minus build output and Finder
# metadata; so a
# checkout fingerprints the same as the working tree that committed it.
list() {
local dir="$1"
shift
(cd "$dir" && git ls-files --cached --others --exclude-standard -- "$@") |
{ grep -Ev '(^|/)(dist|target)/|(^|/)\.DS_Store$' || true; } | LC_ALL=C sort -u | while read -r f; do
if [ -f "$dir/$f" ]; then printf '%s %s\n' "$f" "$(sha256 <"$dir/$f" | cut -d' ' -f1)"; fi
done
}
# Prints the fingerprint of everything plugin $1 is built from.
fingerprint() {
{ list "$1" .; list "$repo" sdk/rust/openidea-plugin/Cargo.toml sdk/rust/openidea-plugin/src sdk/rust/openidea-plugin/wit wit; } |
sha256 | cut -d' ' -f1
}
failed=0
build() {
local dir="$1"
local name
local name target out
name="$(sed -n 's/^name *= *"\(.*\)"/\1/p' "$dir/Cargo.toml" | head -1)"
echo "building $name ($dir)"
CARGO_TARGET_DIR="$dir/target" cargo build --manifest-path "$dir/Cargo.toml" --release --target wasm32-wasip2
target="${CARGO_TARGET_DIR:-$dir/target}"
out="$target/wasm32-wasip2/release/${name//-/_}.wasm"
local rel="${dir#"$repo"/}"
if [ "$check" = 1 ]; then
if ! CARGO_TARGET_DIR="$target" cargo build -q --locked --manifest-path "$dir/Cargo.toml" --release --target wasm32-wasip2; then
echo "FAIL $rel: does not build"
failed=1
return
fi
local want have
want="$(fingerprint "$dir")"
have="$(cat "$dir/dist/plugin.wasm.sources" 2>/dev/null || true)"
if [ "$want" != "$have" ]; then
echo "STALE $rel: dist/plugin.wasm was not built from the current sources; run plugins/build.sh $rel"
failed=1
else
echo "ok $rel"
fi
return
fi
echo "building $name ($rel)"
CARGO_TARGET_DIR="$target" cargo build --manifest-path "$dir/Cargo.toml" --release --target wasm32-wasip2
mkdir -p "$dir/dist"
cp "$dir/target/wasm32-wasip2/release/${name//-/_}.wasm" "$dir/dist/plugin.wasm"
echo " -> $dir/dist/plugin.wasm ($(wc -c < "$dir/dist/plugin.wasm" | tr -d " ") bytes)"
cp "$out" "$dir/dist/plugin.wasm"
fingerprint "$dir" >"$dir/dist/plugin.wasm.sources"
echo " -> $rel/dist/plugin.wasm ($(wc -c <"$dir/dist/plugin.wasm" | tr -d " ") bytes)"
}
if [ $# -gt 0 ]; then
for p in "$@"; do
case "$p" in /*) build "$p" ;; *) build "$here/$p" ;; esac
if [ -d "$here/$p" ]; then build "$here/$p"
elif [ -d "$repo/$p" ]; then build "$(cd "$repo/$p" && pwd)"
elif [ -d "$p" ]; then build "$(cd "$p" && pwd)"
else echo "no plugin at $p" >&2; exit 2
fi
done
else
for manifest in "$here"/*/Cargo.toml; do build "$(dirname "$manifest")"; done
# Every checked-in component built from a plugin.toml + Cargo.toml directory.
for manifest in "$here"/*/Cargo.toml "$here"/official/*/Cargo.toml "$repo"/crates/ide-plugin/tests/fixtures/*/Cargo.toml; do
dir="$(dirname "$manifest")"
[ -f "$dir/plugin.toml" ] && build "$dir"
done
fi
exit "$failed"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
837c72fc3ab90de50efcd4fa3628d3174fe7f68382a822c6c2a161a330b689cf
+1 -1
Переглянути файл
@@ -17,7 +17,7 @@ activation = ["on-command:*", "on-view:greetings"]
required = ["editor.read", "editor.write"]
# Static declarations make the commands visible before the plugin activates;
# the plugin may also register more at runtime with `register_command`.
# a command must be declared here to exist.
[[contributes.commands]]
id = "insert-date"
title = "Hello: Insert Date"
+4 -4
Переглянути файл
@@ -19,7 +19,7 @@ const GREETINGS: &[(&str, &[&str])] = &[
];
impl Plugin for Hello {
fn activate(&mut self, ctx: &Context) -> Result<(), String> {
fn activate(&mut self, ctx: &Context) -> Result<(), Error> {
host::log(LogLevel::Info, &format!("hello {} activated", ctx.plugin_version));
Ok(())
}
@@ -28,7 +28,7 @@ impl Plugin for Hello {
host::log(LogLevel::Info, "hello deactivated");
}
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
let editor = ctx.editor().ok_or("no file is open in the editor")?;
match id {
"insert-date" => {
@@ -47,7 +47,7 @@ impl Plugin for Hello {
host::notify(NotificationLevel::Info, &format!("Word count: {name}"), &body);
Ok(Some(body))
}
other => Err(format!("unknown command: {other}")),
other => Err(Error::NotFound(format!("command {other}"))),
}
}
}
@@ -174,7 +174,7 @@ mod tests {
let host = MockHost::install().with_capabilities(&["editor.read"]);
host.open_editor("/project/a.txt", "text", "", &[(0, 0, 0, 0)]);
let err = Hello::default().run_command("insert-date", &host.invocation(), None).unwrap_err();
assert!(err.contains("editor.write"), "{err}");
assert_eq!(err, Error::Denied("editor.write".into()));
assert_eq!(host.editor_text(), "");
}
}
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
bf264a97dc918f24ac00f0e9e0d0373b68391a96921b44345b466c2a087c5283
+2 -2
Переглянути файл
@@ -118,7 +118,7 @@ impl Providers for AiCompletion {
}
impl Plugin for AiCompletion {
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, String> {
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
let msg = match id {
"use-local" => {
storage::set("endpoint", LOCAL)?;
@@ -145,7 +145,7 @@ impl Plugin for AiCompletion {
storage::set("enabled", if enabled { "true" } else { "false" })?;
(if enabled { "AI Completion is on." } else { "AI Completion is off." }).into()
}
_ => return Err(format!("unknown command {id}")),
_ => return Err(Error::NotFound(format!("command {id}"))),
};
host::notify(NotificationLevel::Info, "AI Completion", &msg);
Ok(Some(msg))
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
6095c7e30658b93d79270e6355d42941a4cfe13eeb8bada07acc7ae462194853
+2 -2
Переглянути файл
@@ -196,7 +196,7 @@ impl Providers for Checkstyle {
}
impl Plugin for Checkstyle {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"check" => {
let editor = ctx.editor().ok_or("no file is open")?;
@@ -207,7 +207,7 @@ impl Plugin for Checkstyle {
storage::set("config", if id == "use-sun" { SUN } else { GOOGLE })?;
Ok(None)
}
_ => Err(format!("unknown command {id}")),
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
b96941af697ce7a6c1c8ef1539d34aa7c23d2d41dd1b2ccffdfda5648f90cf56
+2 -2
Переглянути файл
@@ -183,8 +183,8 @@ impl CsvEditor {
}
impl Plugin for CsvEditor {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
Err(format!("unknown command {id}"))
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(format!("command {id}")))
}
}
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
ad545605b33c6f4e55a06a376333de3162739ed920b5d7d277942e50a4420083
+4 -4
Переглянути файл
@@ -130,7 +130,7 @@ fn fetch(projects: &[Project]) -> Result<usize, Error> {
}
impl Plugin for GitToolbox {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
let project = current(ctx).ok_or("no project is open")?;
match id {
"fetch" => {
@@ -142,12 +142,12 @@ impl Plugin for GitToolbox {
show(&project)?;
Ok(None)
}
_ => Err(format!("unknown command {id}")),
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
fn handle_events(&mut self, events: &[Event], ctx: &Invocation) {
if events.iter().any(|e| *e == Event::Scheduled) {
if events.contains(&Event::Scheduled) {
let projects = host::projects().unwrap_or_default();
if let Err(e) = fetch(&projects)
&& e != Error::Cancelled
@@ -225,7 +225,7 @@ mod tests {
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
assert_eq!(host.notifications()[0].body, "no network");
let err = GitToolbox.run_command("fetch", &host.invocation(), None).unwrap_err();
assert_eq!(err, "no network");
assert_eq!(err, Error::Failed("no network".into()));
host.fail_fetch(Some(Error::Cancelled));
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
assert_eq!(host.notifications().len(), 1, "a cancelled fetch is not an error to report");
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
345c39f818e4950914bf3fcc795fac9e30ff2d87ad3e66f24c7bc00800f92980
+8 -5
Переглянути файл
@@ -4,7 +4,7 @@
//! instance being reclaimed while you work.
use openidea_plugin::host::{self, storage};
use openidea_plugin::{ActionSource, Event, Invocation, NotificationLevel, Plugin, register_plugin};
use openidea_plugin::{ActionSource, Error, Event, Invocation, NotificationLevel, Plugin, register_plugin};
/// Tips for one action are shown on these counts, then every `REPEAT_EVERY`.
const FIRST_TIPS: [u32; 3] = [1, 3, 10];
@@ -22,7 +22,7 @@ fn get_u32(key: &str) -> u32 {
}
impl Plugin for KeyPromoter {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"statistics" => {
let mut rows: Vec<(u32, String)> = storage::keys(COUNT)?
@@ -56,7 +56,7 @@ impl Plugin for KeyPromoter {
storage::set(&format!("{SUPPRESSED}{action}"), "1")?;
Ok(Some(format!("No more tips for {action}.")))
}
_ => Err(format!("unknown command {id}")),
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
@@ -78,7 +78,7 @@ impl Plugin for KeyPromoter {
return; // storage denied or full: nothing to count with
}
let _ = storage::set(&format!("{TITLE}{}", a.action), &a.title);
if FIRST_TIPS.contains(&n) || n % REPEAT_EVERY == 0 {
if FIRST_TIPS.contains(&n) || n.is_multiple_of(REPEAT_EVERY) {
tip = Some((a.clone(), shortcut.clone(), n));
}
}
@@ -156,6 +156,9 @@ mod tests {
let host = MockHost::install().with_capabilities(&["events.input"]);
KeyPromoter.handle_events(&[action("x", ActionSource::Menu, Some("F2"))], &host.invocation());
assert!(host.notifications().is_empty());
assert!(KeyPromoter.run_command("statistics", &host.invocation(), None).unwrap_err().contains("storage"));
assert_eq!(
KeyPromoter.run_command("statistics", &host.invocation(), None).unwrap_err(),
Error::Denied("storage".into())
);
}
}
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
3499cfcae4a0388587a11b38158f421b0cf5046eeda38ae23b0f7700728c92a8
+2 -2
Переглянути файл
@@ -187,12 +187,12 @@ impl Kubernetes {
}
impl Plugin for Kubernetes {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"refresh" => self.refresh(),
"logs" => self.open_logs()?,
"shell" => self.open_shell()?,
_ => return Err(format!("unknown command {id}")),
_ => return Err(Error::NotFound(format!("command {id}"))),
}
Ok(None)
}
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
f7df58bb0ab2b92370b148de2614174ec1ff9b01c0078b5c20bd331f82298616
+3 -3
Переглянути файл
@@ -5,7 +5,7 @@
use openidea_plugin::providers::{CompletionItem, CompletionKind, Document, LineMarker, Providers};
use openidea_plugin::text::{offset, position};
use openidea_plugin::{Invocation, Location, Plugin, Position, Project, Range, host, register_plugin};
use openidea_plugin::{Error, Invocation, Location, Plugin, Position, Project, Range, host, register_plugin};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
@@ -292,8 +292,8 @@ impl Providers for Navigator {
}
impl Plugin for Navigator {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
Err(format!("unknown command {id}"))
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(format!("command {id}")))
}
}
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
3893bd8ad06d3e19c0112081c6643b3bf3c67f5a184098432a6fd338426cd682
+4 -4
Переглянути файл
@@ -4,15 +4,15 @@
//! `languages/solidity.toml`.
use openidea_plugin::syntax::{Fold, FoldKind, Highlight, Symbol, SymbolKind, SyntaxProvider, Token, bracket_folds};
use openidea_plugin::{Invocation, Plugin, register_plugin};
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
use std::ops::Range;
#[derive(Default)]
pub struct Solidity;
impl Plugin for Solidity {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
Err(format!("unknown command {id}"))
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(format!("command {id}")))
}
}
@@ -442,7 +442,7 @@ fn outline(text: &str) -> Vec<Symbol> {
}
stmt_start = i + 1;
}
(Kind::Ident, "contract" | "interface" | "library") if declares && word(i + 1) != "" => {
(Kind::Ident, "contract" | "interface" | "library") if declares && !word(i + 1).is_empty() => {
if code.get(i + 1).is_some_and(|n| n.kind == Kind::Ident) {
let kind = match w {
"interface" => SymbolKind::Interface,
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
a88c39d5782369329efa55b12330daa906e5c8d91252bd57c7343648840bac79
+5 -5
Переглянути файл
@@ -9,10 +9,10 @@ use openidea_plugin::{Error, Invocation, Plugin, Range, TextEdit, TextEditor, re
pub struct StringTools;
impl Plugin for StringTools {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
let transform = transform(id).ok_or_else(|| format!("unknown command {id}"))?;
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
let transform = transform(id).ok_or_else(|| Error::NotFound(format!("command {id}")))?;
let editor = ctx.editor().ok_or("String Tools works on the focused editor")?;
apply(&editor, transform, lines_command(id)).map_err(|e| e.to_string())?;
apply(&editor, transform, lines_command(id))?;
Ok(None)
}
}
@@ -270,7 +270,7 @@ fn base64_decode(s: &str) -> Option<Vec<u8>> {
}
let mut n = 0u32;
for (i, b) in chunk.iter().enumerate() {
let v = B64.iter().position(|x| x == b).or_else(|| match b {
let v = B64.iter().position(|x| x == b).or(match b {
b'-' => Some(62),
b'_' => Some(63),
_ => None,
@@ -454,7 +454,7 @@ mod tests {
let host = MockHost::install().with_capabilities(&["editor.read"]);
host.open_editor("/p/a.txt", "text", "abc", &[(0, 0, 0, 3)]);
let err = StringTools.run_command("upper-case", &host.invocation(), None).unwrap_err();
assert!(err.contains("editor.write"), "{err}");
assert_eq!(err, Error::Denied("editor.write".into()));
assert_eq!(host.editor_text(), "abc");
}
}
+1
Переглянути файл
@@ -99,6 +99,7 @@ name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
"wit-parser",
]
[[package]]
+4
Переглянути файл
@@ -14,3 +14,7 @@ path = "src/lib.rs"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wit-bindgen = "0.63"
[dev-dependencies]
# Reads the WIT in tests/wit_coverage.rs (the version wit-bindgen already uses).
wit-parser = "0.262"
-28
Переглянути файл
@@ -73,18 +73,6 @@ mod imp {
wit::ui::remove_status_item(id)
}
pub fn register_command(id: &str, title: &str, category: Option<&str>) -> Result<(), Error> {
wit::contributions::register_command(id, title, category).map_err(error)
}
pub fn register_language(definition: &str) -> Result<(), Error> {
wit::contributions::register_language(definition).map_err(error)
}
pub fn register_theme(definition: &str) -> Result<(), Error> {
wit::contributions::register_theme(definition).map_err(error)
}
pub fn storage_get(key: &str) -> Result<Option<String>, Error> {
wit::storage::get(key).map_err(error)
}
@@ -533,22 +521,6 @@ pub fn remove_status_item(id: &str) {
imp::remove_status_item(id)
}
/// Registers or renames a command at runtime (action `plugin.<plugin-id>.<id>`).
/// Prefer declaring commands in `plugin.toml`: they show before the plugin runs.
pub fn register_command(id: &str, title: &str, category: Option<&str>) -> Result<(), Error> {
imp::register_command(id, title, category)
}
/// Registers a language (`languages/<id>.toml` format) at runtime.
pub fn register_language(definition: &str) -> Result<(), Error> {
imp::register_language(definition)
}
/// Registers a theme (theme JSON) at runtime.
pub fn register_theme(definition: &str) -> Result<(), Error> {
imp::register_theme(definition)
}
/// 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`).
+19 -31
Переглянути файл
@@ -1,20 +1,20 @@
//! SDK for OpenIDEA plugins written in Rust (plugin API 1.0).
//!
//! ```ignore
//! use openidea_plugin::{Context, Invocation, Plugin, register_plugin};
//! use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
//!
//! #[derive(Default)]
//! struct Shout;
//!
//! impl Plugin for Shout {
//! fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
//! fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
//! match id {
//! "shout" => {
//! let editor = ctx.editor().ok_or("no editor is focused")?;
//! editor.transform_selections(|s| s.to_uppercase())?;
//! Ok(None)
//! }
//! _ => Err(format!("unknown command {id}")),
//! _ => Err(Error::NotFound(format!("command {id}"))),
//! }
//! }
//! }
@@ -120,9 +120,9 @@ pub const API_VERSION: &str = "1.0";
/// A plugin. One instance lives as long as the plugin's Wasm instance (until
/// it is disabled, crashes or is reclaimed when idle).
pub trait Plugin: 'static {
/// Called once when the instance is created. Register runtime commands
/// and subscriptions here; restore state from [`host::storage`].
fn activate(&mut self, _ctx: &Context) -> Result<(), String> {
/// Called once when the instance is created: subscribe to events and
/// restore state from [`host::storage`]. An error fails the activation.
fn activate(&mut self, _ctx: &Context) -> Result<(), Error> {
Ok(())
}
@@ -130,7 +130,9 @@ pub trait Plugin: 'static {
fn deactivate(&mut self) {}
/// Runs one of the plugin's commands; `Ok(Some(text))` is shown to the user.
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, String>;
/// A plain message (`Err("…".into())`, or `?` on a `String` error) is
/// [`Error::Failed`]; return [`Error::Cancelled`] when the user gave up.
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error>;
/// A batch of subscribed events (see [`host::subscribe`]), oldest first.
fn handle_events(&mut self, _events: &[Event], _ctx: &Invocation) {}
@@ -308,7 +310,7 @@ macro_rules! __plugin_exports {
impl $crate::bindings::exports::openidea::plugin::lifecycle::Guest for $component {
fn activate(
ctx: $crate::bindings::openidea::plugin::types::Context,
) -> ::core::result::Result<(), ::std::string::String> {
) -> ::core::result::Result<(), $crate::bindings::openidea::plugin::types::Error> {
$crate::__rt::activate::<$plugin>(ctx)
}
@@ -322,7 +324,10 @@ macro_rules! __plugin_exports {
id: ::std::string::String,
ctx: &$crate::bindings::openidea::plugin::invocation::Invocation,
arg: ::core::option::Option<::std::string::String>,
) -> ::core::result::Result<::core::option::Option<::std::string::String>, ::std::string::String> {
) -> ::core::result::Result<
::core::option::Option<::std::string::String>,
$crate::bindings::openidea::plugin::types::Error,
> {
$crate::__rt::run_command::<$plugin>(id, ctx, arg)
}
}
@@ -361,14 +366,14 @@ pub mod __rt {
})
}
pub fn activate<T: Plugin + Default>(ctx: b::types::Context) -> Result<(), String> {
pub fn activate<T: Plugin + Default>(ctx: b::types::Context) -> Result<(), b::types::Error> {
let ctx = Context {
plugin_id: ctx.plugin_id,
plugin_version: ctx.plugin_version,
api_version: ctx.api_version,
capabilities: ctx.capabilities,
};
with::<T, _>(|p| p.activate(&ctx))
with::<T, _>(|p| p.activate(&ctx)).map_err(error_out)
}
pub fn deactivate<T: Plugin + Default>() {
@@ -380,9 +385,9 @@ pub mod __rt {
id: String,
ctx: &b::invocation::Invocation,
arg: Option<String>,
) -> Result<Option<String>, String> {
) -> Result<Option<String>, b::types::Error> {
let ctx = Invocation::__new(ctx);
with::<T, _>(|p| p.run_command(&id, &ctx, arg.as_deref()))
with::<T, _>(|p| p.run_command(&id, &ctx, arg.as_deref())).map_err(error_out)
}
// ---- providers ----
@@ -413,24 +418,7 @@ pub mod __rt {
.map(|i| w::CompletionItem {
label: i.label,
detail: i.detail,
kind: i.kind.map(|k| match k {
p::CompletionKind::Text => w::CompletionKind::Text,
p::CompletionKind::Method => w::CompletionKind::Method,
p::CompletionKind::Function => w::CompletionKind::Function,
p::CompletionKind::Constructor => w::CompletionKind::Constructor,
p::CompletionKind::Field => w::CompletionKind::Field,
p::CompletionKind::Variable => w::CompletionKind::Variable,
p::CompletionKind::Class => w::CompletionKind::Class,
p::CompletionKind::Interface => w::CompletionKind::Interface,
p::CompletionKind::Module => w::CompletionKind::Module,
p::CompletionKind::Property => w::CompletionKind::Property,
p::CompletionKind::Keyword => w::CompletionKind::Keyword,
p::CompletionKind::Snippet => w::CompletionKind::Snippet,
p::CompletionKind::Value => w::CompletionKind::Value,
p::CompletionKind::Constant => w::CompletionKind::Constant,
p::CompletionKind::File => w::CompletionKind::File,
p::CompletionKind::Reference => w::CompletionKind::Reference,
}),
kind: i.kind.map(|k| k.as_str().to_owned()),
insert_text: i.insert_text,
replace: i.replace.map(crate::host::__range_to),
sort_text: i.sort_text,
-56
Переглянути файл
@@ -19,13 +19,6 @@ pub struct Notification {
pub body: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Command {
pub id: String,
pub title: String,
pub category: Option<String>,
}
/// A task the plugin began.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MockTask {
@@ -64,9 +57,6 @@ struct State {
files: BTreeMap<String, String>,
editor: Option<Editor>,
notifications: Vec<Notification>,
commands: Vec<Command>,
languages: Vec<String>,
themes: Vec<String>,
logs: Vec<(LogLevel, String)>,
status: BTreeMap<String, StatusItem>,
storage: BTreeMap<String, String>,
@@ -174,18 +164,6 @@ impl MockHost {
self.state.borrow().notifications.clone()
}
pub fn commands(&self) -> Vec<Command> {
self.state.borrow().commands.clone()
}
pub fn languages(&self) -> Vec<String> {
self.state.borrow().languages.clone()
}
pub fn themes(&self) -> Vec<String> {
self.state.borrow().themes.clone()
}
pub fn logs(&self) -> Vec<(LogLevel, String)> {
self.state.borrow().logs.clone()
}
@@ -367,37 +345,6 @@ pub mod current {
with(|s| s.status.remove(id));
}
pub fn register_command(id: &str, title: &str, category: Option<&str>) -> Result<(), Error> {
with(|s| {
let cmd = Command { id: id.into(), title: title.into(), category: category.map(str::to_owned) };
match s.commands.iter_mut().find(|c| c.id == id) {
Some(existing) => *existing = cmd,
None => s.commands.push(cmd),
}
Ok(())
})
}
pub fn register_language(definition: &str) -> Result<(), Error> {
with(|s| {
if !definition.contains("id") {
return Err(Error::Invalid("invalid language definition".into()));
}
s.languages.push(definition.into());
Ok(())
})
}
pub fn register_theme(definition: &str) -> Result<(), Error> {
with(|s| {
if !definition.trim_start().starts_with('{') {
return Err(Error::Invalid("invalid theme JSON".into()));
}
s.themes.push(definition.into());
Ok(())
})
}
pub fn storage_get(key: &str) -> Result<Option<String>, Error> {
with(|s| {
require(s, "storage")?;
@@ -714,10 +661,7 @@ mod tests {
assert_eq!(project.read_file("src/a.txt").unwrap(), "hello");
assert!(matches!(project.read_file("../etc/passwd"), Err(Error::Invalid(_))));
host::notify(NotificationLevel::Info, "t", "b");
host::register_command("c", "C", None).unwrap();
host::register_command("c", "C2", Some("Cat")).unwrap();
assert_eq!(mock.notifications()[0].title, "t");
assert_eq!(mock.commands(), [Command { id: "c".into(), title: "C2".into(), category: Some("Cat".into()) }]);
host::storage::set("k", "v").unwrap();
assert_eq!(host::storage::get("k").unwrap().as_deref(), Some("v"));
assert_eq!(host::storage::keys("").unwrap(), ["k"]);
+47 -3
Переглянути файл
@@ -23,7 +23,9 @@ pub struct Document {
pub text: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// Icon and sort group of a completion item.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CompletionKind {
Text,
Method,
@@ -35,12 +37,54 @@ pub enum CompletionKind {
Interface,
Module,
Property,
Unit,
Value,
Enum,
Keyword,
Snippet,
Value,
Constant,
Color,
File,
Reference,
Folder,
EnumMember,
Constant,
Struct,
Event,
Operator,
TypeParameter,
}
impl CompletionKind {
/// The name the plugin API uses (`completion-item.kind`).
pub fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Method => "method",
Self::Function => "function",
Self::Constructor => "constructor",
Self::Field => "field",
Self::Variable => "variable",
Self::Class => "class",
Self::Interface => "interface",
Self::Module => "module",
Self::Property => "property",
Self::Unit => "unit",
Self::Value => "value",
Self::Enum => "enum",
Self::Keyword => "keyword",
Self::Snippet => "snippet",
Self::Color => "color",
Self::File => "file",
Self::Reference => "reference",
Self::Folder => "folder",
Self::EnumMember => "enum-member",
Self::Constant => "constant",
Self::Struct => "struct",
Self::Event => "event",
Self::Operator => "operator",
Self::TypeParameter => "type-parameter",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
+1 -1
Переглянути файл
@@ -212,7 +212,7 @@ mod tests {
fn bracket_folds_skip_strings() {
let text = "a {\n \"{\"\n b {\n }\n}\n";
let s = text.find('"').unwrap();
let folds = bracket_folds(text, b'{', b'}', &[s..s + 3]);
let folds = bracket_folds(text, b'{', b'}', std::slice::from_ref(&(s..s + 3)));
assert_eq!(
folds,
[
+16 -16
Переглянути файл
@@ -30,22 +30,6 @@ pub fn position(text: &str, at: usize) -> Position {
Position { line, character }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_utf16_columns() {
let text = "ab\n😀x\nlast";
for at in [0, 1, 2, 3, 7, 8, 9, text.len()] {
assert_eq!(offset(text, position(text, at)), Some(at), "{at}");
}
assert_eq!(position(text, 7), Position::new(1, 2));
assert_eq!(offset(text, Position::new(0, 99)), Some(2), "clamped to the line");
assert_eq!(offset(text, Position::new(5, 0)), None);
}
}
/// The text of a JSON string literal's contents (`\"`, `\\`, `\n`, `\uXXXX`).
pub fn unescape_json(s: &str) -> String {
let mut out = String::with_capacity(s.len());
@@ -80,3 +64,19 @@ pub fn unescape_json(s: &str) -> String {
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_utf16_columns() {
let text = "ab\n😀x\nlast";
for at in [0, 1, 2, 3, 7, 8, 9, text.len()] {
assert_eq!(offset(text, position(text, at)), Some(at), "{at}");
}
assert_eq!(position(text, 7), Position::new(1, 2));
assert_eq!(offset(text, Position::new(0, 99)), Some(2), "clamped to the line");
assert_eq!(offset(text, Position::new(5, 0)), None);
}
}
+13
Переглянути файл
@@ -35,6 +35,19 @@ impl From<Error> for String {
}
}
/// A plain message is [`Error::Failed`].
impl From<String> for Error {
fn from(message: String) -> Self {
Self::Failed(message)
}
}
impl From<&str> for Error {
fn from(message: &str) -> Self {
Self::Failed(message.to_owned())
}
}
/// Log level of [`crate::host::log`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LogLevel {
+180
Переглянути файл
@@ -0,0 +1,180 @@
//! Released plugin APIs never change (plugins/README.md, "Evolving the plugin API"):
//! wasmtime links a component only if every function type matches exactly, so
//! a field added to a record or a case added to a variant breaks every
//! component built for the release.
//!
//! `tests/api/<version>/` is the WIT of each release, copied when it was
//! frozen. Every interface, type and function in it must still exist in
//! `wit/` with the same definition; additions are fine. When a version is
//! released, copy `wit/` to `tests/api/<that version>/`. Until the first
//! release there is no snapshot and the API may still change freely.
use std::collections::BTreeMap;
use std::path::Path;
use wit_parser::{FunctionKind, Handle, Resolve, Type, TypeDefKind, TypeId, TypeOwner};
/// Everything a release promises, by key (`log/log`, `types/error`), rendered canonically.
fn surface(dir: &Path) -> BTreeMap<String, String> {
let mut resolve = Resolve::default();
let (pkg, _) = resolve.push_dir(dir).unwrap_or_else(|e| panic!("{}: {e:?}", dir.display()));
let mut out = BTreeMap::new();
for (name, &id) in &resolve.packages[pkg].interfaces {
let iface = &resolve.interfaces[id];
for (ty_name, &ty) in &iface.types {
out.insert(format!("{name}/type {ty_name}"), definition(&resolve, ty));
}
for f in iface.functions.values() {
let params: Vec<String> = f.params.iter().map(|p| format!("{}: {}", p.name, ty(&resolve, &p.ty))).collect();
let result = f.result.as_ref().map(|r| format!(" -> {}", ty(&resolve, r))).unwrap_or_default();
let kind = match f.kind {
FunctionKind::Freestanding => "",
FunctionKind::AsyncFreestanding => "async ",
FunctionKind::Method(_) => "method ",
FunctionKind::Static(_) => "static ",
FunctionKind::Constructor(_) => "constructor ",
_ => "other ",
};
out.insert(format!("{name}/func {}", f.name), format!("{kind}({}){result}", params.join(", ")));
}
}
for (name, &id) in &resolve.packages[pkg].worlds {
let world = &resolve.worlds[id];
let keys = |m: &wit_parser::IndexMap<wit_parser::WorldKey, wit_parser::WorldItem>| {
m.keys().map(|k| resolve.name_world_key(k)).collect::<Vec<_>>().join(", ")
};
out.insert(
format!("world {name}"),
format!("imports [{}] exports [{}]", keys(&world.imports), keys(&world.exports)),
);
}
out
}
/// A named type's definition.
fn definition(r: &Resolve, id: TypeId) -> String {
let def = &r.types[id];
match &def.kind {
TypeDefKind::Record(x) => {
let f: Vec<_> = x.fields.iter().map(|f| format!("{}: {}", f.name, ty(r, &f.ty))).collect();
format!("record {{ {} }}", f.join(", "))
}
TypeDefKind::Variant(x) => {
let c: Vec<_> = x
.cases
.iter()
.map(|c| c.ty.as_ref().map_or(c.name.clone(), |t| format!("{}({})", c.name, ty(r, t))))
.collect();
format!("variant {{ {} }}", c.join(", "))
}
TypeDefKind::Enum(x) => {
format!("enum {{ {} }}", x.cases.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(", "))
}
TypeDefKind::Flags(x) => {
format!("flags {{ {} }}", x.flags.iter().map(|f| f.name.as_str()).collect::<Vec<_>>().join(", "))
}
TypeDefKind::Resource => "resource".to_owned(),
// A `use`d type: the same type as the one it names.
TypeDefKind::Type(t) => format!("= {}", ty(r, t)),
_ => anonymous(r, id),
}
}
/// A type in a position (parameter, field…): named types by name, others structurally.
fn ty(r: &Resolve, t: &Type) -> String {
match t {
Type::Id(id) => {
let def = &r.types[*id];
match (&def.name, def.owner) {
(Some(name), TypeOwner::Interface(i)) => {
format!("{}.{name}", r.interfaces[i].name.as_deref().unwrap_or("?"))
}
(Some(name), _) => name.clone(),
(None, _) => anonymous(r, *id),
}
}
other => format!("{other:?}").to_lowercase(),
}
}
fn anonymous(r: &Resolve, id: TypeId) -> String {
let opt = |t: &Option<Type>| t.as_ref().map_or("_".to_owned(), |t| ty(r, t));
match &r.types[id].kind {
TypeDefKind::Option(t) => format!("option<{}>", ty(r, t)),
TypeDefKind::List(t) => format!("list<{}>", ty(r, t)),
TypeDefKind::Result(x) => format!("result<{}, {}>", opt(&x.ok), opt(&x.err)),
TypeDefKind::Tuple(x) => format!("tuple<{}>", x.types.iter().map(|t| ty(r, t)).collect::<Vec<_>>().join(", ")),
TypeDefKind::Handle(Handle::Own(t)) => format!("own<{}>", ty(r, &Type::Id(*t))),
TypeDefKind::Handle(Handle::Borrow(t)) => format!("borrow<{}>", ty(r, &Type::Id(*t))),
TypeDefKind::Type(t) => ty(r, t),
other => format!("{other:?}"),
}
}
/// The frozen releases (`tests/api/<version>/`), none before the first release.
fn releases(root: &Path) -> Vec<std::path::PathBuf> {
match std::fs::read_dir(root.join("tests/api")) {
Ok(dir) => dir.map(|e| e.unwrap().path()).filter(|p| p.is_dir()).collect(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => panic!("tests/api: {e}"),
}
}
#[test]
fn the_surface_covers_the_whole_api() {
// Keeps the check below meaningful while there is no release to compare.
let current = surface(&Path::new(env!("CARGO_MANIFEST_DIR")).join("wit"));
assert!(current.len() > 50, "only {} items: did wit/ load?", current.len());
assert!(current.keys().any(|k| k.starts_with("world ")));
assert!(current.keys().any(|k| k.contains("/func ")));
assert!(current.keys().any(|k| k.contains("/type ")));
}
#[test]
fn released_apis_are_unchanged() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let current = surface(&root.join("wit"));
for dir in releases(root) {
let release = surface(&dir);
let version = dir.file_name().unwrap().to_string_lossy().into_owned();
let mut broken = Vec::new();
for (key, was) in &release {
match current.get(key) {
None => broken.push(format!("{key}: removed (was {was})")),
// A world may gain imports and exports; everything else stays as it was.
Some(now) if key.starts_with("world ") => {
let had = |s: &str, part: &str| -> Vec<String> {
let start = s.find(part).unwrap() + part.len();
let end = s[start..].find(']').unwrap() + start;
s[start..end].split(", ").filter(|x| !x.is_empty()).map(str::to_owned).collect()
};
for part in ["imports [", "exports ["] {
let now = had(now, part);
for item in had(was, part) {
if !now.contains(&item) {
broken.push(format!("{key}: no longer has {item}"));
}
}
}
}
Some(now) if now != was => broken.push(format!("{key}:\n was {was}\n now {now}")),
Some(_) => {}
}
}
assert!(broken.is_empty(), "plugin API {version} changed incompatibly:\n {}", broken.join("\n "));
assert!(release.len() > 50, "{version}: only {} items: did the snapshot load?", release.len());
}
}
#[test]
fn the_current_api_is_a_release_or_newer_than_all() {
// `wit/` is either a frozen release or the next one; never older.
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let src = std::fs::read_to_string(root.join("wit/plugin.wit")).unwrap();
let current = src.lines().find_map(|l| l.trim().strip_prefix("package openidea:plugin@")).unwrap();
let current: Vec<u64> = current.trim_end_matches(';').split('.').map(|p| p.parse().unwrap()).collect();
for dir in releases(root) {
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
let release: Vec<u64> = name.split('.').map(|p| p.parse().unwrap()).collect();
assert!(release <= current, "tests/api/{name} is newer than wit/ ({current:?})");
}
}
+53
Переглянути файл
@@ -0,0 +1,53 @@
//! Every function the plugin API imports is reachable through the SDK: a new
//! WIT function without a wrapper in `src/` (and so in the mock host, which
//! mirrors the wrappers) fails here instead of going unnoticed by plugin
//! authors. The check is textual: free functions must be called as
//! `wit::<interface>::<function>(`, resource methods as `.<method>(`.
use std::path::Path;
use wit_parser::{FunctionKind, Resolve, WorldItem};
fn sdk_source() -> String {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut out = String::new();
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "rs") {
out.push_str(&std::fs::read_to_string(path).unwrap());
}
}
out
}
#[test]
fn every_imported_function_has_an_sdk_wrapper() {
let mut resolve = Resolve::default();
let (pkg, _) = resolve.push_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("wit")).unwrap();
let source = sdk_source();
let world = resolve.packages[pkg].worlds["plugin"];
let mut missing = Vec::new();
let mut checked = 0;
for item in resolve.worlds[world].imports.values() {
let WorldItem::Interface { id, .. } = item else { continue };
let iface = &resolve.interfaces[*id];
if iface.package != Some(pkg) {
continue; // WASI
}
let module = iface.name.as_deref().unwrap().replace('-', "_");
for f in iface.functions.values() {
let name = f.item_name().replace('-', "_");
let call = match f.kind {
FunctionKind::Freestanding | FunctionKind::AsyncFreestanding => format!("wit::{module}::{name}("),
FunctionKind::Constructor(_) => "::new(".to_owned(),
FunctionKind::Static(_) | FunctionKind::AsyncStatic(_) => format!("::{name}("),
_ => format!(".{name}("),
};
checked += 1;
if !source.contains(&call) {
missing.push(format!("{}.{} (expected `{call}`)", iface.name.as_deref().unwrap(), f.name));
}
}
}
assert!(checked > 30, "only {checked} functions found: is the world still called `plugin`?");
assert!(missing.is_empty(), "plugin API functions without an SDK wrapper:\n {}", missing.join("\n "));
}
+10 -5
Переглянути файл
@@ -1,20 +1,25 @@
package openidea:[email protected];
interface lifecycle {
use types.{context};
use types.{context, error};
/// Called once after the instance is created. Register runtime
/// contributions and subscriptions here; restore state from `storage`.
activate: func(ctx: context) -> result<_, string>;
/// Called once after the instance is created: subscribe to events and
/// restore state from `storage`. Contributions (commands, languages,
/// themes, …) are declared in plugin.toml, not registered here.
/// An error fails the activation (shown on the plugin's page).
activate: func(ctx: context) -> result<_, error>;
/// Called before the instance is dropped (disable, uninstall, idle reclaim).
deactivate: func();
}
interface commands {
use types.{error};
use invocation.{invocation};
/// Runs one of the plugin's commands. Returns an optional message.
run: func(id: string, ctx: borrow<invocation>, arg: option<string>) -> result<option<string>, string>;
/// `cancelled` (the user gave up) and `denied` (a capability is missing)
/// are not counted as the plugin failing.
run: func(id: string, ctx: borrow<invocation>, arg: option<string>) -> result<option<string>, error>;
}
interface event-handler {
-13
Переглянути файл
@@ -7,19 +7,6 @@ interface log {
log: func(level: log-level, message: string);
}
/// Contributions made at run time (declare static ones in plugin.toml).
interface contributions {
use types.{error};
/// Registers (or updates) a command `plugin.<plugin-id>.<id>`.
/// `id` must match `[a-z0-9][a-z0-9._-]*`.
register-command: func(id: string, title: string, category: option<string>) -> result<_, error>;
/// A language from a `languages/<id>.toml`-style definition.
register-language: func(definition: string) -> result<_, error>;
/// A theme from JSON: {"id","name","kind":"dark"|"light","tokens":{"--var":"value"}}.
register-theme: func(definition: string) -> result<_, error>;
}
interface ui {
use types.{notification-level};
+3 -4
Переглянути файл
@@ -1,6 +1,6 @@
/// OpenIDEA plugin API, version 1.0 (docs/plugin_plan.md 4.3). Frozen: 1.x
/// only adds (new interfaces, new optional exports); a 2.0 keeps 1.0 running
/// through an adapter in the host.
/// OpenIDEA plugin API, version 1.0. Once released, 1.x only adds (new
/// interfaces, new optional exports; plugins/README.md, "Evolving the plugin
/// API"); a 2.0 keeps 1.0 running through an adapter in the host.
///
/// A plugin is a WebAssembly component targeting the `plugin` world (or
/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment
@@ -11,7 +11,6 @@ package openidea:[email protected];
world plugin {
import log;
import contributions;
import ui;
import storage;
import workspace;
+6 -20
Переглянути файл
@@ -15,29 +15,15 @@ interface providers {
text: string,
}
enum completion-kind {
text,
method,
function,
%constructor,
field,
variable,
class,
%interface,
module,
property,
keyword,
snippet,
value,
constant,
file,
reference,
}
record completion-item {
label: string,
detail: option<string>,
kind: option<completion-kind>,
/// Icon and sort group: text, method, function, constructor, field,
/// variable, class, interface, module, property, unit, value, enum,
/// keyword, snippet, color, file, reference, folder, enum-member,
/// constant, struct, event, operator or type-parameter. Unknown kinds
/// show no icon.
kind: option<string>,
insert-text: string,
/// Replaced on accept; `none`: the word at the caret.
replace: option<range>,
+1 -1
Переглянути файл
@@ -1,6 +1,6 @@
package openidea:[email protected];
/// Declarative views (docs/plugin_plan.md 4.2, L2): tool windows declared
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
/// `[[contributes.customEditors]]`. The plugin describes what to show; the
/// IDE renders it with its own components (theme, keyboard, accessibility)
+3 -3
Переглянути файл
@@ -162,7 +162,7 @@ impl Handler for ClientHandler {
}
Err(e) => tracing::warn!("remote event: {e}"),
},
// Plugin code of this window's project runs on the server (docs/plugin_plan.md 3).
// Plugin code of this window's project runs on the server.
ide_host::plugins::remote_host::EVENT_NOTIFICATION => {
match serde_json::from_value::<ide_host::plugins::remote_host::PluginEventMessage>(params) {
Ok(m) if ide_host::plugins::remote_host::forwarded(&m.event) => {
@@ -445,7 +445,7 @@ pub async fn remote_connect(
/// Sends the desktop's plugin languages, with the programs granted to them, to
/// the window's server: `Workspace` contributions take effect where the project
/// is (docs/plugin_plan.md 3). Servers without the command are left alone.
/// is. Servers without the command are left alone.
pub async fn sync_plugin_languages(plugins: &ide_host::plugins::PluginHost, c: &Connection, window: &str) {
use ide_host::plugins::remote::{SYNC_COMMAND, SyncReport};
if !c.commands.iter().any(|cmd| cmd == SYNC_COMMAND) {
@@ -470,7 +470,7 @@ pub async fn sync_plugin_languages(plugins: &ide_host::plugins::PluginHost, c: &
}
/// Sends the desktop's live code plugins to the window's server, which runs
/// them next to the project (docs/plugin_plan.md 3): digests and grants first,
/// them next to the project: digests and grants first,
/// then the files of the packages the server lacks, one per request.
pub async fn sync_plugin_packages(plugins: &Arc<ide_host::plugins::PluginHost>, c: &Connection, window: &str) {
use ide_host::plugins::remote_host::{PackageSyncReport, SYNC_PACKAGES};
+1 -1
Переглянути файл
@@ -1,5 +1,5 @@
/**
* The install / permission dialog (docs/plugin_plan.md 4.2, 4.6): every
* The install / permission dialog: every
* capability with its risk; optional ones can be refused; high-risk ones are
* called out as running code on this machine.
*/
+1 -1
Переглянути файл
@@ -1,6 +1,6 @@
/**
* Projection of plugin UI contributions onto the workbench's own `register*`
* functions (docs/plugin_plan.md 4.1).
* functions.
*
* The backend validated every declaration completely (Rust extension points);
* a projection maps one onto an existing registration and returns its
+1 -1
Переглянути файл
@@ -1,5 +1,5 @@
/**
* Client side of the P7 UI points (docs/plugin_plan.md P7): declarations the
* Client side of the plugin UI points: declarations the
* backend validated, projected onto the workbench's own registries.
*
* - `menus`: editor / project view / editor tab context menus, and Tools in the main menu;
+1 -1
Переглянути файл
@@ -1,5 +1,5 @@
/**
* Renders a plugin's declarative view (docs/plugin_plan.md 4.2, L2): the
* Renders a plugin's declarative view: the
* plugin returns data (`View`), this draws it with the workbench's own
* components and sends the user's actions back (`ViewEvent`). Used by plugin
* tool windows and custom editors. A refresh comes from the plugin
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more