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