diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a1236d6a..eca369c93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index ac0c58f70..37a6cf588 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 181386a5f..f108c0fbc 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -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)] diff --git a/crates/ide-extension/src/index.rs b/crates/ide-extension/src/index.rs index 2bb05d450..cadc0ea99 100644 --- a/crates/ide-extension/src/index.rs +++ b/crates/ide-extension/src/index.rs @@ -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). //! diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs index 6595166a0..9089067ad 100644 --- a/crates/ide-extension/src/lib.rs +++ b/crates/ide-extension/src/lib.rs @@ -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; diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs index ee8c8642e..5822a405e 100644 --- a/crates/ide-extension/src/package.rs +++ b/crates/ide-extension/src/package.rs @@ -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** (`-.oiplugin`): a zip of the manifest, the files diff --git a/crates/ide-extension/src/registry.rs b/crates/ide-extension/src/registry.rs index 172b580d7..e8630fd85 100644 --- a/crates/ide-extension/src/registry.rs +++ b/crates/ide-extension/src/registry.rs @@ -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, - decl: toml::Value, label: String, - scope: Scope, disposable: Disposable, } @@ -43,7 +33,7 @@ pub struct PreparedSet { pub files: Vec, /// Declarations for extension points this host does not know (skipped). pub warnings: Vec, - 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 = 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 { - 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 = { - let mut entries = self.entries.lock(); - let Some(list) = entries.get_mut(plugin) else { return false }; - let (runtime, keep): (Vec, Vec) = 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 { 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 { 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"]); - } } diff --git a/crates/ide-extension/src/ui.rs b/crates/ide-extension/src/ui.rs index e12bfceec..9a59029b2 100644 --- a/crates/ide-extension/src/ui.rs +++ b/crates/ide-extension/src/ui.rs @@ -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); diff --git a/crates/ide-host/benches/plugins.rs b/crates/ide-host/benches/plugins.rs index 491fc974a..c0a5782f4 100644 --- a/crates/ide-host/benches/plugins.rs +++ b/crates/ide-host/benches/plugins.rs @@ -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 diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs index d55fb96ed..99b736308 100644 --- a/crates/ide-host/src/bin/openidea-plugin/main.rs +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -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 = 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)"); } diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 984466712..4e19ba134 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -79,28 +79,40 @@ async fn plugin_call(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 { + 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::>() + .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); + } +} diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index da79e228c..22726195d 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -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, - pub registry: Arc, pub workspace: RwLock>>, /// `/.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, - registry: Arc, data_dir: PathBuf, pins: Arc>>, ui: Arc, @@ -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 { - 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 { self.shared.workspace().map(|w| w.projects()).unwrap_or_default() } @@ -754,13 +739,7 @@ mod tests { ) .unwrap(); let sink: Arc = 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 } } diff --git a/crates/ide-host/src/plugins/install.rs b/crates/ide-host/src/plugins/install.rs index 1409b5f81..5056300a9 100644 --- a/crates/ide-host/src/plugins/install.rs +++ b/crates/ide-host/src/plugins/install.rs @@ -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: //! `///` 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 diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index 8e34c2b63..771ac3597 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -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 diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 4ca93b2fb..745be9baf 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -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>, - /// 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 + Clone + Send + 'static, ) -> IdeResult { 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); } { diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index f6f32f9e3..9874bd400 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -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..` in Search diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs index aa457b54c..d60b806a2 100644 --- a/crates/ide-host/src/plugins/providers.rs +++ b/crates/ide-host/src/plugins/providers.rs @@ -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( + /// 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( &self, - plugin: &Arc, - f: impl FnOnce(&mut Runtime, Duration) -> Result + Send + 'static, - ) -> Option { - 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 + Clone + Send + 'static, + ) -> Vec<(Arc, 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( @@ -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 { - 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 diff --git a/crates/ide-host/src/plugins/remote.rs b/crates/ide-host/src/plugins/remote.rs index b1d8ffa21..e3666cf47 100644 --- a/crates/ide-host/src/plugins/remote.rs +++ b/crates/ide-host/src/plugins/remote.rs @@ -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 diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 86986e57a..2514ee809 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -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. diff --git a/crates/ide-host/src/plugins/run_detectors.rs b/crates/ide-host/src/plugins/run_detectors.rs index 0fdb92c91..c48b001e6 100644 --- a/crates/ide-host/src/plugins/run_detectors.rs +++ b/crates/ide-host/src/plugins/run_detectors.rs @@ -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]] diff --git a/crates/ide-host/src/plugins/theme.rs b/crates/ide-host/src/plugins/theme.rs index 2de8eabdb..e4f38ff51 100644 --- a/crates/ide-host/src/plugins/theme.rs +++ b/crates/ide-host/src/plugins/theme.rs @@ -96,11 +96,9 @@ impl ThemesPoint { #[derive(Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] struct Decl { - path: Option, + path: PathBuf, #[serde(default)] editor_scheme: Option, - /// The theme JSON inline (runtime `register-theme`). - definition: Option, } impl ExtensionPoint for ThemesPoint { @@ -114,15 +112,9 @@ impl ExtensionPoint for ThemesPoint { fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result { 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()))?; diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 538e402e4..88d3c89c6 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -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. diff --git a/crates/ide-host/src/plugins/views.rs b/crates/ide-host/src/plugins/views.rs index e096ddb30..fd4ed77cf 100644 --- a/crates/ide-host/src/plugins/views.rs +++ b/crates/ide-host/src/plugins/views.rs @@ -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. diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs index f7a1ac64a..8be3f21aa 100644 --- a/crates/ide-host/tests/distribution.rs +++ b/crates/ide-host/tests/distribution.rs @@ -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}; diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 80611eb51..0dcaf7b4e 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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::>(), ["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() { diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs index 0b5ac11ce..953a68934 100644 --- a/crates/ide-host/tests/remote_plugins.rs +++ b/crates/ide-host/tests/remote_plugins.rs @@ -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)] diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index ca544edfc..cef38d97f 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -169,7 +169,7 @@ pub struct DebuggerConfig { pub pinned: Option, } -/// 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. diff --git a/crates/ide-language/src/plugin_point.rs b/crates/ide-language/src/plugin_point.rs index 1aa4cbe56..b6558ff12 100644 --- a/crates/ide-language/src/plugin_point.rs +++ b/crates/ide-language/src/plugin_point.rs @@ -128,9 +128,7 @@ impl LanguagesPoint { #[serde(deny_unknown_fields)] struct Decl { /// A `languages/.toml`-style file in the package. - path: Option, - /// The same, inline (runtime `register-language`). - definition: Option, + 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 { 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")); diff --git a/crates/ide-language/src/syntax/external.rs b/crates/ide-language/src/syntax/external.rs index 3d91a14a8..6399a2f01 100644 --- a/crates/ide-language/src/syntax/external.rs +++ b/crates/ide-language/src/syntax/external.rs @@ -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 diff --git a/crates/ide-plugin/Cargo.toml b/crates/ide-plugin/Cargo.toml index 0cb208221..0c3bf091b 100644 --- a/crates/ide-plugin/Cargo.toml +++ b/crates/ide-plugin/Cargo.toml @@ -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"] } diff --git a/crates/ide-plugin/src/actor.rs b/crates/ide-plugin/src/actor.rs index cf716d043..9897aa34e 100644 --- a/crates/ide-plugin/src/actor.rs +++ b/crates/ide-plugin/src/actor.rs @@ -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 { + result: mpsc::Receiver>, + give_up: Option, + alive: Arc, +} + +impl Pending { + /// Waits for the outcome, until the call's soft deadline if it has one. + pub fn wait(self) -> Result { + 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>>, thread: Mutex>>, @@ -97,6 +123,17 @@ impl Actor { wait: Option, f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, ) -> Result { + 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( + &self, + wait: Option, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + ) -> Result, 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 diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index bebcad554..fd27cb249 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -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::*; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 8a8d10c4b..f491af3c8 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -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); - /// 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; /// Roots of the open projects. fn projects(&self) -> Vec; /// 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 { @@ -200,12 +195,77 @@ impl PluginEngine { &self.engine } - /// Interfaces a component imports (`openidea:plugin/host@0.1.0`, `wasi:…`). - pub fn imports(&self, component: &Component) -> Vec { - 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, String> { + let ty = component.component_type(); + let imports: Vec = 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::>(); + 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/log@1.0.0`). +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) -> Result, String> { + let mut highest: Option = 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, pub(crate) host: Arc, pub(crate) invocation: Invocation, - pub(crate) effects: CallEffects, /// Tasks begun by the running call and not ended yet. pub(crate) open_tasks: Vec, pub(crate) last_report: Option, @@ -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 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) -> wasmtime::Result, ) -> Result { 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) -> wasmtime::Result, ) -> Result { 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 { + 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, CallEffects), CallError> { - let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| { + ) -> Result, 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, invocation: Invocation, - ) -> Result<(View, CallEffects), CallError> { + ) -> Result { 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 { + 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, - invocation: Invocation, - ) -> Result { + pub fn handle_events(&mut self, events: Vec, invocation: Invocation) -> Result<(), CallError> { let events: Vec = 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) -> wasmtime::Result, - ) -> Result<(R, CallEffects), CallError> { + ) -> Result { 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, limits: &Limits, timeout: Duration) -> wasmtime::Result<()> { store.set_fuel(limits.fuel)?; diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index e9074a700..9b4dfed07 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -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. diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 5de21e760..265f992a0 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -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 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(&mut self, value: T) -> wasmtime::Result> { 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) -> 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, diff --git a/crates/ide-plugin/tests/api_compat.rs b/crates/ide-plugin/tests/api_compat.rs new file mode 100644 index 000000000..f7dad376a --- /dev/null +++ b/crates/ide-plugin/tests/api_compat.rs @@ -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 { + 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::>(); + 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/poll@0.2.6", "openidea:plugin/log@1.0.0", "openidea:plugin/views@1.2.0"]; + assert_eq!(component_api(names), Ok(Some(v("1.2.0")))); + assert_eq!(component_api(["wasi:cli/stdout@0.2.6"]), 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; + +fn stub_resource(n: usize) -> ResourceType { + macro_rules! pick { ($($i:literal)*) => { match n { $($i => ResourceType::host::>(),)* _ => 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 { + 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 = 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:?}"); +} diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index 8ef7fdba7..d6018357a 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources new file mode 100644 index 000000000..1c5ed64f4 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -0,0 +1 @@ +0ad2e4c6977a576b7eb27fc1e535fd056dd9da64bdfe6cfda9ca9f57537d0384 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml index 964df0cd2..29dba7d67 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index dd9fba2a6..cd6d65112 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -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) -> Result, String> { + fn run(id: String, ctx: &Invocation, arg: Option) -> Result, 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:` 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::().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 { - if let Some(ms) = text.strip_prefix("sleep:").and_then(|n| n.trim().parse::().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) -> Vec { + 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 { + None + } + + fn code_actions(_doc: Document, _selection: types::Range) -> Vec { + Vec::new() + } + + fn definition(_doc: Document, _at: types::Position) -> Vec { + Vec::new() + } + + fn line_markers(_doc: Document) -> Vec { + Vec::new() + } +} + export!(Misbehave); diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit b/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit index d3cbece56..b1ff2c415 100644 --- a/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit +++ b/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit @@ -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:probe@0.1.0; diff --git a/crates/ide-plugin/tests/p0_probe.rs b/crates/ide-plugin/tests/p0_probe.rs index 11026d12b..d9295b802 100644 --- a/crates/ide-plugin/tests/p0_probe.rs +++ b/crates/ide-plugin/tests/p0_probe.rs @@ -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 diff --git a/crates/ide-run/src/external.rs b/crates/ide-run/src/external.rs index 0be3405d2..340a714cb 100644 --- a/crates/ide-run/src/external.rs +++ b/crates/ide-run/src/external.rs @@ -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. diff --git a/docs/plugin_p0.md b/docs/plugin_p0.md deleted file mode 100644 index 4c374dd71..000000000 --- a/docs/plugin_p0.md +++ /dev/null @@ -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/code-actions@0.1.0")` 在**编译后、实例化前**就能判断 - 组件有没有导出某个接口。安装时据此算出插件实现了哪些 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); - 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; - /// tree-sitter 查询语法;只返回被捕获的节点,最多 `limit` 个。 - query: func(source: string, range: option, limit: u32) -> result, error>; - errors: func(limit: u32) -> list; - } - record node { kind: string, range: range, text: string, parent-kind: option } - record capture { name: string, node: node } - /// 打开的文档用增量缓存树;未打开的文件现场解析(受 `project.read` 约束)。 - parse: func(path: string) -> result; - } - ``` - 不暴露节点句柄的游走 API(`parent`/`children` 逐个跨边界调用代价高);查询一次返回结果列表。 -- **插件提供语法树(export,P3)**:宿主没有 grammar 的语言(Solidity)。宿主不加载插件提供的原生 - grammar;插件在 Wasm 里自带解析器,导出 `syntax-provider`,按需返回**扁平结果**而不是树: - ```wit - interface syntax-provider { - highlights: func(text: string) -> list; // range + 语义类别(映射到 --syn-*) - outline: func(text: string) -> list; - folding: func(text: string) -> list; - } - ``` - 宿主把结果转换成现有的 `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; - symbols-named: func(name: string, kind: option, limit: u32) -> list; - find-files: func(query: string, limit: u32) -> list; - /// 插件自定义索引的查询,见下。 - lookup: func(index: string, key: string, limit: u32) -> list; - } - 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`;宿主在**后台**(复用 `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`。 diff --git a/docs/plugin_plan.md b/docs/plugin_plan.md deleted file mode 100644 index 180b40f5c..000000000 --- a/docs/plugin_plan.md +++ /dev/null @@ -1,601 +0,0 @@ -# 插件系统重构计划 - -状态:P0–P8 完成(P4 release 实测在预算内),WIT 冻结为 `openidea:plugin@1.0.0`。涉及 `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; -} - -pub struct Prepared { - pub capabilities: Vec, // 带 LSP 的语言 → process: - pub commit: Box Result + Send>, -} -``` - -- 原子生效:全部 `prepare` 成功才开始 `commit`;任一 `commit` 失败,把已 commit 的 `Disposable` 逆序撤销。 -- 前后端一致:所有校验都在 Rust 侧完成,前端只做投影。投影抛错时记为缺陷,不做部分生效。 -- 干净卸载:贡献记在所属插件名下,禁用或卸载时统一 dispose。 -- 前向兼容:不认识的 `contributes.` 告警并跳过,不让整包失败。 -- 前端投影:`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:`、`secrets`、按键与输入事件 | 安装页单独列出并说明用途 | -| 高 | `process:`、`fs.read:<项目外路径>` | 单独弹窗,明确提示“等同于在本机执行任意代码” | - -`process:` 说明: - -- 它是完全的代码执行权限,参数无法有效限制(例如 `git -c core.sshCommand=...`),不把它包装成细粒度权限。 -- 二进制在授权时按绝对路径解析并写入账本;运行时路径或文件哈希变化要重新确认。不在运行时按 PATH 查找, - 防止仓库在 PATH 前面放同名程序冒充。 -- 声明也算能力:带 `language-servers.command` 的语言包同样需要 `process:` 授权,否则语言注册修好后就成了沙箱绕过口。 - -`net:` 由宿主的 HTTP 客户端实现,每一跳重定向都校验主机;不向插件暴露原始 socket。 - -默认用声明式视图树(tree、list、form、toolbar),由 `src/ui` 渲染,自动跟随主题和无障碍规范。 -iframe 只用于富内容:严格 CSP、仅 `postMessage`、不暴露 Tauri API。第三方代码不进主 WebView。 - -### 4.3 WIT API - -开发期版本号用 `openidea:plugin@0.2.0`,**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; } } -interface editor { resource text-editor { - apply-edits: func(version: u64, edits: list) -> result<_, error>; } } -interface syntax { resource tree { node-at, query, errors } parse: func(path) -> result; } -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); - is-cancelled: func() -> bool; - cancelled: func() -> pollable; } } -// 另有 ui、vcs、net、process、storage、secrets - -interface commands { run: func(id: string, ctx: borrow, arg: option) - -> result, 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:` 声明同样效果);`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:}}`, - 宿主发出前替换(这就是“本地代理”的做法);远程项目里由服务器按需向桌面端请求。 -- 权限失败返回 `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///` 加 active 指针,支持原子升级和回滚;数据放 `data//`。 -- 市场:静态索引(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, 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:`,整个会话有效、跨空闲回收。`on-schedule` 到点投递 `scheduled`。 -- **任务**:`tasks.begin` 进入前端后台任务列表(可取消,取消同时取消宿主侧的 fetch),打开任务的调用改用 - 60s 后台预算;任务不跨调用。 -- **存储**:`plugins/.data//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:` 授权、拒绝跨主机重定向、 - 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[]`,插件经 `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:plugin@1.0.0` - -验收:升级失败能自动回滚;命中吊销列表的插件被禁用;10 个官方插件只改 `engines.openidea` 和 WIT 包版本即可切到 1.0。 - -完成情况: - -- **包与签名**(`ide_extension::package`):`-.oiplugin` 是 zip(manifest、声明引用的文件、组件、 - 许可证文件、可选的 `SIGNATURE.json`);解包拒绝越界路径、链接和超限内容。签名是 Ed25519,签的是 - `openidea-plugin/1\n\n\n<摘要>`,摘要覆盖 manifest 与包内文件。`openidea-plugin keygen`、 - `pack --key --publisher`、`index`(由一批包生成索引,保留已有吊销和旧公钥)。 -- **信任与吊销**:静态索引(`index.json`,格式 1)登记发布者及其公钥(信任根;发布者 `acme` 只能发布 - `acme.*`)、插件条目(含摘要)和吊销(某些版本或全部版本;公钥可标为吊销)。签名不成立(被改过、 - 不是该发布者的公钥、越权前缀、公钥已吊销)的包拒绝安装;无签名包从磁盘安装时提示。宿主保存最近一次读到 - 的索引(`market-index.json`),启动时、每次刷新(桌面端每天一次)都重新评估:命中的插件停用并给出原因、 - 通知用户,`set_enabled` 无法绕过;解除吊销后自动恢复。 -- **版本目录与回滚**(`plugins::install`):`///` 加 `active`、`previous` 指针,新版本 - 写完后才原子替换指针;更新后**新版本代码首次启动失败**(实例化失败、`activate` 出错或 trap)时自动回滚到 - 被替换的版本、删掉失败的版本、恢复旧的授权记录并通知用户;加载即失败的更新在安装时就回滚。设置页可手动 - “回滚到 …”。旧的平铺安装在下次更新时迁入版本目录;中断的迁移由 `recover` 复原。 -- **市场页**:设置 › 插件 › Available 填入索引 URL(`https://…/index.json` 或本地路径)后列出市场插件, - 安装/更新时下载、对照索引条目核对摘要和发布者签名,再走同一个授权安装页;已安装插件显示“由 … 签名 / - 未签名”。没有配置市场时仍显示本机可用的包。 -- **冻结 WIT 1.0**:包名改为 `openidea:plugin@1.0.0`,宿主 `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:` 还是只给 `process:` | 影响能力模型 | 已定(P7):Kubernetes 只要 `process:kubectl`,kubeconfig 由 kubectl 自己读,插件不读项目外文件;`fs.read` 仍保留为高风险能力,暂无使用者 | -| 发布者密钥丢失或泄露 | 影响已发布版本的可信度 | 吊销列表 + 发布者重新登记,P8 写成流程文档 | -| Wasm 单包体积(尤其 TS SDK) | 影响分发和内存 | P4 纳入性能预算观测 | diff --git a/perf/budgets.json b/perf/budgets.json index 21ad2c6e5..f831d10c2 100644 --- a/perf/budgets.json +++ b/perf/budgets.json @@ -1,5 +1,5 @@ { - "$comment": "Upper bounds, lower is better. ui: `.` 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: `.` 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": { diff --git a/plugins/README.md b/plugins/README.md index 4765e4212..fc4258823 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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:plugin@1.0.0`, 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, String> { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result, 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 _" 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/x@1.0.0` 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. diff --git a/plugins/build.sh b/plugins/build.sh index 2b30f45c0..0c2639015 100755 --- a/plugins/build.sh +++ b/plugins/build.sh @@ -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" diff --git a/plugins/hello/dist/plugin.wasm b/plugins/hello/dist/plugin.wasm index 5570638ca..b0c8caeec 100644 Binary files a/plugins/hello/dist/plugin.wasm and b/plugins/hello/dist/plugin.wasm differ diff --git a/plugins/hello/dist/plugin.wasm.sources b/plugins/hello/dist/plugin.wasm.sources new file mode 100644 index 000000000..72f8f773b --- /dev/null +++ b/plugins/hello/dist/plugin.wasm.sources @@ -0,0 +1 @@ +837c72fc3ab90de50efcd4fa3628d3174fe7f68382a822c6c2a161a330b689cf diff --git a/plugins/hello/plugin.toml b/plugins/hello/plugin.toml index 54e273038..6145272b0 100644 --- a/plugins/hello/plugin.toml +++ b/plugins/hello/plugin.toml @@ -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" diff --git a/plugins/hello/src/lib.rs b/plugins/hello/src/lib.rs index da009057b..26f754106 100644 --- a/plugins/hello/src/lib.rs +++ b/plugins/hello/src/lib.rs @@ -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(), ""); } } diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index ee5484557..ee11a42e8 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources new file mode 100644 index 000000000..43ce4cdf4 --- /dev/null +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -0,0 +1 @@ +bf264a97dc918f24ac00f0e9e0d0373b68391a96921b44345b466c2a087c5283 diff --git a/plugins/official/ai-completion/src/lib.rs b/plugins/official/ai-completion/src/lib.rs index a908831f9..c07d60f6c 100644 --- a/plugins/official/ai-completion/src/lib.rs +++ b/plugins/official/ai-completion/src/lib.rs @@ -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)) diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 13e6c1d64..b75085041 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources new file mode 100644 index 000000000..6192fb92e --- /dev/null +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -0,0 +1 @@ +6095c7e30658b93d79270e6355d42941a4cfe13eeb8bada07acc7ae462194853 diff --git a/plugins/official/checkstyle/src/lib.rs b/plugins/official/checkstyle/src/lib.rs index b4ef62b72..74f17cc48 100644 --- a/plugins/official/checkstyle/src/lib.rs +++ b/plugins/official/checkstyle/src/lib.rs @@ -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}"))), } } diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 4667b33e6..539a1dce2 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources new file mode 100644 index 000000000..d1b11a175 --- /dev/null +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -0,0 +1 @@ +b96941af697ce7a6c1c8ef1539d34aa7c23d2d41dd1b2ccffdfda5648f90cf56 diff --git a/plugins/official/csv-editor/src/lib.rs b/plugins/official/csv-editor/src/lib.rs index 046635fd2..38cad0ee8 100644 --- a/plugins/official/csv-editor/src/lib.rs +++ b/plugins/official/csv-editor/src/lib.rs @@ -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}"))) } } diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index e14440bf7..9479f183c 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources new file mode 100644 index 000000000..fd5094012 --- /dev/null +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -0,0 +1 @@ +ad545605b33c6f4e55a06a376333de3162739ed920b5d7d277942e50a4420083 diff --git a/plugins/official/git-toolbox/src/lib.rs b/plugins/official/git-toolbox/src/lib.rs index c5f36c805..5201eae95 100644 --- a/plugins/official/git-toolbox/src/lib.rs +++ b/plugins/official/git-toolbox/src/lib.rs @@ -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"); diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 1ca08c5f4..daefc884d 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources new file mode 100644 index 000000000..9965326dd --- /dev/null +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -0,0 +1 @@ +345c39f818e4950914bf3fcc795fac9e30ff2d87ad3e66f24c7bc00800f92980 diff --git a/plugins/official/key-promoter/src/lib.rs b/plugins/official/key-promoter/src/lib.rs index 97ce5d863..4636e3dba 100644 --- a/plugins/official/key-promoter/src/lib.rs +++ b/plugins/official/key-promoter/src/lib.rs @@ -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()) + ); } } diff --git a/plugins/official/kubernetes/dist/plugin.wasm b/plugins/official/kubernetes/dist/plugin.wasm index acae4b47d..8a55990b3 100644 Binary files a/plugins/official/kubernetes/dist/plugin.wasm and b/plugins/official/kubernetes/dist/plugin.wasm differ diff --git a/plugins/official/kubernetes/dist/plugin.wasm.sources b/plugins/official/kubernetes/dist/plugin.wasm.sources new file mode 100644 index 000000000..1c2a824c5 --- /dev/null +++ b/plugins/official/kubernetes/dist/plugin.wasm.sources @@ -0,0 +1 @@ +3499cfcae4a0388587a11b38158f421b0cf5046eeda38ae23b0f7700728c92a8 diff --git a/plugins/official/kubernetes/src/lib.rs b/plugins/official/kubernetes/src/lib.rs index 9e41bf1b5..486d617e1 100644 --- a/plugins/official/kubernetes/src/lib.rs +++ b/plugins/official/kubernetes/src/lib.rs @@ -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) } diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index dc36a1647..5ed3800aa 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources new file mode 100644 index 000000000..0241ff751 --- /dev/null +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -0,0 +1 @@ +f7df58bb0ab2b92370b148de2614174ec1ff9b01c0078b5c20bd331f82298616 diff --git a/plugins/official/mybatis/src/lib.rs b/plugins/official/mybatis/src/lib.rs index eca8ba1ba..d10b135a5 100644 --- a/plugins/official/mybatis/src/lib.rs +++ b/plugins/official/mybatis/src/lib.rs @@ -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}"))) } } diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index e6d0c908d..3cb17fd7c 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources new file mode 100644 index 000000000..d7a74f4c1 --- /dev/null +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -0,0 +1 @@ +3893bd8ad06d3e19c0112081c6643b3bf3c67f5a184098432a6fd338426cd682 diff --git a/plugins/official/solidity/src/lib.rs b/plugins/official/solidity/src/lib.rs index 5b7abcd47..def94beeb 100644 --- a/plugins/official/solidity/src/lib.rs +++ b/plugins/official/solidity/src/lib.rs @@ -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, diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 1280d055b..1c33e3e43 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources new file mode 100644 index 000000000..2dbf488f3 --- /dev/null +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -0,0 +1 @@ +a88c39d5782369329efa55b12330daa906e5c8d91252bd57c7343648840bac79 diff --git a/plugins/official/string-tools/src/lib.rs b/plugins/official/string-tools/src/lib.rs index c69ec6fc5..805b78ebf 100644 --- a/plugins/official/string-tools/src/lib.rs +++ b/plugins/official/string-tools/src/lib.rs @@ -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"); } } diff --git a/sdk/rust/openidea-plugin/Cargo.lock b/sdk/rust/openidea-plugin/Cargo.lock index 5b8089e7c..9629fb20c 100644 --- a/sdk/rust/openidea-plugin/Cargo.lock +++ b/sdk/rust/openidea-plugin/Cargo.lock @@ -99,6 +99,7 @@ name = "openidea-plugin" version = "0.1.0" dependencies = [ "wit-bindgen", + "wit-parser", ] [[package]] diff --git a/sdk/rust/openidea-plugin/Cargo.toml b/sdk/rust/openidea-plugin/Cargo.toml index 7728a8600..3f36703f7 100644 --- a/sdk/rust/openidea-plugin/Cargo.toml +++ b/sdk/rust/openidea-plugin/Cargo.toml @@ -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" diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 12cf7a822..187c5f8bb 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -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`). diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 5695cf5e7..f59d728c0 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -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, diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index e82a96cfa..33ce121dc 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -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"]); diff --git a/sdk/rust/openidea-plugin/src/providers.rs b/sdk/rust/openidea-plugin/src/providers.rs index 52a0d7446..c10f75cf9 100644 --- a/sdk/rust/openidea-plugin/src/providers.rs +++ b/sdk/rust/openidea-plugin/src/providers.rs @@ -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)] diff --git a/sdk/rust/openidea-plugin/src/syntax.rs b/sdk/rust/openidea-plugin/src/syntax.rs index b7b8516d5..74e860a93 100644 --- a/sdk/rust/openidea-plugin/src/syntax.rs +++ b/sdk/rust/openidea-plugin/src/syntax.rs @@ -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, [ diff --git a/sdk/rust/openidea-plugin/src/text.rs b/sdk/rust/openidea-plugin/src/text.rs index 2ba0f7afc..7f34e968e 100644 --- a/sdk/rust/openidea-plugin/src/text.rs +++ b/sdk/rust/openidea-plugin/src/text.rs @@ -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); + } +} diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs index 34237c474..5a74f67d3 100644 --- a/sdk/rust/openidea-plugin/src/types.rs +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -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 { diff --git a/sdk/rust/openidea-plugin/tests/api_frozen.rs b/sdk/rust/openidea-plugin/tests/api_frozen.rs new file mode 100644 index 000000000..e6723b428 --- /dev/null +++ b/sdk/rust/openidea-plugin/tests/api_frozen.rs @@ -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:?})"); + } +} diff --git a/sdk/rust/openidea-plugin/tests/wit_coverage.rs b/sdk/rust/openidea-plugin/tests/wit_coverage.rs new file mode 100644 index 000000000..e3ce3db4b --- /dev/null +++ b/sdk/rust/openidea-plugin/tests/wit_coverage.rs @@ -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 ")); +} diff --git a/sdk/rust/openidea-plugin/wit/exports.wit b/sdk/rust/openidea-plugin/wit/exports.wit index f907ff472..065ef9a5f 100644 --- a/sdk/rust/openidea-plugin/wit/exports.wit +++ b/sdk/rust/openidea-plugin/wit/exports.wit @@ -1,20 +1,25 @@ package openidea:plugin@1.0.0; 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 { diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index 750bfd342..b6325cb71 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -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}; diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index 84b20a900..938ee0842 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -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:plugin@1.0.0; world plugin { import log; - import contributions; import ui; import storage; import workspace; diff --git a/sdk/rust/openidea-plugin/wit/providers.wit b/sdk/rust/openidea-plugin/wit/providers.wit index af3f86eb4..cfca65609 100644 --- a/sdk/rust/openidea-plugin/wit/providers.wit +++ b/sdk/rust/openidea-plugin/wit/providers.wit @@ -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>, diff --git a/sdk/rust/openidea-plugin/wit/views.wit b/sdk/rust/openidea-plugin/wit/views.wit index 1dfbd1357..de1ae7801 100644 --- a/sdk/rust/openidea-plugin/wit/views.wit +++ b/sdk/rust/openidea-plugin/wit/views.wit @@ -1,6 +1,6 @@ package openidea:plugin@1.0.0; -/// 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) diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index d9d47b8f6..7be979e35 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -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}; diff --git a/src/features/plugins/consent.tsx b/src/features/plugins/consent.tsx index f978e05a0..017920b13 100644 --- a/src/features/plugins/consent.tsx +++ b/src/features/plugins/consent.tsx @@ -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. */ diff --git a/src/features/plugins/projection.ts b/src/features/plugins/projection.ts index b447a2f49..d2a286015 100644 --- a/src/features/plugins/projection.ts +++ b/src/features/plugins/projection.ts @@ -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 diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index a65172b32..e5e124537 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -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; diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx index 33406140e..2fac0b40a 100644 --- a/src/features/plugins/views/PluginView.tsx +++ b/src/features/plugins/views/PluginView.tsx @@ -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 diff --git a/wit/exports.wit b/wit/exports.wit index f907ff472..065ef9a5f 100644 --- a/wit/exports.wit +++ b/wit/exports.wit @@ -1,20 +1,25 @@ package openidea:plugin@1.0.0; 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 { diff --git a/wit/host.wit b/wit/host.wit index 750bfd342..b6325cb71 100644 --- a/wit/host.wit +++ b/wit/host.wit @@ -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}; diff --git a/wit/plugin.wit b/wit/plugin.wit index 84b20a900..938ee0842 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -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:plugin@1.0.0; world plugin { import log; - import contributions; import ui; import storage; import workspace; diff --git a/wit/providers.wit b/wit/providers.wit index af3f86eb4..cfca65609 100644 --- a/wit/providers.wit +++ b/wit/providers.wit @@ -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>, diff --git a/wit/views.wit b/wit/views.wit index 1dfbd1357..de1ae7801 100644 --- a/wit/views.wit +++ b/wit/views.wit @@ -1,6 +1,6 @@ package openidea:plugin@1.0.0; -/// 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)