feat(plugins): extension points, capability consent, async runtime and plugin API 1.0

Rework the plugin system per docs/plugin_plan.md (P0–P8):

- Extension-point registry (ide-extension) so new contribution types register
  without touching the kernel; languages, run detectors, providers, views and
  UI contributions go through it.
- Per-plugin capability consent with a risk-graded ledger; process
  capabilities are pinned to resolved binary paths and hashes.
- Async, actor-per-plugin Wasm runtime with lazy activation, cancellation,
  events, long-running tasks and idle reclamation.
- Plugin host moved into ide-host, shared by local and remote projects.
- Distribution: install/package tooling (openidea-plugin bin) and a static
  market index.
- WIT frozen as openidea:[email protected]; Rust SDK with mock host.
- Ten official validation plugins under plugins/official.
Цей коміт міститься в:
l0ng-ai committed 2026-11-04 02:41:46 +08:00
1 джерело 63801ba249
коміт fd1f465aeb
326 files changed
+35587 -2777

No files matched your search

Створено
+35 -2
Переглянути файл
@@ -4181,6 +4181,26 @@ dependencies = [
"which",
]
[[package]]
name = "ide-extension"
version = "0.1.34"
dependencies = [
"base64 0.22.1",
"blake3",
"ed25519-dalek",
"ide-core",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
"zip",
]
[[package]]
name = "ide-fonts"
version = "0.1.35"
@@ -4210,9 +4230,13 @@ dependencies = [
name = "ide-host"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"criterion",
"dirs 6.0.0",
"dunce",
"encoding_rs",
"getrandom 0.3.4",
"globset",
"ide-agent",
"ide-analysis",
"ide-build",
@@ -4220,12 +4244,14 @@ dependencies = [
"ide-coverage",
"ide-dap",
"ide-db",
"ide-extension",
"ide-http",
"ide-index",
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-notebook",
"ide-plugin",
"ide-remote",
"ide-run",
"ide-sdk",
@@ -4239,7 +4265,9 @@ dependencies = [
"ide-vcs",
"ide-vfs",
"ide-workspace",
"ignore",
"parking_lot",
"reqwest",
"rustix",
"serde",
"serde_json",
@@ -4247,6 +4275,7 @@ dependencies = [
"specta-typescript",
"tempfile",
"tokio",
"toml 0.9.12+spec-1.1.0",
"tracing",
"tracing-subscriber",
"uuid",
@@ -4316,6 +4345,7 @@ dependencies = [
"blake3",
"criterion",
"ide-core",
"ide-extension",
"ide-text",
"parking_lot",
"regex-lite",
@@ -4417,19 +4447,20 @@ dependencies = [
name = "ide-plugin"
version = "0.1.35"
dependencies = [
"blake3",
"ide-core",
"ide-language",
"ide-extension",
"parking_lot",
"serde",
"serde_json",
"specta",
"tempfile",
"thiserror 2.0.21",
"tokio",
"toml 0.9.12+spec-1.1.0",
"tracing",
"wasmtime",
"wasmtime-wasi",
"zip",
]
[[package]]
@@ -6164,6 +6195,7 @@ dependencies = [
"ide-dap",
"ide-db",
"ide-docker",
"ide-extension",
"ide-fonts",
"ide-github",
"ide-host",
@@ -6195,6 +6227,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-web-kit 0.3.2",
"parking_lot",
"security-framework",
"serde",
"serde_json",
"specta",
+5
Переглянути файл
@@ -1,6 +1,8 @@
[workspace]
resolver = "3"
members = ["crates/*", "src-tauri"]
# Plugins and the guest SDK build for wasm32-wasip2 in their own workspaces.
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.35"
@@ -41,6 +43,8 @@ ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -69,6 +73,7 @@ ropey = "1.6"
encoding_rs = "0.8"
chardetng = "0.1"
ignore = "0.4"
base64 = "0.22"
globset = "0.4"
notify = "8"
notify-debouncer-full = "0.6"
+4
Переглянути файл
@@ -61,6 +61,9 @@ pub struct Settings {
pub cmake: CMakeSettings,
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
}
/// What the meters in an agent task's header show.
@@ -215,6 +218,7 @@ impl Default for Settings {
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
plugins: BTreeMap::new(),
}
}
}
+19 -7
Переглянути файл
@@ -8,7 +8,7 @@
use ide_core::process::executable_names;
use ide_core::{IdeError, IdeResult};
use ide_language::{DebuggerConfig, LanguageRegistry};
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -44,6 +44,8 @@ pub struct AdapterSpec {
/// `host:port` of an adapter inside another program (the Godot editor): connected to
/// when it accepts, else `command` is started.
pub connect: Option<String>,
/// A plugin language's adapter: start exactly this approved program.
pub pinned: Option<ProgramPin>,
}
/// Built-in adapters used when no language definition names `adapter`.
@@ -55,6 +57,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
transport: transport.to_owned(),
connect: None,
install_hint: Some(hint.to_owned()),
pinned: None,
};
Some(match adapter {
"codelldb" => cfg(
@@ -133,6 +136,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
env: Vec::new(),
server_host: None,
connect: config.connect.clone(),
pinned: config.pinned.clone(),
}
}
@@ -426,12 +430,20 @@ impl AdapterLauncher for ProcessLauncher {
if is_plugin_bundle(&spec.command) {
return Err(not_standalone(&spec));
}
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
.await
.ok()
.flatten()
.ok_or_else(|| missing(&spec))?;
let binary = match spec.pinned.clone() {
Some(pin) => tokio::task::spawn_blocking(move || pin.verify().map(Path::to_path_buf))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?,
None => {
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
.await
.ok()
.flatten()
.ok_or_else(|| missing(&spec))?
}
};
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
+56
Переглянути файл
@@ -915,6 +915,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
transport: "tcp".into(),
connect: Some(format!("127.0.0.1:{port}")),
install_hint: Some("Open the project in the Godot editor".into()),
pinned: None,
}),
};
Arc::new(LanguageRegistry::from_configs(vec![lang]))
@@ -1147,3 +1148,58 @@ async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
h.manager.stop(id).await.unwrap();
}
}
/// Plan P3: 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)]
#[tokio::test]
async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
use ide_language::ProgramPin;
use ide_language::plugin_point::{PluginLanguages, parse_definition};
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("openidea-plugin-dap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Stands in for `godot --headless --editor …`.
let program = dir.join("godot");
std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
let debugger = gdscript.debugger.as_mut().unwrap();
debugger.args.clear();
debugger.connect = Some(format!("127.0.0.1:{port}"));
// No built-in GDScript: it comes from the plugin.
let registry = Arc::new(LanguageRegistry::from_configs(Vec::new()));
let store = PluginLanguages::new();
registry.attach_plugins(store.clone());
let pin = ProgramPin::approve(&program).unwrap();
store.set_process_policy(Arc::new(move |plugin, cap| {
(plugin == "acme.godot" && cap.to_string() == "process:godot").then(|| pin.clone())
}));
let _language = store.add("acme.godot".into(), gdscript);
let log: Log = Arc::default();
let server_log = log.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
let (stream, _) = listener.accept().await.unwrap();
fake_adapter(stream, server_log, Script::default()).await;
});
let (owner, _events) = recording_owner("main");
let manager = DebugManager::new(registry.clone());
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
assert!(log.lock().iter().any(|m| m["command"] == "launch"));
manager.stop(id).await.unwrap();
// Replaced after approval: refused before anything starts.
std::fs::write(&program, "#!/bin/sh\nexec sleep 31\n").unwrap();
let (owner, _events) = recording_owner("main");
let err = manager.start(godot_launch(&dir), owner).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
let _ = std::fs::remove_dir_all(&dir);
}
+24
Переглянути файл
@@ -0,0 +1,24 @@
[package]
name = "ide-extension"
description = "Plugin manifest, extension points, contribution registry and capability model (no Wasm runtime)"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ide-core.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
specta-typescript.workspace = true
toml.workspace = true
tracing.workspace = true
parking_lot.workspace = true
semver = "1"
blake3.workspace = true
zip.workspace = true
base64.workspace = true
ed25519-dalek = "3"
[dev-dependencies]
tempfile.workspace = true
+261
Переглянути файл
@@ -0,0 +1,261 @@
//! What a plugin may do beyond pure computation, and how risky each grant is.
//!
//! Capabilities are written as strings in the manifest (`project.read`,
//! `net:crates.io`, `process:cargo`) and stored the same way in the grant
//! ledger. The 0.1 permission names (`project-read`, …) parse as their 0.2
//! equivalents.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use specta::Type;
use std::fmt;
use std::str::FromStr;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Capability {
/// Read files inside the project root; the project root itself.
ProjectRead,
/// The focused editor's file, selection and text.
EditorRead,
/// Edit the focused editor (versioned edits).
EditorWrite,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
Storage,
/// The plugin's own secrets (kept in the client's OS keychain).
Secrets,
/// Workbench events (file opened, saved, focus…), not keystrokes.
Events,
/// Keystrokes and editor input events.
InputEvents,
/// HTTP(S) to one host (`*.example.com` matches subdomains).
Net(String),
/// Run one external program. Full code execution: never fine-grained.
Process(String),
/// Read files outside the project under one path.
FsRead(String),
}
/// How a grant is presented (plan 4.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum Risk {
/// Listed on the install page, one confirmation.
Low,
/// Listed separately with its purpose.
Medium,
/// Separate dialog: "equivalent to running arbitrary code on this machine".
High,
}
impl Capability {
pub fn risk(&self) -> Risk {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents => Risk::Medium,
Self::Process(_) | Self::FsRead(_) => Risk::High,
}
}
/// Low-risk capabilities that the one-time migration of the 0.1 "declared
/// means granted" behaviour may grant automatically (plan 4.6).
pub fn auto_migrates(&self) -> bool {
self.risk() == Risk::Low
}
/// Short user-facing description for install and grant pages.
pub fn describe(&self) -> String {
match self {
Self::ProjectRead => "Read files in the open project".into(),
Self::EditorRead => "Read the focused editor's file and selection".into(),
Self::EditorWrite => "Change text in the editor".into(),
Self::VcsRead => "Read version control state".into(),
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
Self::Storage => "Store its own data".into(),
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
}
}
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
pub fn covers(&self, wanted: &Capability) -> bool {
match (self, wanted) {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::FsRead(base), Self::FsRead(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
_ => self == wanted,
}
}
}
/// `example.com` matches exactly; `*.example.com` matches any subdomain (not the apex).
pub fn host_matches(pattern: &str, host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
let pattern = pattern.to_ascii_lowercase();
match pattern.strip_prefix("*.") {
Some(suffix) => host.len() > suffix.len() + 1 && host.ends_with(&format!(".{suffix}")),
None => host == pattern,
}
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ProjectRead => f.write_str("project.read"),
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
Self::Secrets => f.write_str("secrets"),
Self::Events => f.write_str("events"),
Self::InputEvents => f.write_str("events.input"),
Self::Net(h) => write!(f, "net:{h}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
}
}
}
impl FromStr for Capability {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
let s = s.trim();
if let Some((kind, arg)) = s.split_once(':') {
let arg = arg.trim();
if arg.is_empty() {
return Err(format!("capability {s:?} needs a value after ':'"));
}
return match kind {
"net" => {
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
&& host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
}
"process" => {
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
return Err(format!("{s:?}: use a program name or an absolute path"));
}
Ok(Self::Process(arg.to_owned()))
}
"fs.read" => {
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
}
Ok(Self::FsRead(arg.to_owned()))
}
_ => Err(format!("unknown capability {s:?}")),
};
}
Ok(match s {
"project.read" | "project-read" => Self::ProjectRead,
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
"secrets" => Self::Secrets,
"events" => Self::Events,
"events.input" => Self::InputEvents,
_ => return Err(format!("unknown capability {s:?}")),
})
}
}
impl Serialize for Capability {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Capability {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
String::deserialize(d)?.parse().map_err(serde::de::Error::custom)
}
}
/// A capability as the UI shows it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CapabilityInfo {
/// `project.read`, `net:crates.io`, …
pub id: String,
pub risk: Risk,
pub description: String,
/// Declared under `optional`: the plugin runs without it.
pub optional: bool,
/// Currently granted.
pub granted: bool,
}
impl CapabilityInfo {
pub fn new(cap: &Capability, optional: bool, granted: bool) -> Self {
Self { id: cap.to_string(), risk: cap.risk(), description: cap.describe(), optional, granted }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_prints() {
for s in [
"project.read",
"editor.write",
"vcs.write",
"storage",
"secrets",
"events",
"events.input",
"net:crates.io",
"net:*.github.com",
"process:cargo",
"process:/usr/bin/git",
"fs.read:/etc",
] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
assert!("net:".parse::<Capability>().is_err());
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
}
#[test]
fn risk_levels() {
assert_eq!(Capability::ProjectRead.risk(), Risk::Low);
assert_eq!(Capability::Net("x".into()).risk(), Risk::Medium);
assert_eq!(Capability::InputEvents.risk(), Risk::Medium);
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
}
#[test]
fn host_patterns() {
assert!(host_matches("crates.io", "crates.io"));
assert!(host_matches("crates.io", "CRATES.IO."));
assert!(!host_matches("crates.io", "static.crates.io"));
assert!(host_matches("*.github.com", "api.github.com"));
assert!(!host_matches("*.github.com", "github.com"));
assert!(!host_matches("*.github.com", "evilgithub.com"));
let any_gh: Capability = "net:*.github.com".parse().unwrap();
assert!(any_gh.covers(&"net:api.github.com".parse().unwrap()));
assert!(Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etc/hosts".into())));
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
}
}
+104
Переглянути файл
@@ -0,0 +1,104 @@
//! The contribution index (docs/plugin_plan.md 4.5): validated UI
//! declarations of installed packages, kept on disk so start-up does not
//! validate them again (sanitising thousands of icon SVGs, say).
//!
//! Only packages with a [`Package::digest`] are indexed: installed copies,
//! which the host never modifies after install. An entry is keyed by that
//! digest, the extension point, the declaration and a salt naming the
//! validation logic (the IDE version), so a new package version, a changed
//! declaration or an IDE upgrade simply misses. A miss or an unreadable entry
//! only costs the validation.
use crate::point::Package;
use crate::ui::UiDecl;
use std::collections::BTreeSet;
use std::path::PathBuf;
/// Bumped when [`UiDecl`]'s serialized shape changes.
const FORMAT: u32 = 1;
pub struct ContributionIndex {
dir: PathBuf,
salt: String,
}
impl ContributionIndex {
/// `salt` names the validators' version (the IDE version).
pub fn new(dir: PathBuf, salt: &str) -> Self {
Self { dir, salt: format!("{FORMAT}:{salt}") }
}
/// Entries start with a prefix of the package digest, for pruning.
fn file(&self, digest: &str, point: &str, decl: &toml::Value) -> PathBuf {
let mut h = blake3::Hasher::new();
for part in [self.salt.as_str(), digest, point, &decl.to_string()] {
h.update(part.as_bytes());
h.update(&[0]);
}
let key = h.finalize().to_hex();
self.dir.join(format!("{}-{}.json", prefix(digest), &key[..32]))
}
pub fn get(&self, pkg: &Package, point: &str, decl: &toml::Value) -> Option<UiDecl> {
let digest = pkg.digest.as_deref()?;
let bytes = std::fs::read(self.file(digest, point, decl)).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn put(&self, pkg: &Package, point: &str, decl: &toml::Value, ui: &UiDecl) {
let Some(digest) = pkg.digest.as_deref() else { return };
let Ok(bytes) = serde_json::to_vec(ui) else { return };
let file = self.file(digest, point, decl);
let _ = std::fs::create_dir_all(&self.dir);
let tmp = file.with_extension(format!("tmp{}", std::process::id()));
if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).is_err() {
let _ = std::fs::remove_file(&tmp);
}
}
/// Removes entries of packages other than `live` (digests).
pub fn prune<'a>(&self, live: impl IntoIterator<Item = &'a str>) {
let keep: BTreeSet<String> = live.into_iter().map(|d| prefix(d).to_owned()).collect();
let Ok(entries) = std::fs::read_dir(&self.dir) else { return };
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.split_once('-').is_some_and(|(p, _)| keep.contains(p)) {
let _ = std::fs::remove_file(e.path());
}
}
}
}
fn prefix(digest: &str) -> &str {
let d = digest.strip_prefix("blake3:").unwrap_or(digest);
let end = d.char_indices().nth(16).map_or(d.len(), |(i, _)| i);
&d[..end]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
#[test]
fn hits_only_the_same_package_point_declaration_and_salt() {
let tmp = tempfile::tempdir().unwrap();
let index = ContributionIndex::new(tmp.path().join("idx"), "1.0");
let manifest =
Manifest::parse("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n").unwrap();
let pkg = Package::new(manifest, tmp.path().to_path_buf()).with_digest("blake3:00aa11bb22cc33dd44ee");
let decl: toml::Value = toml::from_str("id = \"x\"").unwrap();
let ui = UiDecl::new("x", "X", serde_json::json!({ "id": "x" }));
assert!(index.get(&pkg, "pt", &decl).is_none());
index.put(&pkg, "pt", &decl, &ui);
assert_eq!(index.get(&pkg, "pt", &decl).unwrap().label, "X");
assert!(index.get(&pkg, "other", &decl).is_none());
assert!(index.get(&pkg, "pt", &toml::from_str("id = \"y\"").unwrap()).is_none());
assert!(ContributionIndex::new(tmp.path().join("idx"), "1.1").get(&pkg, "pt", &decl).is_none());
let unhashed = Package { digest: None, ..pkg.clone() };
assert!(index.get(&unhashed, "pt", &decl).is_none(), "not an installed copy");
index.prune(["blake3:ffff"]);
assert!(index.get(&pkg, "pt", &decl).is_none());
}
}
+483
Переглянути файл
@@ -0,0 +1,483 @@
//! The grant ledger (plan 4.6): which capabilities the user granted to which
//! plugin, saved as `grants.json` next to the installed plugins.
//!
//! - Installed plugins are keyed by id and remember the version and package
//! hash they were confirmed for. An update that needs no new capability is
//! accepted silently; one that does must be confirmed again.
//! - Linked development plugins and project plugins (`.openidea/plugins/*`)
//! are keyed by their directory, not by hash, so rebuilding does not ask
//! again; new capabilities still do.
//! - `process:<program>` grants record the program's absolute path and file
//! hash at grant time. Calls use that path, never a `PATH` lookup, and fail
//! when the file changed (re-confirmation needed).
use crate::capability::{Capability, Risk};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub const LEDGER_FILE: &str = "grants.json";
const FORMAT: u32 = 1;
/// Whose grant an entry is.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
pub enum Subject {
/// Installed under the plugins directory, by plugin id.
Installed(String),
/// A linked development plugin or a project plugin, by package directory.
Path(PathBuf),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BinaryGrant {
pub path: PathBuf,
/// blake3 of the file when granted.
pub hash: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Grant {
pub plugin_id: String,
/// The version and package hash the user confirmed (installed plugins).
#[serde(default)]
pub version: String,
#[serde(default)]
pub package_hash: String,
/// The user confirmed the plugin (the install page or the project-plugin prompt).
#[serde(default)]
pub confirmed: bool,
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
/// `process:<program>` → the program confirmed for it.
#[serde(default)]
pub binaries: BTreeMap<String, BinaryGrant>,
/// Migrated from 0.1: capabilities not granted yet are asked for on first
/// use instead of blocking the plugin from loading.
#[serde(default)]
pub ask_on_use: bool,
}
impl Grant {
pub fn has(&self, cap: &Capability) -> bool {
self.granted.iter().filter_map(|s| s.parse::<Capability>().ok()).any(|g| g.covers(cap))
}
}
/// What a plugin (in its current form) needs, to compare with its grant.
#[derive(Clone, Debug)]
pub struct Request<'a> {
pub subject: Subject,
pub plugin_id: &'a str,
pub version: &'a str,
pub package_hash: &'a str,
pub required: Vec<Capability>,
pub optional: Vec<Capability>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Decision {
/// Load with these capabilities. `pending` required ones are asked for when
/// the plugin is first used (plugins migrated from 0.1 only).
Allowed { granted: Vec<Capability>, pending: Vec<Capability> },
/// Ask the user first. `new` lists what was never granted (all of it on first use).
NeedsConfirmation { new: Vec<Capability>, first_time: bool },
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct File {
#[serde(default)]
format: u32,
/// The one-time migration from 0.1 ("declared means granted") ran.
#[serde(default)]
migrated: bool,
#[serde(default)]
grants: Vec<(Subject, Grant)>,
}
/// The ledger, loaded into memory; [`Ledger::save`] writes it atomically.
#[derive(Debug, Default)]
pub struct Ledger {
path: Option<PathBuf>,
migrated: bool,
grants: BTreeMap<Subject, Grant>,
}
impl Ledger {
/// Loads `<dir>/grants.json`; a missing or unreadable file gives an empty
/// ledger with `needs_migration()` true.
pub fn load(dir: &Path) -> Self {
let path = dir.join(LEDGER_FILE);
let file: Option<File> = std::fs::read_to_string(&path).ok().and_then(|s| match serde_json::from_str(&s) {
Ok(f) => Some(f),
Err(e) => {
tracing::warn!("{}: {e}; starting with an empty grant ledger", path.display());
None
}
});
let existed = file.is_some();
let file = file.unwrap_or_default();
Self { path: Some(path), migrated: file.migrated || existed, grants: file.grants.into_iter().collect() }
}
pub fn in_memory() -> Self {
Self { path: None, migrated: true, grants: BTreeMap::new() }
}
pub fn save(&self) -> std::io::Result<()> {
let Some(path) = &self.path else { return Ok(()) };
let file = File {
format: FORMAT,
migrated: self.migrated,
grants: self.grants.iter().map(|(s, g)| (s.clone(), g.clone())).collect(),
};
let json = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
}
pub fn get(&self, subject: &Subject) -> Option<&Grant> {
self.grants.get(subject)
}
pub fn remove(&mut self, subject: &Subject) -> Option<Grant> {
self.grants.remove(subject)
}
/// Puts back an earlier state of `subject`'s grant (`None`: none), e.g.
/// after rolling back an update.
pub fn restore(&mut self, subject: &Subject, grant: Option<Grant>) {
match grant {
Some(g) => {
self.grants.insert(subject.clone(), g);
}
None => {
self.grants.remove(subject);
}
}
}
/// The one-time migration of 0.1's "declared means granted" has not run yet.
pub fn needs_migration(&self) -> bool {
!self.migrated
}
/// Migrates plugins installed before the ledger existed: low-risk
/// capabilities are granted, the rest are asked on first use.
pub fn migrate(&mut self, installed: &[Request<'_>]) {
for req in installed {
let granted: BTreeSet<String> =
req.required.iter().chain(&req.optional).filter(|c| c.auto_migrates()).map(|c| c.to_string()).collect();
self.grants.entry(req.subject.clone()).or_insert_with(|| Grant {
plugin_id: req.plugin_id.to_owned(),
version: req.version.to_owned(),
package_hash: req.package_hash.to_owned(),
confirmed: true,
granted,
ask_on_use: true,
..Grant::default()
});
}
self.migrated = true;
}
/// Compares what the plugin needs with what was granted.
pub fn decide(&mut self, req: &Request<'_>) -> Decision {
let Some(grant) = self.grants.get_mut(&req.subject) else {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
};
if !grant.confirmed {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
}
let new: Vec<Capability> = req.required.iter().filter(|c| !grant.has(c)).cloned().collect();
if !new.is_empty() && !grant.ask_on_use {
return Decision::NeedsConfirmation { new, first_time: false };
}
// Same or fewer needs: accept an update silently (plan 4.6).
if grant.version != req.version || grant.package_hash != req.package_hash {
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
}
let granted = req.required.iter().chain(&req.optional).filter(|c| grant.has(c)).cloned().collect();
Decision::Allowed { granted, pending: new }
}
/// Records the user's confirmation: `required` plus the accepted optional capabilities.
pub fn confirm(
&mut self,
req: &Request<'_>,
accepted_optional: &[Capability],
binaries: BTreeMap<String, BinaryGrant>,
) {
let grant = self.grants.entry(req.subject.clone()).or_default();
grant.plugin_id = req.plugin_id.to_owned();
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
grant.confirmed = true;
grant.ask_on_use = false;
for cap in req.required.iter().chain(accepted_optional) {
grant.granted.insert(cap.to_string());
}
for cap in &req.optional {
let s = cap.to_string();
if accepted_optional.contains(cap) {
grant.declined.remove(&s);
} else if !grant.granted.contains(&s) {
grant.declined.insert(s);
}
}
grant.binaries.extend(binaries);
}
/// Grants or revokes one capability (Settings › Plugins › permissions).
pub fn set(&mut self, subject: &Subject, cap: &Capability, granted: bool) -> bool {
let Some(grant) = self.grants.get_mut(subject) else { return false };
let s = cap.to_string();
if granted {
grant.declined.remove(&s);
grant.granted.insert(s)
} else {
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
}
grant.granted.remove(&s)
}
}
/// Records the program approved for a `process:` grant.
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
if let Some(grant) = self.grants.get_mut(subject) {
grant.binaries.insert(cap.to_string(), binary);
}
}
/// Checks a `process:` grant at call time: the program confirmed for it,
/// unchanged since. Errors name what to do.
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
let Capability::Process(name) = cap else { return Err(format!("{cap} is not a process capability")) };
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
let key = cap.to_string();
if !grant.granted.contains(&key) {
return Err(format!("{cap} was not granted"));
}
let bin = grant.binaries.get(&key).ok_or_else(|| format!("{cap}: no program was confirmed for {name}"))?;
match file_hash(&bin.path) {
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
Ok(_) => Err(format!(
"{} changed since it was approved for {cap}; confirm the plugin's permissions again",
bin.path.display()
)),
Err(e) => Err(format!("{}: {e}; confirm the plugin's permissions again", bin.path.display())),
}
}
/// The program approved for a granted `process:` capability, without
/// checking the file: whoever starts it verifies the hash then
/// ([`check_binary`](Self::check_binary) or the pin's own check).
pub fn pinned_binary(&self, subject: &Subject, cap: &Capability) -> Option<BinaryGrant> {
let grant = self.grants.get(subject)?;
let key = cap.to_string();
if !grant.granted.contains(&key) {
return None;
}
grant.binaries.get(&key).cloned()
}
pub fn subjects(&self) -> impl Iterator<Item = (&Subject, &Grant)> {
self.grants.iter()
}
}
/// What a confirmation page shows: every capability with its risk, high risk first.
pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
let mut groups: BTreeMap<std::cmp::Reverse<Risk>, Vec<Capability>> = BTreeMap::new();
for c in caps {
groups.entry(std::cmp::Reverse(c.risk())).or_default().push(c.clone());
}
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
}
/// Resolves a `process:` program to an absolute path once, at grant time:
/// absolute names are taken as is, others are looked up in `path_var`.
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
let candidate = Path::new(name);
if candidate.is_absolute() {
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
}
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
} else {
vec![String::new()]
};
for dir in std::env::split_paths(path_var.unwrap_or_default()) {
if !dir.is_absolute() {
continue; // `.` or relative entries could point into the project
}
for ext in &exts {
let p = dir.join(format!("{name}{ext}"));
if is_executable(&p) {
return Ok(dunce_canonical(&p));
}
}
}
Err(format!("{name} was not found on PATH"))
}
fn dunce_canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
let Ok(meta) = std::fs::metadata(p) else { return false };
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
}
/// blake3 of a file, hex.
pub fn file_hash(path: &Path) -> std::io::Result<String> {
let mut hasher = blake3::Hasher::new();
hasher.update_reader(std::fs::File::open(path)?)?;
Ok(hasher.finalize().to_hex().to_string())
}
/// blake3 over a package's files (sorted relative paths and contents).
pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
let mut sorted: Vec<&PathBuf> = files.iter().collect();
sorted.sort();
sorted.dedup();
let mut hasher = blake3::Hasher::new();
for rel in sorted {
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(&[0]);
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
hasher.update(&[0]);
}
Ok(hasher.finalize().to_hex().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn caps(list: &[&str]) -> Vec<Capability> {
list.iter().map(|s| s.parse().unwrap()).collect()
}
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
Request {
subject: Subject::Installed("p".into()),
plugin_id: "p",
version,
package_hash: hash,
required: caps(required),
optional: caps(optional),
}
}
#[test]
fn confirmation_then_silent_update_then_reconfirm_on_new_capability() {
let mut l = Ledger::in_memory();
let r1 = req("1.0.0", "h1", &["project.read"], &["net:crates.io"]);
assert_eq!(l.decide(&r1), Decision::NeedsConfirmation { new: caps(&["project.read"]), first_time: true });
l.confirm(&r1, &[], BTreeMap::new());
assert_eq!(
l.decide(&r1),
Decision::Allowed { granted: caps(&["project.read"]), pending: vec![] },
"declined optional"
);
assert!(l.get(&r1.subject).unwrap().declined.contains("net:crates.io"));
// Same needs, new version: silent.
let r2 = req("1.1.0", "h2", &["project.read"], &["net:crates.io"]);
assert!(matches!(l.decide(&r2), Decision::Allowed { .. }));
assert_eq!(l.get(&r2.subject).unwrap().version, "1.1.0");
// A new required capability: ask again, only for it.
let r3 = req("1.2.0", "h3", &["project.read", "editor.write"], &[]);
assert_eq!(l.decide(&r3), Decision::NeedsConfirmation { new: caps(&["editor.write"]), first_time: false });
l.confirm(&r3, &[], BTreeMap::new());
assert!(matches!(l.decide(&r3), Decision::Allowed { .. }));
// Revoking a required capability blocks loading again.
assert!(l.set(&r3.subject, &Capability::EditorWrite, false));
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
}
#[test]
fn migration_grants_low_risk_only() {
let tmp = tempfile::tempdir().unwrap();
let mut l = Ledger::load(tmp.path());
assert!(l.needs_migration());
let r = req("0.1.0", "h", &["project.read", "editor.write"], &[]);
l.migrate(std::slice::from_ref(&r));
// Loads with the low-risk part; the rest is asked for on first use.
assert_eq!(
l.decide(&r),
Decision::Allowed { granted: caps(&["project.read"]), pending: caps(&["editor.write"]) }
);
l.confirm(&r, &[], BTreeMap::new());
assert_eq!(
l.decide(&r),
Decision::Allowed { granted: caps(&["project.read", "editor.write"]), pending: vec![] }
);
l.save().unwrap();
let again = Ledger::load(tmp.path());
assert!(!again.needs_migration());
assert!(again.get(&r.subject).unwrap().has(&Capability::ProjectRead));
}
#[cfg(unix)]
#[test]
fn binaries_are_pinned_by_path_and_hash() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir(&bin_dir).unwrap();
let tool = bin_dir.join("tool");
std::fs::write(&tool, "#!/bin/sh\necho v1\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let path_var = std::env::join_paths([PathBuf::from("relative"), bin_dir.clone()]).unwrap();
let resolved = resolve_program("tool", Some(&path_var)).unwrap();
assert!(resolved.is_absolute());
assert!(resolve_program("missing", Some(&path_var)).is_err());
let mut l = Ledger::in_memory();
let r = req("1.0.0", "h", &["process:tool"], &[]);
let cap: Capability = "process:tool".parse().unwrap();
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
std::fs::write(&tool, "#!/bin/sh\necho evil\n").unwrap();
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
#[test]
fn hashes_and_grouping() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("a"), "1").unwrap();
std::fs::write(tmp.path().join("b"), "2").unwrap();
let h1 = package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap();
assert_eq!(h1, package_hash(tmp.path(), &["b".into(), "a".into()]).unwrap());
std::fs::write(tmp.path().join("b"), "3").unwrap();
assert_ne!(h1, package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap());
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
}
}
+37
Переглянути файл
@@ -0,0 +1,37 @@
//! The plugin system's kernel, independent of the Wasm runtime
//! (`docs/plugin_plan.md` 3–4):
//!
//! - [`Manifest`]: `plugin.toml` (0.2 format; 0.1 still parses);
//! - [`Capability`]: what a plugin may do, with its [`Risk`] level;
//! - [`ExtensionPoint`]: one kind of contribution, implemented by the crate that
//! owns the feature; [`ExtensionRegistry`] validates a package's declarations
//! ([`ExtensionRegistry::prepare`], no side effects), makes them take effect
//! all or nothing ([`ExtensionRegistry::commit`]) and withdraws them per plugin;
//! - [`UiStore`] / [`UiPoint`]: UI contributions, validated here and projected
//! by the client without the possibility to fail;
//! - [`ledger`]: which capabilities the user granted to which plugin;
//! - [`package`]: the package file, its signature and the marketplace index.
//!
//! Dependency direction: `ide-core` ← `ide-extension` ← feature crates (their
//! extension points) and `ide-plugin` (Wasm) ← `ide-host` (assembly).
mod capability;
mod index;
pub mod ledger;
mod manifest;
pub mod package;
mod point;
mod registry;
mod ui;
mod when;
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use index::ContributionIndex;
pub use manifest::{
ActivationEvent, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, check_relative,
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 ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
pub use when::validate_when;
+501
Переглянути файл
@@ -0,0 +1,501 @@
//! `plugin.toml`, the package manifest (plan 4.4).
//!
//! ```toml
//! id = "acme.rust-tools" # optional publisher namespace before the first '.'
//! name = "Rust Tools"
//! version = "1.2.0"
//! engines.openidea = "^1.0"
//! wasm = "plugin.wasm"
//! activation = ["on-language:rust", "on-command:check"]
//!
//! [capabilities]
//! required = ["project.read", "editor.write"]
//! optional = ["net:crates.io", "process:cargo"]
//!
//! [[contributes.commands]]
//! id = "check"
//! title = "Check"
//! ```
//!
//! `contributes` is kept as raw TOML: each `contributes.<point>` is parsed and
//! validated by the extension point registered under that id
//! ([`crate::ExtensionRegistry`]), so new contribution types need no change here.
//!
//! Manifests in the 0.1 format (`openidea-api = "0.1"`, `permissions = [...]`)
//! still parse: permissions become required capabilities and the plugin is
//! activated at start-up, as before.
use crate::capability::Capability;
use serde::Deserialize;
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.0.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
#[derive(Clone, Debug, PartialEq)]
pub struct Manifest {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub license: Option<String>,
/// Release notes of this version (Settings › Plugins › What's New).
pub change_notes: Option<String>,
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
pub engine: String,
/// Written in the 0.1 format.
pub legacy: bool,
/// Component file relative to the package directory.
pub wasm: Option<PathBuf>,
pub activation: Vec<ActivationEvent>,
pub capabilities: Capabilities,
/// `contributes.<point>` → its declarations, validated by the extension points.
pub contributes: toml::Table,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Capabilities {
pub required: Vec<Capability>,
pub optional: Vec<Capability>,
}
impl Capabilities {
pub fn all(&self) -> impl Iterator<Item = &Capability> {
self.required.iter().chain(&self.optional)
}
pub fn is_optional(&self, cap: &Capability) -> bool {
self.optional.contains(cap) && !self.required.contains(cap)
}
}
/// When a plugin's code is instantiated (plan 4.4). Contributions are live
/// without it; code only runs once one of these fires.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ActivationEvent {
/// One of the plugin's commands is run (`on-command:<id>`; `*` = any).
Command(String),
/// A file of this language is opened.
Language(String),
/// A file matching this glob (project-relative) is opened.
File(String),
/// One of the plugin's views is shown.
View(String),
/// Periodically: `on-schedule:<seconds>`. Does not keep the plugin alive.
Schedule(u32),
/// A workbench event of this kind (`on-event:file-saved`): the plugin is
/// subscribed from the start, and activated to receive it.
Event(String),
/// Shortly after the workbench is ready.
StartupFinished,
}
impl ActivationEvent {
pub fn parse(s: &str) -> Result<Self, String> {
let s = s.trim();
if s == "on-startup-finished" {
return Ok(Self::StartupFinished);
}
let (kind, arg) = s.split_once(':').ok_or_else(|| format!("unknown activation event {s:?}"))?;
let arg = arg.trim();
if arg.is_empty() {
return Err(format!("activation event {s:?} needs a value"));
}
Ok(match kind {
"on-command" => Self::Command(arg.into()),
"on-language" => Self::Language(arg.into()),
"on-file" => Self::File(arg.into()),
"on-view" => Self::View(arg.into()),
"on-schedule" => {
let secs: u32 =
arg.trim_end_matches('s').parse().map_err(|_| format!("{s:?}: expected seconds, e.g. 300"))?;
if secs < 60 {
return Err(format!("{s:?}: the shortest schedule is 60 seconds"));
}
Self::Schedule(secs)
}
"on-event" => {
if !EVENT_KINDS.contains(&arg) {
return Err(format!("{s:?}: unknown event kind (one of {})", EVENT_KINDS.join(", ")));
}
Self::Event(arg.into())
}
_ => return Err(format!("unknown activation event {s:?}")),
})
}
}
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
impl std::fmt::Display for ActivationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Command(c) => write!(f, "on-command:{c}"),
Self::Language(l) => write!(f, "on-language:{l}"),
Self::File(g) => write!(f, "on-file:{g}"),
Self::View(v) => write!(f, "on-view:{v}"),
Self::Schedule(s) => write!(f, "on-schedule:{s}"),
Self::Event(k) => write!(f, "on-event:{k}"),
Self::StartupFinished => f.write_str("on-startup-finished"),
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct Raw {
id: String,
name: String,
version: String,
#[serde(default)]
description: String,
#[serde(default)]
authors: Vec<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
license: Option<String>,
#[serde(default)]
change_notes: Option<String>,
#[serde(default)]
engines: Option<Engines>,
/// 0.1 format.
#[serde(default)]
openidea_api: Option<String>,
#[serde(default)]
wasm: Option<PathBuf>,
#[serde(default)]
activation: Option<Vec<String>>,
#[serde(default)]
capabilities: Option<RawCapabilities>,
/// 0.1 format.
#[serde(default)]
permissions: Option<Vec<String>>,
#[serde(default)]
contributes: toml::Table,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Engines {
openidea: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCapabilities {
#[serde(default)]
required: Vec<String>,
#[serde(default)]
optional: Vec<String>,
}
impl Manifest {
pub fn parse(src: &str) -> Result<Self, String> {
let raw: Raw = toml::from_str(src).map_err(|e| e.to_string())?;
let (engine, legacy) = match (raw.engines, raw.openidea_api) {
(Some(e), None) => (e.openidea, false),
(None, Some(api)) => (api, true),
(Some(_), Some(_)) => return Err("use `engines.openidea`, not `openidea-api`".into()),
(None, None) => return Err("missing `engines.openidea` (e.g. \"^1.0\")".into()),
};
if legacy && raw.capabilities.is_some() {
return Err("`capabilities` requires `engines.openidea`; 0.1 manifests use `permissions`".into());
}
if !legacy && raw.permissions.is_some() {
return Err("`permissions` is the 0.1 format; use `[capabilities] required = [...]`".into());
}
let parse_caps = |list: Vec<String>| -> Result<Vec<Capability>, String> {
let mut out: Vec<Capability> = Vec::new();
for s in list {
let cap: Capability = s.parse()?;
if !out.contains(&cap) {
out.push(cap);
}
}
Ok(out)
};
let capabilities = match (raw.capabilities, raw.permissions) {
(Some(c), _) => Capabilities { required: parse_caps(c.required)?, optional: parse_caps(c.optional)? },
(None, Some(p)) => Capabilities { required: parse_caps(p)?, optional: Vec::new() },
(None, None) => Capabilities::default(),
};
let activation = match raw.activation {
Some(list) => list.iter().map(|s| ActivationEvent::parse(s)).collect::<Result<Vec<_>, _>>()?,
// 0.1 plugins were activated at start-up; keep that until they move to 1.0.
None if legacy && raw.wasm.is_some() => vec![ActivationEvent::StartupFinished],
None => Vec::new(),
};
let m = Manifest {
id: raw.id,
name: raw.name,
version: raw.version,
description: raw.description,
authors: raw.authors,
homepage: raw.homepage,
license: raw.license,
change_notes: raw.change_notes,
engine,
legacy,
wasm: raw.wasm,
activation,
capabilities,
contributes: raw.contributes,
};
m.validate()?;
Ok(m)
}
pub fn load(dir: &Path) -> Result<Self, String> {
let path = dir.join(MANIFEST_FILE);
let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
}
fn validate(&self) -> Result<(), String> {
validate_plugin_id(&self.id)?;
if self.name.trim().is_empty() {
return Err("`name` must not be empty".into());
}
if semver::Version::parse(&self.version).is_err() {
return Err(format!("`version` must be a semantic version like 1.2.3, got {:?}", self.version));
}
if !engine_compatible(&self.engine, self.legacy) {
return Err(if self.legacy {
format!(
"unsupported openidea-api {:?} (this IDE implements {LEGACY_API} and {ENGINE_VERSION})",
self.engine
)
} else {
format!("engines.openidea {:?} does not match this IDE's plugin API {ENGINE_VERSION}", self.engine)
});
}
if let Some(wasm) = &self.wasm {
check_relative(wasm, "wasm")?;
}
if self.wasm.is_none() && !self.activation.is_empty() {
return Err("`activation` needs `wasm`: there is no code to activate".into());
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if !seen.insert(event) {
return Err(format!("duplicate activation event {event}"));
}
}
for (point, decls) in &self.contributes {
validate_point_id(point)?;
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
return Err(format!("`contributes.{point}` must be a table or an array of tables"));
}
}
Ok(())
}
/// The declarations of one extension point: `[[contributes.x]]` gives
/// several, `[contributes.x]` one.
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
match self.contributes.get(point) {
Some(toml::Value::Array(items)) => items.clone(),
Some(single @ toml::Value::Table(_)) => vec![single.clone()],
_ => Vec::new(),
}
}
/// Number of declarations per extension point (for listings before loading).
pub fn declaration_counts(&self) -> Vec<(String, u32)> {
self.contributes.keys().map(|p| (p.clone(), self.declarations(p).len() as u32)).collect()
}
/// Publisher namespace: the id's part before the first `.` (`acme` in `acme.tools`).
pub fn publisher(&self) -> Option<&str> {
self.id.split_once('.').map(|(p, _)| p)
}
/// Whether `cap` is declared (required or optional).
pub fn declares(&self, cap: &Capability) -> bool {
self.capabilities.all().any(|c| c.covers(cap))
}
}
/// Whether this host can load a plugin written for `engine`.
pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
if legacy {
return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.'));
}
let Ok(req) = semver::VersionReq::parse(engine) else { return false };
let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version");
req.matches(&host)
}
/// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher
/// namespace first), at most 64 chars.
pub fn validate_plugin_id(id: &str) -> Result<(), String> {
let ok = id.len() <= 64 && !id.is_empty() && id.split('.').all(segment_ok);
if ok {
Ok(())
} else {
Err(format!(
"invalid plugin id {id:?}: use a-z, 0-9, '-', '_' in '.'-separated parts (e.g. acme.tools), max 64"
))
}
}
/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars (commands, themes, views…).
pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
let ok_rest = id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
if ok_first && ok_rest && id.len() <= 64 {
Ok(())
} else {
Err(format!("invalid {what} {id:?}: use a-z, 0-9, '.', '-', '_', starting with a letter or digit (max 64)"))
}
}
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
pub fn validate_point_id(id: &str) -> Result<(), String> {
let ok = !id.is_empty()
&& id.len() <= 64
&& id.starts_with(|c: char| c.is_ascii_lowercase())
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
}
fn segment_ok(s: &str) -> bool {
s.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
/// Package paths must stay inside the package directory.
pub fn check_relative(path: &Path, what: &str) -> Result<(), String> {
let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)));
if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) }
}
#[cfg(test)]
mod tests {
use super::*;
const FULL: &str = r#"
id = "acme.rust-tools"
name = "Rust Tools"
version = "1.2.0"
engines.openidea = "^1.0"
wasm = "plugin.wasm"
activation = ["on-language:rust", "on-command:check", "on-schedule:300"]
[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"
[contributes.settings]
schema = "settings.schema.json"
"#;
#[test]
fn event_activation() {
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
}
#[test]
fn parses_the_02_format() {
let m = Manifest::parse(FULL).unwrap();
assert_eq!(m.publisher(), Some("acme"));
assert!(!m.legacy);
assert_eq!(m.activation.len(), 3);
assert_eq!(m.activation[2], ActivationEvent::Schedule(300));
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
assert!(m.capabilities.is_optional(&Capability::Process("cargo".into())));
assert_eq!(m.declarations("commands").len(), 1);
assert_eq!(m.declarations("settings").len(), 1, "a single table is one declaration");
assert!(m.declarations("nope").is_empty());
let counts: Vec<_> = m.declaration_counts();
assert!(counts.contains(&("menus".into(), 1)));
}
#[test]
fn parses_the_01_format() {
let m = Manifest::parse(
"id = \"hello\"\nname = \"Hello\"\nversion = \"0.1.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n\
permissions = [\"project-read\", \"editor-write\"]\n[[contributes.commands]]\nid = \"x\"\ntitle = \"X\"",
)
.unwrap();
assert!(m.legacy);
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
assert_eq!(m.activation, vec![ActivationEvent::StartupFinished]);
assert_eq!(m.publisher(), None);
}
fn err(src: &str) -> String {
Manifest::parse(src).unwrap_err()
}
#[test]
fn rejects_invalid_manifests() {
let base = |extra: &str| format!("id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\n{extra}");
assert!(err(&base("")).contains("engines.openidea"));
assert!(err(&base("engines.openidea = \"^0.3\"")).contains("does not match"));
assert!(err(&base("engines.openidea = \"^2\"")).contains("does not match"));
assert!(err(&base("engines.openidea = \"^0.2\"")).contains("does not match"));
assert!(err(&base("openidea-api = \"0.2\"")).contains("unsupported"));
assert!(err(&base("engines.openidea = \"^1.0\"\npermissions = [\"project-read\"]")).contains("0.1 format"));
assert!(
err(&base("engines.openidea = \"^1.0\"\n[capabilities]\nrequired = [\"network\"]")).contains("unknown")
);
assert!(err(&base("engines.openidea = \"^1.0\"\nwasm = \"../x.wasm\"")).contains("relative path"));
assert!(err(&base("engines.openidea = \"^1.0\"\nactivation = [\"on-command:x\"]")).contains("needs `wasm`"));
assert!(
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-boot\"]"))
.contains("unknown")
);
assert!(
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-schedule:5\"]"))
.contains("60 seconds")
);
assert!(err(&base("engines.openidea = \"^1.0\"\nbogus = 1")).contains("unknown field"));
assert!(err(&base("engines.openidea = \"^1.0\"\ncontributes.commands = 3")).contains("table"));
assert!(
err("id = \"Bad\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
);
assert!(
err("id = \"a..b\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
);
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version"));
}
#[test]
fn ids() {
assert!(validate_plugin_id("openidea.material-icons").is_ok());
assert!(validate_plugin_id("hello_2").is_ok());
assert!(validate_plugin_id(".x").is_err());
assert!(validate_item_id("word-count.v2_x", "command id").is_ok());
assert!(validate_item_id("-x", "command id").is_err());
assert!(validate_point_id("iconThemes").is_ok());
assert!(validate_point_id("Icon").is_err());
assert!(engine_compatible("1.0", false));
assert!(engine_compatible(">=1.0, <2", false));
assert!(!engine_compatible("^0.2", false), "0.x components don't run on 1.0");
assert!(engine_compatible("0.1.3", true));
assert!(!engine_compatible("0.10", true));
}
}
+378
Переглянути файл
@@ -0,0 +1,378 @@
//! Plugin distribution (docs/plugin_plan.md 4.7): the package file, its
//! signature, and the static marketplace index.
//!
//! - **Package** (`<id>-<version>.oiplugin`): a zip of the manifest, the files
//! its declarations reference, the component and licence files, plus
//! [`SIGNATURE_FILE`] when signed.
//! - **Signature**: Ed25519 over `openidea-plugin/1\n<id>\n<version>\n<digest>`,
//! `digest` being [`package_digest`] of the unpacked files. A signature
//! proves who published the bytes; whether to trust that publisher is the
//! index's call.
//! - **Index** (`index.json`): the publishers and their keys (the trust root),
//! the published plugins, and revocations. A publisher owns the id prefix
//! `<publisher>.`; a revoked key invalidates everything it signed, a
//! revoked plugin version is disabled where it is installed.
use crate::MANIFEST_FILE;
use base64::Engine as _;
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
pub const SIGNATURE_FILE: &str = "SIGNATURE.json";
pub const PACKAGE_EXTENSION: &str = "oiplugin";
pub const INDEX_FORMAT: u32 = 1;
/// Limits when unpacking an untrusted package.
const MAX_ENTRIES: usize = 10_000;
const MAX_TOTAL: u64 = 512 << 20;
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
/// Content digest of a package: the manifest plus `files` (relative paths).
pub fn package_digest(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
let mut all = files.to_vec();
all.push(PathBuf::from(MANIFEST_FILE));
crate::ledger::package_hash(dir, &all)
}
fn signed_message(id: &str, version: &str, digest: &str) -> Vec<u8> {
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
}
/// `SIGNATURE.json` of a signed package.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageSignature {
pub publisher: String,
/// Ed25519 public key, base64.
pub key: String,
/// Ed25519 signature, base64.
pub signature: String,
}
/// A publisher's key pair (the 32-byte seed, base64, in a file only the publisher has).
pub struct PublisherKey(SigningKey);
impl PublisherKey {
pub fn from_seed(seed: [u8; 32]) -> Self {
Self(SigningKey::from_bytes(&seed))
}
/// The seed as written by `openidea-plugin keygen`.
pub fn from_base64(text: &str) -> Result<Self, String> {
let bytes = B64.decode(text.trim()).map_err(|e| format!("key file: {e}"))?;
let seed: [u8; 32] = bytes.try_into().map_err(|_| "key file: expected a 32-byte key".to_owned())?;
Ok(Self::from_seed(seed))
}
pub fn seed_base64(&self) -> String {
B64.encode(self.0.to_bytes())
}
pub fn public_base64(&self) -> String {
B64.encode(self.0.verifying_key().to_bytes())
}
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
let sig = self.0.sign(&signed_message(id, version, digest));
PackageSignature {
publisher: publisher.into(),
key: self.public_base64(),
signature: B64.encode(sig.to_bytes()),
}
}
}
impl PackageSignature {
/// Whether the signature is valid for `id`, `version` and `digest` (the
/// key's validity is checked against an [`Index`]).
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
let key: [u8; 32] = B64
.decode(&self.key)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| "signature: malformed public key".to_owned())?;
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
let sig: [u8; 64] = B64
.decode(&self.signature)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| "signature: malformed".to_owned())?;
key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
.map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
}
}
/// Writes the package `out` from `dir`: the manifest, `files`, and the signature if any.
pub fn pack(dir: &Path, files: &[PathBuf], out: &Path, signature: Option<&PackageSignature>) -> Result<(), String> {
let io = |e: std::io::Error| e.to_string();
let file = std::fs::File::create(out).map_err(io)?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut names: Vec<PathBuf> = std::iter::once(PathBuf::from(MANIFEST_FILE)).chain(files.iter().cloned()).collect();
names.sort();
names.dedup();
for rel in names {
let name = rel.to_str().ok_or("non UTF-8 file name")?.replace('\\', "/");
zip.start_file(name, options).map_err(|e| e.to_string())?;
let bytes = std::fs::read(dir.join(&rel)).map_err(|e| format!("{}: {e}", rel.display()))?;
zip.write_all(&bytes).map_err(io)?;
}
if let Some(sig) = signature {
zip.start_file(SIGNATURE_FILE, options).map_err(|e| e.to_string())?;
zip.write_all(serde_json::to_string_pretty(sig).map_err(|e| e.to_string())?.as_bytes()).map_err(io)?;
}
zip.finish().map_err(|e| e.to_string())?;
Ok(())
}
/// What [`unpack`] wrote.
#[derive(Debug)]
pub struct Unpacked {
/// Every file but the manifest and the signature, relative.
pub files: Vec<PathBuf>,
pub signature: Option<PackageSignature>,
}
/// `name` as a relative path that stays inside the package.
fn safe_path(name: &str) -> Option<PathBuf> {
let p = Path::new(name);
(!name.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))).then(|| p.to_path_buf())
}
/// Extracts the package `archive` into the empty directory `dest`, refusing
/// paths leaving it, links, and oversized contents.
pub fn unpack(archive: &Path, dest: &Path) -> Result<Unpacked, String> {
let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?;
let mut zip =
zip::ZipArchive::new(file).map_err(|e| format!("{}: not a plugin package ({e})", archive.display()))?;
if zip.len() > MAX_ENTRIES {
return Err(format!("more than {MAX_ENTRIES} files"));
}
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
let (mut files, mut signature, mut total, mut manifest) = (Vec::new(), None, 0u64, false);
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
if entry.is_dir() {
continue;
}
let name = entry.name().map_err(|e| e.to_string())?.into_owned();
if entry.is_symlink() {
return Err(format!("{name}: links are not allowed in packages"));
}
let rel = safe_path(&name).ok_or_else(|| format!("invalid path {name:?} in package"))?;
let mut bytes = Vec::new();
entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
total += bytes.len() as u64;
if total > MAX_TOTAL {
return Err(format!("the package unpacks to more than {} MiB", MAX_TOTAL >> 20));
}
if rel == Path::new(SIGNATURE_FILE) {
signature = Some(serde_json::from_slice(&bytes).map_err(|e| format!("{SIGNATURE_FILE}: {e}"))?);
continue;
}
let to = dest.join(&rel);
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&to, bytes).map_err(|e| e.to_string())?;
if rel == Path::new(MANIFEST_FILE) {
manifest = true;
} else {
files.push(rel);
}
}
if !manifest {
return Err(format!("the package has no {MANIFEST_FILE}"));
}
files.sort();
Ok(Unpacked { files, signature })
}
// ---- index -------------------------------------------------------------------
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct Index {
pub format: u32,
#[serde(default)]
pub publishers: Vec<Publisher>,
#[serde(default)]
pub plugins: Vec<IndexEntry>,
#[serde(default)]
pub revoked: Vec<Revocation>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct Publisher {
/// Owns plugin ids `<id>.…`.
pub id: String,
pub name: String,
pub keys: Vec<KeyEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct KeyEntry {
/// Ed25519 public key, base64.
pub key: String,
/// Packages signed with it are no longer trusted.
#[serde(default)]
pub revoked: bool,
}
/// A published plugin version.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct IndexEntry {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub authors: Vec<String>,
pub publisher: String,
/// The package, relative to the index (or an absolute URL).
pub download: String,
/// [`package_digest`] of its contents.
pub digest: String,
#[serde(default)]
pub capabilities: Vec<String>,
}
/// A plugin (all versions when `versions` is empty) that must not run.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct Revocation {
pub id: String,
#[serde(default)]
pub versions: Vec<String>,
pub reason: String,
}
impl Index {
pub fn parse(text: &str) -> Result<Self, String> {
let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
if index.format != INDEX_FORMAT {
return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
}
Ok(index)
}
/// The revocation that applies to `id` at `version`.
pub fn revocation(&self, id: &str, version: &str) -> Option<&Revocation> {
self.revoked.iter().find(|r| r.id == id && (r.versions.is_empty() || r.versions.iter().any(|v| v == version)))
}
/// Whether `key` (base64) belongs to a publisher and was revoked.
pub fn key_revoked(&self, key: &str) -> bool {
self.publishers.iter().flat_map(|p| &p.keys).any(|k| k.key == key && k.revoked)
}
/// The trusted publisher of a signed package: the signature is valid, its
/// key is one of the publisher's and not revoked, and the publisher owns
/// the plugin's id prefix.
pub fn verify(&self, sig: &PackageSignature, id: &str, version: &str, digest: &str) -> Result<String, String> {
let publisher = self
.publishers
.iter()
.find(|p| p.id == sig.publisher)
.ok_or_else(|| format!("unknown publisher {:?}", sig.publisher))?;
let key = publisher
.keys
.iter()
.find(|k| k.key == sig.key)
.ok_or_else(|| format!("the key is not one of {}'s", publisher.name))?;
if key.revoked {
return Err(format!("{}'s signing key was revoked", publisher.name));
}
if !id.starts_with(&format!("{}.", publisher.id)) {
return Err(format!("{} may only publish plugins named {}.*", publisher.name, publisher.id));
}
sig.check(id, version, digest)?;
Ok(publisher.id.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn package(dir: &Path) -> Vec<PathBuf> {
std::fs::create_dir_all(dir.join("dist")).unwrap();
std::fs::write(dir.join(MANIFEST_FILE), "id = \"acme.tools\"\nversion = \"1.0.0\"").unwrap();
std::fs::write(dir.join("dist/plugin.wasm"), b"\0asm").unwrap();
vec![PathBuf::from("dist/plugin.wasm")]
}
fn index(key: &PublisherKey) -> Index {
Index {
format: INDEX_FORMAT,
publishers: vec![Publisher {
id: "acme".into(),
name: "ACME".into(),
keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
}],
plugins: Vec::new(),
revoked: vec![Revocation {
id: "acme.bad".into(),
versions: vec!["1.0.0".into()],
reason: "malware".into(),
}],
}
}
#[test]
fn signed_packages_round_trip_and_tampering_is_caught() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
let files = package(&src);
let digest = package_digest(&src, &files).unwrap();
let key = PublisherKey::from_seed([7; 32]);
let sig = key.sign("acme", "acme.tools", "1.0.0", &digest);
let out = tmp.path().join("acme.tools-1.0.0.oiplugin");
pack(&src, &files, &out, Some(&sig)).unwrap();
let dest = tmp.path().join("dest");
let unpacked = unpack(&out, &dest).unwrap();
assert_eq!(unpacked.files, files);
assert_eq!(package_digest(&dest, &unpacked.files).unwrap(), digest);
let index = index(&key);
assert_eq!(index.verify(unpacked.signature.as_ref().unwrap(), "acme.tools", "1.0.0", &digest).unwrap(), "acme");
// Changed bytes, another id, someone else's prefix, a revoked key.
assert!(index.verify(&sig, "acme.tools", "1.0.0", "0000").is_err());
assert!(index.verify(&sig, "acme.tools", "1.0.1", &digest).is_err());
let other = PublisherKey::from_seed([9; 32]).sign("acme", "acme.tools", "1.0.0", &digest);
assert!(index.verify(&other, "acme.tools", "1.0.0", &digest).unwrap_err().contains("not one of"));
let foreign = key.sign("acme", "evil.tools", "1.0.0", &digest);
assert!(index.verify(&foreign, "evil.tools", "1.0.0", &digest).unwrap_err().contains("acme.*"));
let mut revoked = index.clone();
revoked.publishers[0].keys[0].revoked = true;
assert!(revoked.verify(&sig, "acme.tools", "1.0.0", &digest).unwrap_err().contains("revoked"));
assert!(revoked.key_revoked(&sig.key));
assert_eq!(index.revocation("acme.bad", "1.0.0").unwrap().reason, "malware");
assert!(index.revocation("acme.bad", "1.0.1").is_none());
assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64());
}
#[test]
fn unpacking_refuses_paths_leaving_the_package() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("evil.oiplugin");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
let o = zip::write::SimpleFileOptions::default();
zip.start_file(MANIFEST_FILE, o).unwrap();
zip.write_all(b"id = \"x\"").unwrap();
zip.start_file("../outside.txt", o).unwrap();
zip.write_all(b"x").unwrap();
zip.finish().unwrap();
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
assert!(err.contains("invalid path"), "{err}");
assert!(!tmp.path().join("outside.txt").exists());
assert!(Index::parse(r#"{"format": 2}"#).is_err());
}
}
+175
Переглянути файл
@@ -0,0 +1,175 @@
//! The extension point contract (plan 4.1).
use crate::capability::Capability;
use crate::manifest::{Manifest, check_relative};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Where a contribution takes effect (plan 3, placement rules).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum Placement {
/// Projected to the client (menus, keybindings, themes, icons).
Ui,
/// Takes effect on the machine the project is on (languages, LSP, run configurations).
Workspace,
}
/// The package a declaration comes from, as extension points see it.
#[derive(Clone, Debug)]
pub struct Package {
pub manifest: Arc<Manifest>,
/// Package directory.
pub dir: PathBuf,
/// Hash of the package's files when they never change (an installed
/// copy): validated declarations are then reused from the
/// [`ContributionIndex`](crate::ContributionIndex).
pub digest: Option<Arc<str>>,
}
/// Largest single package file an extension point reads.
pub const MAX_PACKAGE_FILE: u64 = 8 << 20;
impl Package {
pub fn new(manifest: Manifest, dir: PathBuf) -> Self {
Self { manifest: Arc::new(manifest), dir, digest: None }
}
pub fn with_digest(mut self, digest: &str) -> Self {
self.digest = Some(digest.into());
self
}
pub fn id(&self) -> &str {
&self.manifest.id
}
/// Resolves a package-relative path, refusing anything that leaves the
/// package (`..`, absolute paths, symlinks pointing outside).
pub fn resolve(&self, rel: &Path) -> Result<PathBuf, String> {
check_relative(rel, "path")?;
let joined = self.dir.join(rel);
let real = joined.canonicalize().map_err(|_| format!("{}: no such file in the plugin", rel.display()))?;
let root = self.dir.canonicalize().map_err(|e| format!("plugin directory: {e}"))?;
if !real.starts_with(&root) {
return Err(format!("{}: points outside the plugin", rel.display()));
}
Ok(real)
}
pub fn read_bytes(&self, rel: &Path) -> Result<Vec<u8>, String> {
let real = self.resolve(rel)?;
let meta = std::fs::metadata(&real).map_err(|e| format!("{}: {e}", rel.display()))?;
if !meta.is_file() {
return Err(format!("{}: not a file", rel.display()));
}
if meta.len() > MAX_PACKAGE_FILE {
return Err(format!("{}: larger than {} MiB", rel.display(), MAX_PACKAGE_FILE >> 20));
}
std::fs::read(&real).map_err(|e| format!("{}: {e}", rel.display()))
}
pub fn read_text(&self, rel: &Path) -> Result<String, String> {
String::from_utf8(self.read_bytes(rel)?).map_err(|_| format!("{}: not UTF-8 text", rel.display()))
}
}
/// Undoes one committed contribution. Dropping without [`dispose`](Self::dispose) also disposes.
#[must_use = "dropping a Disposable undoes the contribution"]
pub struct Disposable(Option<Box<dyn FnOnce() + Send>>);
impl Disposable {
pub fn new(f: impl FnOnce() + Send + 'static) -> Self {
Self(Some(Box::new(f)))
}
/// A contribution with nothing to undo.
pub fn noop() -> Self {
Self(None)
}
pub fn dispose(mut self) {
if let Some(f) = self.0.take() {
f();
}
}
}
impl Drop for Disposable {
fn drop(&mut self) {
if let Some(f) = self.0.take() {
f();
}
}
}
impl std::fmt::Debug for Disposable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Disposable")
}
}
pub type Commit = Box<dyn FnOnce() -> Result<Disposable, String> + Send>;
/// A validated declaration, ready to take effect.
pub struct Prepared {
/// Capabilities this declaration needs (a language with an LSP → `process:<binary>`).
pub capabilities: Vec<Capability>,
/// Package files the declaration references (copied on install).
pub files: Vec<PathBuf>,
/// Identity within the point: a later declaration with the same key replaces
/// the earlier one of the same plugin (re-registering a command).
pub key: Option<String>,
/// User-facing one-liner for the plugin details page ("Insert Date").
pub label: String,
/// Makes it take effect. Must not fail for reasons `prepare` could have checked.
pub commit: Commit,
}
impl Prepared {
pub fn new(label: impl Into<String>, commit: impl FnOnce() -> Result<Disposable, String> + Send + 'static) -> Self {
Self { capabilities: Vec::new(), files: Vec::new(), key: None, label: label.into(), commit: Box::new(commit) }
}
pub fn key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
self.files.push(path.into());
self
}
pub fn capability(mut self, cap: Capability) -> Self {
if !self.capabilities.contains(&cap) {
self.capabilities.push(cap);
}
self
}
}
impl std::fmt::Debug for Prepared {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Prepared").field("key", &self.key).field("label", &self.label).finish_non_exhaustive()
}
}
/// A kind of contribution (`contributes.<id>` in the manifest). Implemented by
/// the crate that owns the feature; registered once in an [`crate::ExtensionRegistry`].
pub trait ExtensionPoint: Send + Sync {
/// `"languages"`, `"iconThemes"`, …
fn id(&self) -> &'static str;
fn placement(&self) -> Placement;
/// Parses and validates one declaration without side effects, and works
/// out the capabilities it needs. `Ui` points validate completely here: the
/// client's projection is not allowed to fail.
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String>;
}
/// Deserializes a declaration with serde, prefixing errors with the point id.
pub fn decode<T: serde::de::DeserializeOwned>(point: &str, decl: toml::Value) -> Result<T, String> {
decl.try_into().map_err(|e: toml::de::Error| format!("contributes.{point}: {}", e.message()))
}
+384
Переглянути файл
@@ -0,0 +1,384 @@
//! Extension points plus the contributions committed to them, accounted per plugin.
use crate::capability::Capability;
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use specta::Type;
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")]
pub struct ContributionInfo {
pub point: String,
pub label: String,
}
struct Entry {
point: &'static str,
key: Option<String>,
decl: toml::Value,
label: String,
scope: Scope,
disposable: Disposable,
}
/// Every declaration of a package, validated; nothing has taken effect yet.
pub struct PreparedSet {
pub plugin: String,
/// Union of what the declarations need (beyond the manifest's own list).
pub capabilities: Vec<Capability>,
/// Package files the declarations reference.
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)>,
}
impl PreparedSet {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
}
impl std::fmt::Debug for PreparedSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedSet")
.field("plugin", &self.plugin)
.field("items", &self.items.len())
.field("warnings", &self.warnings)
.finish()
}
}
#[derive(Default)]
pub struct ExtensionRegistry {
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
/// plugin id → its contributions in commit order.
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
}
impl ExtensionRegistry {
pub fn new() -> Self {
Self::default()
}
/// Adds an extension point. Ids are unique.
pub fn register(&self, point: Arc<dyn ExtensionPoint>) -> Result<(), String> {
let id = point.id();
crate::manifest::validate_point_id(id)?;
let mut points = self.points.write();
if points.contains_key(id) {
return Err(format!("extension point {id:?} is already registered"));
}
points.insert(id, point);
Ok(())
}
pub fn point_ids(&self) -> Vec<&'static str> {
self.points.read().keys().copied().collect()
}
pub fn placement(&self, point: &str) -> Option<Placement> {
self.points.read().get(point).map(|p| p.placement())
}
fn point(&self, id: &str) -> Option<Arc<dyn ExtensionPoint>> {
self.points.read().get(id).cloned()
}
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
/// whole on the first invalid declaration; unknown points are skipped with
/// a warning (forward compatibility).
pub fn prepare(&self, pkg: &Package) -> Result<PreparedSet, String> {
let mut set = PreparedSet {
plugin: pkg.id().to_owned(),
capabilities: Vec::new(),
files: Vec::new(),
warnings: Vec::new(),
items: Vec::new(),
};
for point_id in pkg.manifest.contributes.keys() {
let Some(point) = self.point(point_id) else {
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
tracing::warn!(plugin = %pkg.id(), "{msg}");
set.warnings.push(msg);
continue;
};
for (i, decl) in pkg.manifest.declarations(point_id).into_iter().enumerate() {
let prepared = point
.prepare(pkg, decl.clone())
.map_err(|e| format!("contributes.{point_id}[{i}]: {}", strip_prefix(&e, point_id)))?;
for cap in &prepared.capabilities {
if !set.capabilities.contains(cap) {
set.capabilities.push(cap.clone());
}
}
for f in &prepared.files {
if !set.files.contains(f) {
set.files.push(f.clone());
}
}
set.items.push((point.id(), decl, prepared));
}
}
Ok(set)
}
/// 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> {
let mut committed: Vec<Entry> = Vec::with_capacity(set.items.len());
for (point, decl, prepared) in set.items {
let Prepared { key, label, commit, .. } = prepared;
match commit() {
Ok(disposable) => committed.push(Entry { point, key, decl, label, scope, disposable }),
Err(e) => {
tracing::error!(plugin = %set.plugin, "contributes.{point}: commit failed: {e}");
while let Some(entry) = committed.pop() {
entry.disposable.dispose();
}
return Err(format!("contributes.{point}: {e}"));
}
}
}
let replaced = {
let mut entries = self.entries.lock();
let list = entries.entry(set.plugin).or_default();
let mut replaced = Vec::new();
for new in &committed {
if let Some(key) = &new.key
&& let Some(pos) = list.iter().position(|e| e.point == new.point && e.key.as_ref() == Some(key))
{
replaced.push(list.remove(pos));
}
}
list.extend(committed);
replaced
};
for old in replaced.into_iter().rev() {
old.disposable.dispose();
}
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();
let any = !list.is_empty();
for entry in list.into_iter().rev() {
entry.disposable.dispose();
}
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()
.get(plugin)
.map(|list| {
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
})
.unwrap_or_default()
}
/// How many contributions `plugin` has at `point`.
pub fn count(&self, plugin: &str, point: &str) -> usize {
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()
}
}
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Records live items in a shared list; `fail = true` makes commit fail.
struct Recording {
id: &'static str,
live: Arc<Mutex<Vec<String>>>,
commits: Arc<AtomicUsize>,
}
impl ExtensionPoint for Recording {
fn id(&self) -> &'static str {
self.id
}
fn placement(&self) -> Placement {
Placement::Ui
}
fn prepare(&self, _pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let name = decl.get("name").and_then(|v| v.as_str()).ok_or("missing name")?.to_owned();
let fail = decl.get("fail").and_then(|v| v.as_bool()).unwrap_or(false);
let (live, commits) = (self.live.clone(), self.commits.clone());
let label = name.clone();
let mut p = Prepared::new(label, move || {
commits.fetch_add(1, Ordering::SeqCst);
if fail {
return Err("boom".into());
}
live.lock().push(name.clone());
Ok(Disposable::new(move || live.lock().retain(|n| *n != name)))
});
if let Some(f) = decl.get("file").and_then(|v| v.as_str()) {
p = p.file(f);
}
if decl.get("key").and_then(|v| v.as_bool()).unwrap_or(false) {
p = p.key("k");
}
Ok(p)
}
}
fn setup() -> (ExtensionRegistry, Arc<Mutex<Vec<String>>>) {
let reg = ExtensionRegistry::new();
let live = Arc::new(Mutex::new(Vec::new()));
let commits = Arc::new(AtomicUsize::new(0));
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: commits.clone() })).unwrap();
reg.register(Arc::new(Recording { id: "others", live: live.clone(), commits })).unwrap();
(reg, live)
}
fn pkg(contributes: &str) -> Package {
let src = format!("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n{contributes}");
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
}
#[test]
fn commits_and_disposes_per_plugin() {
let (reg, live) = setup();
assert!(
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: Default::default() }))
.is_err()
);
let p = pkg(
"[[contributes.things]]\nname = \"a\"\nfile = \"x.json\"\n[[contributes.things]]\nname = \"b\"\n[contributes.others]\nname = \"c\"",
);
let set = reg.prepare(&p).unwrap();
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();
assert_eq!(*live.lock(), ["c", "a", "b"]);
assert_eq!(reg.count("p", "things"), 2);
assert_eq!(reg.contributions("p").len(), 3);
assert!(reg.dispose_plugin("p"));
assert!(live.lock().is_empty());
}
#[test]
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();
assert!(err.contains("boom"));
assert!(live.lock().is_empty(), "a was undone");
assert!(reg.contributions("p").is_empty());
}
#[test]
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
let (reg, _) = setup();
let err = reg.prepare(&pkg("[[contributes.things]]\nnope = 1")).unwrap_err();
assert_eq!(err, "contributes.things[0]: missing name");
let set =
reg.prepare(&pkg("[[contributes.futureThing]]\nx = 1\n[[contributes.things]]\nname = \"a\"")).unwrap();
assert_eq!(set.len(), 1);
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"]);
}
}
+247
Переглянути файл
@@ -0,0 +1,247 @@
//! UI contributions: validated declarations projected to the client.
//!
//! A `Ui` extension point validates a declaration completely in Rust and
//! commits it as JSON into the [`UiStore`]; the client receives the store's
//! snapshot and projects each item onto an existing `register*` function
//! (`defineUiPoint` in `src/features/plugins/projection.ts`). Large payloads
//! (icon SVGs) go into the store's assets and are fetched on demand.
use crate::index::ContributionIndex;
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
/// One projected declaration.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UiContribution {
pub plugin_id: String,
/// Extension point id (`commands`, `iconThemes`, …).
pub point: String,
/// Stable within (plugin, point): the client keys its registrations on it.
pub key: String,
/// The validated declaration, shaped by the point.
#[specta(type = specta_typescript::Unknown)]
pub decl: serde_json::Value,
}
struct Item {
seq: u64,
contribution: UiContribution,
}
/// Committed UI contributions of all plugins.
#[derive(Default)]
pub struct UiStore {
items: RwLock<Vec<Item>>,
/// (plugin, asset id) → content.
assets: RwLock<BTreeMap<(String, String), Arc<str>>>,
seq: AtomicU64,
revision: AtomicU64,
index: RwLock<Option<Arc<ContributionIndex>>>,
}
impl UiStore {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Adds an item; the returned disposable removes it.
pub fn insert(self: &Arc<Self>, contribution: UiContribution) -> Disposable {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push(Item { seq, contribution });
self.revision.fetch_add(1, Ordering::Release);
let store = Arc::downgrade(self);
Disposable::new(move || {
if let Some(store) = store.upgrade() {
store.items.write().retain(|i| i.seq != seq);
store.revision.fetch_add(1, Ordering::Release);
}
})
}
/// Stores assets of a plugin (ids are unique per plugin; later ones replace).
pub fn insert_assets(self: &Arc<Self>, plugin: &str, assets: Vec<(String, String)>) -> Disposable {
let keys: Vec<(String, String)> = assets.iter().map(|(id, _)| (plugin.to_owned(), id.clone())).collect();
{
let mut map = self.assets.write();
for (id, content) in assets {
map.insert((plugin.to_owned(), id), Arc::from(content));
}
}
let store = Arc::downgrade(self);
Disposable::new(move || {
if let Some(store) = store.upgrade() {
let mut map = store.assets.write();
for k in keys {
map.remove(&k);
}
}
})
}
pub fn asset(&self, plugin: &str, id: &str) -> Option<Arc<str>> {
self.assets.read().get(&(plugin.to_owned(), id.to_owned())).cloned()
}
/// Assets of `plugin` whose id starts with `prefix`.
pub fn assets_with_prefix(&self, plugin: &str, prefix: &str) -> Vec<(String, Arc<str>)> {
self.assets
.read()
.range((plugin.to_owned(), prefix.to_owned())..)
.take_while(|((p, id), _)| p == plugin && id.starts_with(prefix))
.map(|((_, id), v)| (id.clone(), v.clone()))
.collect()
}
/// Where [`UiPoint`]s keep validated declarations of installed packages.
pub fn set_index(&self, index: Option<Arc<ContributionIndex>>) {
*self.index.write() = index;
}
/// Bumped on every change: clients compare it to skip re-projection.
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
pub fn snapshot(&self) -> Vec<UiContribution> {
self.items.read().iter().map(|i| i.contribution.clone()).collect()
}
/// Items of one point, optionally of one plugin.
pub fn items(&self, point: &str, plugin: Option<&str>) -> Vec<UiContribution> {
self.items
.read()
.iter()
.filter(|i| i.contribution.point == point && plugin.is_none_or(|p| i.contribution.plugin_id == p))
.map(|i| i.contribution.clone())
.collect()
}
}
/// What a [`UiPoint`]'s validator produces for one declaration.
#[derive(Debug, Serialize, Deserialize)]
pub struct UiDecl {
pub key: String,
pub label: String,
pub json: serde_json::Value,
/// Package files referenced (copied on install).
pub files: Vec<PathBuf>,
/// Large validated payloads fetched separately by the client.
pub assets: Vec<(String, String)>,
}
impl UiDecl {
pub fn new(key: impl Into<String>, label: impl Into<String>, json: serde_json::Value) -> Self {
Self { key: key.into(), label: label.into(), json, files: Vec::new(), assets: Vec::new() }
}
}
type Validate = dyn Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync;
/// A `Ui` extension point defined by a validator: committing stores the
/// validated JSON (and assets) in the [`UiStore`]. Most UI contribution types
/// (menus, keybindings, status bar items, icon themes) are one of these.
pub struct UiPoint {
id: &'static str,
store: Arc<UiStore>,
validate: Box<Validate>,
}
impl UiPoint {
pub fn new(
id: &'static str,
store: Arc<UiStore>,
validate: impl Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync + 'static,
) -> Self {
Self { id, store, validate: Box::new(validate) }
}
}
impl ExtensionPoint for UiPoint {
fn id(&self) -> &'static str {
self.id
}
fn placement(&self) -> Placement {
Placement::Ui
}
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let index = pkg.digest.as_ref().and_then(|_| self.store.index.read().clone());
let ui = match index.as_ref().and_then(|i| i.get(pkg, self.id, &decl)) {
Some(ui) => ui,
None => match &index {
Some(i) => {
let ui = (self.validate)(pkg, decl.clone())?;
i.put(pkg, self.id, &decl, &ui);
ui
}
None => (self.validate)(pkg, decl)?,
},
};
let UiDecl { key, label, json, files, assets } = ui;
let store = self.store.clone();
let contribution =
UiContribution { plugin_id: pkg.id().to_owned(), point: self.id.to_owned(), key: key.clone(), decl: json };
let plugin = pkg.id().to_owned();
let mut prepared = Prepared::new(label, move || {
let item = store.insert(contribution);
if assets.is_empty() {
return Ok(item);
}
let assets = store.insert_assets(&plugin, assets);
Ok(Disposable::new(move || {
item.dispose();
assets.dispose();
}))
})
.key(key);
prepared.files = files;
Ok(prepared)
}
}
/// TOML declaration → JSON for the client.
pub fn to_json(v: &toml::Value) -> serde_json::Value {
serde_json::to_value(v).unwrap_or(serde_json::Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
use crate::registry::{ExtensionRegistry, Scope};
#[test]
fn ui_point_projects_into_the_store() {
let store = UiStore::new();
let reg = ExtensionRegistry::new();
reg.register(Arc::new(UiPoint::new("badges", store.clone(), |_pkg, decl| {
let text = decl.get("text").and_then(|v| v.as_str()).ok_or("missing text")?.to_owned();
let mut d = UiDecl::new(text.clone(), text.clone(), serde_json::json!({ "text": text }));
d.assets.push((format!("badge/{text}"), "<svg/>".into()));
Ok(d)
})))
.unwrap();
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[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();
assert!(store.revision() > rev);
let items = store.items("badges", Some("p"));
assert_eq!(items.len(), 1);
assert_eq!(items[0].decl["text"], "hi");
assert_eq!(store.asset("p", "badge/hi").as_deref(), Some("<svg/>"));
assert_eq!(store.assets_with_prefix("p", "badge/").len(), 1);
reg.dispose_plugin("p");
assert!(store.snapshot().is_empty());
assert!(store.asset("p", "badge/hi").is_none());
}
}
+136
Переглянути файл
@@ -0,0 +1,136 @@
//! Syntax check for `when` clauses in UI declarations. The client evaluates
//! them (`src/platform/context.ts`); validating here keeps the client's
//! projection from failing. Grammar (same as the client):
//!
//! ```text
//! expr := or ; or := and ("||" and)* ; and := unary ("&&" unary)*
//! unary := "!" unary | "(" expr ")" | cmp ; cmp := key (("==" | "!=") value)?
//! ```
const MAX_LEN: usize = 512;
#[derive(Debug, PartialEq)]
enum Tok<'a> {
Id(&'a str),
Str,
Op(&'a str),
}
fn tokenize(src: &str) -> Result<Vec<Tok<'_>>, String> {
let mut out = Vec::new();
let b = src.as_bytes();
let mut i = 0;
while i < b.len() {
let c = b[i] as char;
if c.is_whitespace() {
i += 1;
} else if ["&&", "||", "==", "!="].iter().any(|op| src[i..].starts_with(op)) {
out.push(Tok::Op(&src[i..i + 2]));
i += 2;
} else if matches!(c, '!' | '(' | ')') {
out.push(Tok::Op(&src[i..i + 1]));
i += 1;
} else if c == '\'' || c == '"' {
let end = src[i + 1..].find(c).ok_or_else(|| format!("unterminated string in when clause: {src}"))?;
out.push(Tok::Str);
i += end + 2;
} else {
let len = src[i..]
.find(|ch: char| !(ch.is_alphanumeric() || matches!(ch, '_' | '.' | '-' | ':' | '/')))
.unwrap_or(src.len() - i);
if len == 0 {
return Err(format!("unexpected '{c}' in when clause: {src}"));
}
out.push(Tok::Id(&src[i..i + len]));
i += len;
}
}
Ok(out)
}
/// Checks a `when` clause; empty means "always".
pub fn validate_when(src: &str) -> Result<(), String> {
if src.len() > MAX_LEN {
return Err(format!("when clause longer than {MAX_LEN} bytes"));
}
if src.trim().is_empty() {
return Ok(());
}
let toks = tokenize(src)?;
let mut p = 0;
parse_or(&toks, &mut p, src)?;
if p != toks.len() {
return Err(format!("unexpected token in when clause: {src}"));
}
Ok(())
}
fn parse_or(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
parse_and(t, p, src)?;
while t.get(*p) == Some(&Tok::Op("||")) {
*p += 1;
parse_and(t, p, src)?;
}
Ok(())
}
fn parse_and(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
parse_unary(t, p, src)?;
while t.get(*p) == Some(&Tok::Op("&&")) {
*p += 1;
parse_unary(t, p, src)?;
}
Ok(())
}
fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
match t.get(*p) {
Some(Tok::Op("!")) => {
*p += 1;
parse_unary(t, p, src)
}
Some(Tok::Op("(")) => {
*p += 1;
parse_or(t, p, src)?;
if t.get(*p) != Some(&Tok::Op(")")) {
return Err(format!("missing ')' in when clause: {src}"));
}
*p += 1;
Ok(())
}
Some(Tok::Id(_)) => {
*p += 1;
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
*p += 1;
match t.get(*p) {
Some(Tok::Id(_) | Tok::Str) => *p += 1,
_ => return Err(format!("missing value after comparison in when clause: {src}")),
}
}
Ok(())
}
_ => Err(format!("expected a context key in when clause: {src}")),
}
}
#[cfg(test)]
mod tests {
use super::validate_when;
#[test]
fn accepts_the_client_grammar() {
for ok in [
"",
"editorFocus",
"editorLang == rust",
"editorLang != 'rust' && !readOnly",
"(a || b) && toolWindow.project.active",
"resourceScheme == file:/x",
] {
assert!(validate_when(ok).is_ok(), "{ok}");
}
for bad in ["a &&", "(a", "a == ", "== a", "a b", "a $ b", "'x", "!()"] {
assert!(validate_when(bad).is_err(), "{bad}");
}
}
}
+13
Переглянути файл
@@ -30,15 +30,23 @@ ide-terminal.workspace = true
ide-run.workspace = true
ide-sdk.workspace = true
ide-http.workspace = true
reqwest.workspace = true
ignore.workspace = true
base64.workspace = true
getrandom = "0.3"
globset.workspace = true
ide-db.workspace = true
ide-ssh.workspace = true
ide-remote.workspace = true
ide-jsonrpc.workspace = true
ide-agent.workspace = true
ide-notebook.workspace = true
ide-extension.workspace = true
ide-plugin.workspace = true
ide-tools = { path = "../ide-tools" }
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
specta.workspace = true
specta-typescript.workspace = true
tokio = { workspace = true, features = ["io-std"] }
@@ -53,9 +61,14 @@ tempfile.workspace = true
[dev-dependencies]
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
criterion = { version = "0.5", default-features = false }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
[[bench]]
name = "plugins"
harness = false
+147
Переглянути файл
@@ -0,0 +1,147 @@
//! `cargo bench -p ide-host`: the plugin runtime budgets of
//! docs/plugin_plan.md 4.5, checked by `pnpm perf:rust` against
//! `perf/budgets.json` › rust (`plugins/…`).
//!
//! - `load_50`: start-up load of 50 installed plugins (20 with code, 10
//! languages with a syntax provider, 10 icon themes, 10 declarations only),
//! from the contribution index; no Wasm is instantiated.
//! - `cold_activation`: an idle-reclaimed plugin's next command: activation
//! from the precompiled component plus the call.
//! - `syntax_highlights_2k_lines`: an edit-path call (Solidity highlighting).
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use ide_core::LanguageId;
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Invocation, Limits};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for e in std::fs::read_dir(from).unwrap().flatten() {
let name = e.file_name();
if matches!(name.to_str(), Some("target" | "src" | "Cargo.lock" | ".gitignore")) {
continue;
}
let dst = to.join(&name);
if e.path().is_dir() { copy_dir(&e.path(), &dst) } else { std::fs::copy(e.path(), &dst).map(|_| ()).unwrap() }
}
}
/// A copy of `src` installed under a new id (and new language / theme ids).
fn variant(work: &Path, src: &Path, n: usize, rename: &[(&str, String)]) -> PathBuf {
let dir = work.join(format!("pkg{n}"));
copy_dir(src, &dir);
for file in ["plugin.toml", "languages/solidity.toml"] {
let path = dir.join(file);
if let Ok(mut text) = std::fs::read_to_string(&path) {
for (from, to) in rename {
text = text.replace(from, to);
}
std::fs::write(&path, text).unwrap();
}
}
dir
}
fn sink() -> Arc<dyn ide_host::plugins::PluginEventSink> {
Arc::new(|_: Option<&str>, _| {})
}
fn install_50(plugins: &Path, work: &Path) {
let host = PluginHost::new(plugins.to_path_buf(), sink(), Limits::default()).unwrap();
host.load_all();
let root = repo();
let mut n = 0;
let mut install = |src: PathBuf, rename: Vec<(&str, String)>| {
let pkg = variant(work, &src, n, &rename);
n += 1;
host.install_from_dir(&pkg, &Consent::default()).unwrap();
};
for i in 0..20 {
install(root.join("plugins/hello"), vec![("id = \"hello\"", format!("id = \"bench.hello{i}\""))]);
}
for i in 0..10 {
install(
root.join("plugins/official/solidity"),
vec![
("openidea.solidity", format!("bench.solidity{i}")),
("id = \"solidity\"", format!("id = \"solidity{i}\"")),
("extensions = [\"sol\"]", format!("extensions = [\"sol{i}\"]")),
("on-language:solidity", format!("on-language:solidity{i}")),
("id = \"forge-", format!("id = \"f{i}-")),
("id = \"hardhat-", format!("id = \"h{i}-")),
],
);
}
for i in 0..10 {
install(
root.join("plugins/official/material-icons"),
vec![
("openidea.material-icons", format!("bench.icons{i}")),
("id = \"material\"", format!("id = \"material{i}\"")),
],
);
}
for i in 0..10 {
let dir = work.join(format!("static{i}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("plugin.toml"),
format!(
"id = \"bench.static{i}\"\nname = \"Static {i}\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.commands]]\nid = \"a\"\ntitle = \"Static {i}: A\"\nwhen = \"editorLang == rust\"\n"
),
)
.unwrap();
host.install_from_dir(&dir, &Consent::default()).unwrap();
}
}
fn bench(c: &mut Criterion) {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
install_50(&plugins, &tmp.path().join("work"));
let mut g = c.benchmark_group("plugins");
g.sample_size(20);
g.bench_function("load_50", |b| {
b.iter(|| {
let host = PluginHost::new(plugins.clone(), sink(), Limits::default()).unwrap();
host.load_all();
assert_eq!(host.instances(), 0, "start-up instantiates no Wasm");
black_box(host.list(None).plugins.len())
})
});
let limits = Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() };
let host = Arc::new(PluginHost::new(plugins.clone(), sink(), limits).unwrap());
host.load_all();
host.warm_component_cache();
g.bench_function("cold_activation", |b| {
b.iter(|| {
host.tick();
// Fails for want of an editor, after activating and running.
let r = host.run_command("bench.hello0", "word-count", None, Invocation::default());
black_box(r.is_err())
})
});
let provider = host.syntax_provider();
let lang = LanguageId::new("solidity0");
let unit = "contract Token {\n uint256 public total;\n mapping(address => uint256) balances;\n event Sent(address to, uint256 v);\n function send(address to, uint256 v) public returns (bool) {\n // move funds\n balances[to] += v;\n emit Sent(to, v);\n return true;\n }\n}\n";
let text = unit.repeat(2000 / unit.lines().count());
assert!(provider.highlights(&lang, &text).is_some());
g.bench_function("syntax_highlights_2k_lines", |b| {
b.iter(|| black_box(provider.highlights(&lang, &text).map(|t| t.len())))
});
g.finish();
}
criterion_group!(benches, bench);
criterion_main!(benches);
+633
Переглянути файл
@@ -0,0 +1,633 @@
//! `openidea-plugin`: scaffolds, builds, checks and links OpenIDEA plugins.
//!
//! ```text
//! openidea-plugin new <dir> [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH]
//! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check`
//! openidea-plugin check [dir] # validate the package as the IDE would install it
//! openidea-plugin link [dir] # load the folder in the IDE without installing it
//! openidea-plugin unlink [dir]
//! openidea-plugin links # list linked folders
//! openidea-plugin keygen <key-file> # a publisher's Ed25519 key
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # <id>-<version>.oiplugin
//! openidea-plugin index <dir> --publisher ID --name NAME --key F # marketplace index.json
//! ```
//!
//! `link` writes `links.json` in the IDE's plugins directory
//! (`$OPENIDEA_PLUGINS_DIR`, or the app data directory); the IDE picks the
//! change up when Settings › Plugins is opened or refreshed. Linked plugins
//! are re-read from the folder, so `build` + refresh is the edit loop.
mod templates;
use ide_extension::{Manifest, validate_plugin_id};
use ide_host::plugins::PluginHost;
use ide_plugin::{Limits, PluginEngine};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::sync::Arc;
const USAGE: &str = "\
usage: openidea-plugin <command> [options]
commands:
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
create a plugin; T = command (default), theme, icon-theme, language
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
check [dir] validate the package as the IDE would install it
link [dir] load the folder in the IDE without installing it
unlink [dir] stop loading a linked folder
links list linked folders
keygen <key-file> create a publisher signing key (keep the file secret)
pack [dir] [--key F --publisher ID] [--out FILE]
write <id>-<version>.oiplugin, signed with the key
index <dir> --publisher ID --name NAME --key F [--out FILE]
write the marketplace index.json for the packages in dir
environment:
OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = match args.first().map(String::as_str) {
Some("new") => cmd_new(&args[1..]),
Some("build") => cmd_build(&args[1..]),
Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)),
Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)),
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
Some("links") => list_links(),
Some("keygen") => cmd_keygen(&args[1..]),
Some("pack") => cmd_pack(&args[1..]),
Some("index") => cmd_index(&args[1..]),
Some("--version" | "-V") => {
println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION);
Ok(())
}
Some("help" | "--help" | "-h") | None => {
println!("{USAGE}");
Ok(())
}
Some(other) => Err(format!("unknown command {other:?}\n\n{USAGE}")),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// Splits `args` into positionals and `--flag value` / `--switch` options.
struct Args {
positional: Vec<String>,
options: Vec<(String, Option<String>)>,
}
impl Args {
fn parse(args: &[String], switches: &[&str], valued: &[&str]) -> Result<Self, String> {
let mut out = Args { positional: Vec::new(), options: Vec::new() };
let mut it = args.iter();
while let Some(a) = it.next() {
if let Some(name) = a.strip_prefix("--") {
let (name, inline) = match name.split_once('=') {
Some((n, v)) => (n, Some(v.to_owned())),
None => (name, None),
};
if switches.contains(&name) && inline.is_none() {
out.options.push((name.into(), None));
} else if valued.contains(&name) {
let v = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("--{name} needs a value"))?;
out.options.push((name.into(), Some(v)));
} else {
return Err(format!("unknown option --{name}"));
}
} else {
out.positional.push(a.clone());
}
}
Ok(out)
}
fn value(&self, name: &str) -> Option<&str> {
self.options.iter().rev().find(|(n, _)| n == name).and_then(|(_, v)| v.as_deref())
}
fn has(&self, name: &str) -> bool {
self.options.iter().any(|(n, _)| n == name)
}
fn dir(&self) -> Result<PathBuf, String> {
match self.positional.as_slice() {
[] => Ok(PathBuf::from(".")),
[d] => Ok(PathBuf::from(d)),
_ => Err("expected at most one directory".into()),
}
}
}
fn dir_arg(args: &[String]) -> Result<PathBuf, String> {
Args::parse(args, &[], &[])?.dir()
}
fn canonical(dir: &Path) -> Result<PathBuf, String> {
dunce::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display()))
}
// ---------------------------------------------------------------- new
fn cmd_new(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["template", "id", "name", "sdk"])?;
let [dir] = a.positional.as_slice() else {
return Err("usage: openidea-plugin new <dir> [--template T] [--id ID] [--name NAME]".into());
};
let dir = PathBuf::from(dir);
let template = templates::Template::parse(a.value("template").unwrap_or("command"))?;
let base = dir.file_name().and_then(|n| n.to_str()).ok_or("the directory needs a name")?;
let id = match a.value("id") {
Some(id) => id.to_owned(),
None => slugify(base),
};
validate_plugin_id(&id)?;
let name = match a.value("name") {
Some(n) => n.to_owned(),
None => title_case(id.rsplit('.').next().unwrap_or(&id)),
};
if name.trim().is_empty() || name.contains(['"', '\\', '\n']) {
return Err("--name must not be empty or contain quotes, backslashes or newlines".into());
}
if dir.exists() && std::fs::read_dir(&dir).map_err(|e| e.to_string())?.next().is_some() {
return Err(format!("{} already exists and is not empty", dir.display()));
}
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let dir = canonical(&dir)?;
let sdk = match template {
templates::Template::Command => Some(sdk_path(a.value("sdk"), &dir)?),
_ => None,
};
let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() };
let files = templates::render(template, &vars);
for (rel, content) in &files {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(&path, content).map_err(|e| format!("{}: {e}", path.display()))?;
}
// Generated packages must be valid as written.
Manifest::load(&dir).map_err(|e| format!("internal error: the template is invalid: {e}"))?;
println!("created {} ({id}) in {}", template.label(), dir.display());
let shown = std::env::current_dir().ok().and_then(|cwd| dir.strip_prefix(cwd).ok().map(Path::to_path_buf));
let shown = shown.as_deref().unwrap_or(&dir).display().to_string();
println!();
println!("next:");
if template == templates::Template::Command {
println!(" cargo test --manifest-path {shown}/Cargo.toml # unit tests against the mock host");
println!(" openidea-plugin build {shown}");
} else {
println!(" openidea-plugin check {shown}");
}
println!(" openidea-plugin link {shown} # then Settings › Plugins");
Ok(())
}
/// `My Cool_Plugin` → `my-cool-plugin`.
fn slugify(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
match c {
'a'..='z' | '0'..='9' | '.' => out.push(c),
'A'..='Z' => out.push(c.to_ascii_lowercase()),
_ if !out.ends_with('-') && !out.is_empty() => out.push('-'),
_ => {}
}
}
out.trim_matches(['-', '.']).to_owned()
}
/// `my-cool_plugin` → `My Cool Plugin`.
fn title_case(s: &str) -> String {
s.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
c.next().map(|f| f.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
}
/// The SDK dependency path written into a new plugin's Cargo.toml: `--sdk`,
/// or the SDK of the source tree this tool was built from.
fn sdk_path(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
let sdk = match explicit {
Some(p) => PathBuf::from(p),
None => PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin")),
};
let sdk = dunce::canonicalize(&sdk).map_err(|_| {
format!("the plugin SDK was not found at {}; pass --sdk <path to sdk/rust/openidea-plugin>", sdk.display())
})?;
if !sdk.join("Cargo.toml").is_file() {
return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display()));
}
let path = relative(plugin_dir, &sdk).unwrap_or(sdk);
Ok(path.to_string_lossy().replace('\\', "/"))
}
/// `to` relative to `from` when they share a root (keeps packages in a repo movable).
fn relative(from: &Path, to: &Path) -> Option<PathBuf> {
let (f, t): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
let common = f.iter().zip(&t).take_while(|(a, b)| a == b).count();
if common <= 1 {
return None;
}
let mut out = PathBuf::new();
for _ in common..f.len() {
out.push("..");
}
for c in &t[common..] {
out.push(c);
}
Some(out)
}
// ---------------------------------------------------------------- build / check
fn cmd_build(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &["debug"], &[])?;
let dir = canonical(&a.dir()?)?;
let manifest = Manifest::load(&dir)?;
let cargo_toml = dir.join("Cargo.toml");
if !cargo_toml.is_file() {
if manifest.wasm.is_some() {
return Err(format!(
"{} has no Cargo.toml: build the component with your toolchain, then run `check`",
dir.display()
));
}
println!("{} has no code to build", manifest.id);
return check(&dir);
}
let wasm_rel = manifest.wasm.clone().ok_or("plugin.toml has no `wasm = \"dist/plugin.wasm\"`")?;
let crate_name = crate_name(&cargo_toml)?;
ensure_wasm_target();
let release = !a.has("debug");
let target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| dir.join("target"));
let mut cmd = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
cmd.arg("build").arg("--manifest-path").arg(&cargo_toml).args(["--target", "wasm32-wasip2"]);
if release {
cmd.arg("--release");
}
cmd.env("CARGO_TARGET_DIR", &target_dir);
println!("building {crate_name} for wasm32-wasip2 ({})", if release { "release" } else { "debug" });
let status = cmd.status().map_err(|e| format!("cannot run cargo: {e}"))?;
if !status.success() {
return Err("cargo build failed".into());
}
let built = target_dir
.join("wasm32-wasip2")
.join(if release { "release" } else { "debug" })
.join(format!("{}.wasm", crate_name.replace('-', "_")));
if !built.is_file() {
return Err(format!("{} was not produced: is `crate-type = [\"cdylib\"]` set in Cargo.toml?", built.display()));
}
let out = dir.join(&wasm_rel);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::copy(&built, &out).map_err(|e| format!("{}: {e}", out.display()))?;
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
println!(" -> {} ({})", wasm_rel.display(), human_size(size));
check(&dir)
}
fn crate_name(cargo_toml: &Path) -> Result<String, String> {
let src = std::fs::read_to_string(cargo_toml).map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
let doc: toml::Table = src.parse().map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
let package = doc.get("package").and_then(|p| p.as_table());
let lib_name = doc.get("lib").and_then(|l| l.get("name")).and_then(|n| n.as_str());
lib_name
.or_else(|| package.and_then(|p| p.get("name")).and_then(|n| n.as_str()))
.map(str::to_owned)
.ok_or_else(|| format!("{}: no [package] name", cargo_toml.display()))
}
/// Installs the target through rustup when it is missing (no-op without rustup).
fn ensure_wasm_target() {
let Ok(out) = Command::new("rustup").args(["target", "list", "--installed"]).output() else { return };
if String::from_utf8_lossy(&out.stdout).lines().any(|l| l.trim() == "wasm32-wasip2") {
return;
}
println!("installing the wasm32-wasip2 target");
let _ = Command::new("rustup").args(["target", "add", "wasm32-wasip2"]).status();
}
/// Validates `dir` exactly as Install from Disk would, and checks the
/// component imports only the plugin API.
fn check(dir: &Path) -> Result<(), String> {
let dir = canonical(dir)?;
let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id()));
let result = (|| {
let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.map_err(|e| e.to_string())?;
let preview = host.preview_install(&dir).map_err(|e| e.to_string())?;
let manifest = Manifest::load(&dir)?;
println!("{} {} — {}", preview.id, preview.version, preview.name);
if manifest.legacy {
println!(" note: plugin.toml uses the 0.1 format (`openidea-api`); see plugins/README.md to move to 0.2");
}
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 size = std::fs::metadata(dir.join(wasm)).map(|m| m.len()).unwrap_or(0);
println!(" code: {} ({})", wasm.display(), human_size(size));
} else {
println!(" code: none (contributions only)");
}
if !manifest.activation.is_empty() {
let events: Vec<String> = manifest.activation.iter().map(ToString::to_string).collect();
println!(" activation: {}", events.join(", "));
}
for c in &preview.contributions {
println!(" contributes {} × {}", c.count, c.point);
}
for c in &preview.capabilities {
let kind = if c.optional { "optional" } else { "required" };
println!(" capability {} ({kind}, {:?} risk): {}", c.id, c.risk, c.description);
}
for w in &preview.warnings {
println!(" warning: {w}");
}
println!("ok");
Ok(())
})();
let _ = std::fs::remove_dir_all(&scratch);
result
}
fn human_size(bytes: u64) -> String {
match bytes {
b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / (1 << 20) as f64),
b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0),
b => format!("{b} B"),
}
}
// ---------------------------------------------------------------- link
fn plugins_dir() -> Result<PathBuf, String> {
if let Some(dir) = std::env::var_os("OPENIDEA_PLUGINS_DIR") {
return Ok(PathBuf::from(dir));
}
dirs::data_dir()
.map(|d| d.join("ai.l0ng.openidea").join("plugins"))
.ok_or_else(|| "cannot find the app data directory; set OPENIDEA_PLUGINS_DIR".into())
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct Links {
#[serde(default)]
paths: Vec<PathBuf>,
}
fn read_links(file: &Path) -> Result<Links, String> {
match std::fs::read_to_string(file) {
Ok(s) => serde_json::from_str(&s).map_err(|e| format!("{}: {e}", file.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Links::default()),
Err(e) => Err(format!("{}: {e}", file.display())),
}
}
fn link(dir: &Path, add: bool) -> Result<(), String> {
let plugins = plugins_dir()?;
let file = plugins.join("links.json");
let mut links = read_links(&file)?;
let dir = if add {
let dir = canonical(dir)?;
let m = Manifest::load(&dir)?;
if let Some(wasm) = &m.wasm
&& !dir.join(wasm).is_file()
{
println!("note: {} does not exist yet; run `openidea-plugin build` first", wasm.display());
}
if links.paths.contains(&dir) {
println!("{} is already linked", dir.display());
return Ok(());
}
println!("linked {} ({})", m.id, dir.display());
links.paths.push(dir.clone());
dir
} else {
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
let before = links.paths.len();
links.paths.retain(|p| p != &dir);
if links.paths.len() == before {
return Err(format!("{} is not linked", dir.display()));
}
println!("unlinked {}", dir.display());
dir
};
let _ = dir;
std::fs::create_dir_all(&plugins).map_err(|e| format!("{}: {e}", plugins.display()))?;
let json = serde_json::to_string_pretty(&links).map_err(|e| e.to_string())?;
let tmp = file.with_extension("json.tmp");
std::fs::write(&tmp, json)
.and_then(|()| std::fs::rename(&tmp, &file))
.map_err(|e| format!("{}: {e}", file.display()))?;
println!("open Settings › Plugins in OpenIDEA to load the change");
Ok(())
}
fn list_links() -> Result<(), String> {
let links = read_links(&plugins_dir()?.join("links.json"))?;
if links.paths.is_empty() {
println!("no linked plugins");
}
for p in links.paths {
let id = Manifest::load(&p).map(|m| m.id).unwrap_or_else(|e| format!("invalid: {e}"));
println!("{id}\t{}", p.display());
}
Ok(())
}
// ---------------------------------------------------------------- distribution
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PublisherKey};
fn read_key(path: &str) -> Result<PublisherKey, String> {
PublisherKey::from_base64(&std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?)
}
fn cmd_keygen(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &[])?;
let [file] = a.positional.as_slice() else { return Err("usage: openidea-plugin keygen <key-file>".into()) };
if Path::new(file).exists() {
return Err(format!("{file} exists; not overwriting a key"));
}
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).map_err(|e| format!("random: {e}"))?;
let key = PublisherKey::from_seed(seed);
std::fs::write(file, key.seed_base64()).map_err(|e| format!("{file}: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600));
}
println!("public key: {}", key.public_base64());
println!("register it with your publisher id in the marketplace index; keep {file} secret");
Ok(())
}
/// A scratch host to list a package's files as the IDE would.
fn with_scratch_host<T>(f: impl FnOnce(&PluginHost) -> Result<T, String>) -> Result<T, String> {
let scratch = std::env::temp_dir().join(format!("openidea-plugin-pack-{}", std::process::id()));
let result = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.map_err(|e| e.to_string())
.and_then(|h| f(&h));
let _ = std::fs::remove_dir_all(&scratch);
result
}
fn cmd_pack(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["key", "publisher", "out"])?;
let dir = canonical(&a.dir()?)?;
check(&dir)?;
let manifest = Manifest::load(&dir)?;
let mut files = with_scratch_host(|h| h.package_files_of(&dir).map_err(|e| e.to_string()))?;
let digest = package::package_digest(&dir, &files).map_err(|e| e.to_string())?;
let signature = match (a.value("key"), a.value("publisher")) {
(Some(key), Some(publisher)) => {
if !manifest.id.starts_with(&format!("{publisher}.")) {
return Err(format!("publisher {publisher:?} may only sign plugins named {publisher}.*"));
}
Some(read_key(key)?.sign(publisher, &manifest.id, &manifest.version, &digest))
}
(None, None) => None,
_ => return Err("--key and --publisher go together".into()),
};
for lic in ["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "COPYING", "README.md"] {
if dir.join(lic).is_file() {
files.push(PathBuf::from(lic));
}
}
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| {
PathBuf::from(format!("{}-{}.{}", manifest.id, manifest.version, package::PACKAGE_EXTENSION))
});
package::pack(&dir, &files, &out, signature.as_ref())?;
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
let signed = signature.as_ref().map_or("unsigned".to_owned(), |s| format!("signed by {}", s.publisher));
println!("-> {} ({}, {signed})", out.display(), human_size(size));
Ok(())
}
fn cmd_index(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?;
let dir = canonical(&a.dir()?)?;
let publisher = a.value("publisher").ok_or("--publisher is required")?;
let name = a.value("name").unwrap_or(publisher);
let key = read_key(a.value("key").ok_or("--key is required")?)?;
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json"));
// Revocations and other publishers of an existing index are kept.
let mut index = match std::fs::read_to_string(&out) {
Ok(text) => Index::parse(&text)?,
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
};
// Earlier keys of the publisher stay listed (packages they signed remain valid until revoked).
let current = key.public_base64();
let earlier: Vec<KeyEntry> = index
.publishers
.iter()
.filter(|p| p.id == publisher)
.flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned())
.collect();
index.publishers.retain(|p| p.id != publisher);
let keys: Vec<KeyEntry> = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect();
index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
index.plugins.clear();
let mut packages: Vec<PathBuf> = std::fs::read_dir(&dir)
.map_err(|e| e.to_string())?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION))
.collect();
packages.sort();
for path in packages {
let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
let unpacked = package::unpack(&path, &scratch)?;
let entry = (|| {
let m = Manifest::load(&scratch)?;
let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?;
let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?;
let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?;
sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?;
Ok::<_, String>(IndexEntry {
id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
authors: m.authors.clone(),
publisher: sig.publisher.clone(),
download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
digest,
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
})
})();
let _ = std::fs::remove_dir_all(&scratch);
let entry = entry?;
println!(" {} {}", entry.id, entry.version);
index.plugins.push(entry);
}
let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?;
println!("-> {} ({} plugins)", out.display(), index.plugins.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names() {
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
assert_eq!(slugify("acme.Tools"), "acme.tools");
assert_eq!(title_case("material-icons"), "Material Icons");
assert_eq!(
relative(Path::new("/a/b/plugins/x"), Path::new("/a/b/sdk/rust/p")).unwrap(),
PathBuf::from("../../sdk/rust/p")
);
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
}
#[test]
fn every_template_is_a_valid_package() {
for t in templates::Template::ALL {
let dir =
std::env::temp_dir().join(format!("openidea-plugin-template-{}-{}", t.label(), std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("../sdk") };
for (rel, content) in templates::render(t, &vars) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
let m = Manifest::load(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
assert_eq!(m.id, "acme.sample");
if m.wasm.is_none() {
check(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
}
std::fs::remove_dir_all(&dir).unwrap();
}
}
}
+278
Переглянути файл
@@ -0,0 +1,278 @@
//! The files of `openidea-plugin new`. Placeholders: `__ID__` (plugin id),
//! `__SLUG__` (last id segment), `__NAME__`, `__CRATE__`, `__SDK__`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Template {
Command,
Theme,
IconTheme,
Language,
}
impl Template {
pub const ALL: [Template; 4] = [Self::Command, Self::Theme, Self::IconTheme, Self::Language];
pub fn parse(s: &str) -> Result<Self, String> {
Self::ALL
.into_iter()
.find(|t| t.label() == s)
.ok_or_else(|| format!("unknown template {s:?} (command, theme, icon-theme, language)"))
}
pub fn label(self) -> &'static str {
match self {
Self::Command => "command",
Self::Theme => "theme",
Self::IconTheme => "icon-theme",
Self::Language => "language",
}
}
}
pub struct Vars<'a> {
pub id: &'a str,
pub name: &'a str,
pub sdk: Option<&'a str>,
}
/// `(relative path, contents)` of every file of the template.
pub fn render(template: Template, vars: &Vars) -> Vec<(String, String)> {
let slug = vars.id.rsplit('.').next().unwrap_or(vars.id).to_owned();
let crate_name = format!("openidea-plugin-{}", vars.id.replace('.', "-"));
let fill = |s: &str| {
s.replace("__ID__", vars.id)
.replace("__SLUG__", &slug)
.replace("__NAME__", vars.name)
.replace("__CRATE__", &crate_name)
.replace("__SDK__", vars.sdk.unwrap_or(""))
// The theme files come from templates/theme-plugin.
.replace("harbor-night", &slug)
.replace("Harbor Night", vars.name)
};
let files: Vec<(String, &str)> = match template {
Template::Command => vec![
("plugin.toml".into(), COMMAND_MANIFEST),
("Cargo.toml".into(), COMMAND_CARGO),
("src/lib.rs".into(), COMMAND_LIB),
(".gitignore".into(), "/target\n"),
("README.md".into(), COMMAND_README),
],
Template::Theme => vec![
("plugin.toml".into(), THEME_MANIFEST),
(format!("themes/{slug}.theme.json"), THEME_UI),
(format!("themes/{slug}.scheme.json"), THEME_SCHEME),
],
Template::IconTheme => vec![
("plugin.toml".into(), ICON_MANIFEST),
("icons/theme.json".into(), ICON_THEME),
("icons/file.svg".into(), ICON_FILE),
("icons/folder.svg".into(), ICON_FOLDER),
("icons/folder-open.svg".into(), ICON_FOLDER_OPEN),
("icons/markdown.svg".into(), ICON_MARKDOWN),
],
Template::Language => {
vec![("plugin.toml".into(), LANGUAGE_MANIFEST), (format!("languages/{slug}.toml"), LANGUAGE_DEF)]
}
};
files.into_iter().map(|(path, content)| (path, fill(content))).collect()
}
const COMMAND_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "Adds two sample commands."
authors = []
engines.openidea = "^1.0"
# Built by `openidea-plugin build`. The code is loaded the first time one of
# its commands runs; the commands below show in Find Action before that.
wasm = "dist/plugin.wasm"
# What the code may do. The user approves these when installing.
[capabilities]
required = ["editor.read"]
[[contributes.commands]]
id = "say-hello"
title = "__NAME__: Say Hello"
category = "__NAME__"
[[contributes.commands]]
id = "line-count"
title = "__NAME__: Line Count of Active File"
category = "__NAME__"
"#;
const COMMAND_CARGO: &str = r#"[package]
name = "__CRATE__"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "__SDK__" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
"#;
const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin.
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
#[derive(Default)]
pub struct Main {
greetings: u32,
}
impl Plugin for Main {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
match id {
"say-hello" => {
// Instance state lasts until the plugin is idle for a while;
// keep what must survive in `host::storage`.
self.greetings += 1;
let body = format!("Hello #{} from __NAME__!", self.greetings);
host::notify(NotificationLevel::Info, "__NAME__", &body);
Ok(None)
}
"line-count" => {
let editor = ctx.editor().ok_or("no file is open")?;
let text = editor.document()?.text;
Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path())))
}
_ => Err(format!("unknown command {id}")),
}
}
}
register_plugin!(Main);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
#[test]
fn says_hello() {
let host = MockHost::install();
let mut plugin = Main::default();
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
assert_eq!(host.notifications()[1].body, "Hello #2 from __NAME__!");
}
#[test]
fn counts_lines() {
let host = MockHost::install();
host.open_editor("/project/src/main.rs", "rust", "fn main() {\n}\n", &[]);
let out = Main::default().run_command("line-count", &host.invocation(), None).unwrap();
assert_eq!(out.as_deref(), Some("2 lines in /project/src/main.rs"));
}
}
"#;
const COMMAND_README: &str = r#"# __NAME__
An OpenIDEA plugin.
```sh
cargo test # logic, against the SDK's mock host
openidea-plugin build # → dist/plugin.wasm, validated like an install
openidea-plugin link # load this folder in the IDE (Settings › Plugins)
```
`plugin.toml` declares the commands, so they appear in Find Action before the
code is loaded, and the capabilities the code needs. `src/lib.rs` implements
them with the `openidea-plugin` SDK.
"#;
const THEME_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "A dark theme."
authors = []
engines.openidea = "^1.0"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
[[contributes.themes]]
path = "themes/__SLUG__.theme.json"
editor-scheme = "themes/__SLUG__.scheme.json"
"#;
const THEME_UI: &str = include_str!("../../../../../templates/theme-plugin/themes/harbor-night.theme.json");
const THEME_SCHEME: &str = include_str!("../../../../../templates/theme-plugin/themes/harbor-night.scheme.json");
const ICON_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "File and folder icons."
authors = []
engines.openidea = "^1.0"
# Apply it in Settings › Plugins › __NAME__. SVGs are sanitised on install:
# no scripts, event handlers, external references or entities.
[[contributes.iconThemes]]
id = "__SLUG__"
label = "__NAME__"
path = "icons/theme.json"
"#;
const ICON_THEME: &str = r#"{
"iconDefinitions": {
"file": "icons/file.svg",
"folder": "icons/folder.svg",
"folder-open": "icons/folder-open.svg",
"markdown": "icons/markdown.svg"
},
"file": "file",
"folder": "folder",
"folderExpanded": "folder-open",
"fileExtensions": { "md": "markdown", "markdown": "markdown" },
"fileNames": { "readme": "markdown" },
"folderNames": {}
}
"#;
const ICON_FILE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1.5h5.5L13 5v9.5H4z"/><path fill="#cfd8dc" d="M9.5 1.5V5H13z"/></svg>
"##;
const ICON_FOLDER: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6.5v9h-13z"/></svg>
"##;
const ICON_FOLDER_OPEN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6v2h-10l-2 7h-.5z"/><path fill="#b0bec5" d="M4 7.5h11.5l-2 6H2z"/></svg>
"##;
const ICON_MARKDOWN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="3.5" width="14" height="9" rx="1.5" fill="#42a5f5"/><path fill="#fff" d="M3 10.5v-5h1.5L6 7.5l1.5-2H9v5H7.5V8L6 10 4.5 8v2.5zm8 0L9.5 8h1V5.5H12V8h1z"/></svg>
"##;
const LANGUAGE_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "Language support for .__SLUG__ files: detection, comments and indentation."
authors = []
engines.openidea = "^1.0"
# Same format as languages/<id>.toml in the IDE repository.
[[contributes.languages]]
path = "languages/__SLUG__.toml"
"#;
const LANGUAGE_DEF: &str = r##"id = "__SLUG__"
name = "__NAME__"
extensions = ["__SLUG__"]
line-comment = "#"
indent = { tab-size = 2, insert-spaces = true }
# Borrow an existing CodeMirror mode for instant highlighting.
ui-mode = "Properties files"
"##;
+30 -1
Переглянути файл
@@ -1,6 +1,8 @@
//! `openidea-server --stdio`: the IDE backend for remote development, serving
//! one desktop client over stdin / stdout (started by the client over SSH).
use ide_host::plugins::remote::{RemotePluginLanguages, SYNC_COMMAND};
use ide_host::plugins::remote_host::RemotePlugins;
use ide_host::rpc::{self, Dispatcher};
use ide_host::{Host, HostConfig};
use ide_language::LanguageRegistry;
@@ -51,8 +53,12 @@ async fn run(base: PathBuf) -> i32 {
return 1;
}
};
// The desktop's plugin languages (`plugin_sync_languages`); programs pinned on this machine.
let plugin_languages = RemotePluginLanguages::new(Some(&data_dir));
languages.attach_plugins(plugin_languages.store().clone());
let peer = Arc::new(OnceLock::new());
let sink_peer = peer.clone();
let plugins_data = data_dir.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
local_data_dir: data_dir.clone(),
@@ -64,8 +70,31 @@ async fn run(base: PathBuf) -> i32 {
runtime: tokio::runtime::Handle::current(),
project_environment: true,
}));
// The desktop's code plugins run here, next to the project (`plugin_sync_packages`).
let plugins = match RemotePlugins::start(&plugins_data, &host, peer.clone()) {
Ok(p) => Some(p),
Err(e) => {
tracing::warn!("plugins are unavailable: {e}");
None
}
};
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
let mut dispatcher = Dispatcher::standard();
if let Some(plugins) = &plugins {
plugins.register(&mut dispatcher);
}
dispatcher.add(SYNC_COMMAND, move |c| {
let remote = plugin_languages.clone();
async move {
let report = remote.sync(c.arg("languages")?);
c.host.plugin_languages_changed();
Ok(report)
}
});
rpc::serve(host.clone(), dispatcher, peer, tokio::io::stdin(), tokio::io::stdout()).await;
if let Some(plugins) = &plugins {
plugins.host().shutdown();
}
// `process::exit` skips destructors: nothing started for the client may outlive it.
if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() {
tracing::warn!("language servers did not shut down in time");
+13 -1
Переглянути файл
@@ -1,6 +1,6 @@
//! The IDE backend without a UI toolkit: open projects per window, terminals,
//! processes, settings and the remote services. The desktop shell (`src-tauri`)
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! wraps one [`Host`] and adds windows and menus; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod data_dirs;
@@ -10,6 +10,7 @@ mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
pub mod plugins;
mod project;
mod proxy;
mod requests;
@@ -133,6 +134,17 @@ impl Host {
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
}
/// Plugin languages or the servers they may start changed (they are part
/// of [`Self::languages`] and every project's fork): open documents are
/// detected again and language servers follow their new configuration.
pub fn plugin_languages_changed(&self) {
for project in self.projects() {
project.documents.redetect_languages();
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.reconfigure().await });
}
}
/// Every open project (all windows).
pub fn projects(&self) -> Vec<Arc<Project>> {
self.projects.read().values().cloned().collect()
+170 -6
Переглянути файл
@@ -1,9 +1,11 @@
use super::doc::blocking;
use crate::Host;
use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
@@ -38,9 +40,87 @@ pub async fn lsp_completion(
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger, cancel).await;
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
host.end_request(request);
result
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return result;
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
Ok(list)
}
/// The plugin host and the document, when some plugin provides `kind`.
fn plugin_providers(
host: &Host,
window: &str,
doc: DocId,
kind: ProviderKind,
) -> Option<(std::sync::Arc<crate::plugins::PluginHost>, ProviderDocument)> {
let plugins = editor_providers(kind)?;
let snap = host.project(window).ok()?.documents.snapshot(doc).ok()?;
let d = ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
Some((plugins, d))
}
/// Plugin calls block (the plugin's actor thread does the work).
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
tokio::task::spawn_blocking(f).await.ok()
}
/// Items from plugins carry no server (resolve and commands don't apply).
const PLUGIN_SERVER: ServerId = ServerId(0);
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,
})
});
CompletionItem {
label: item.label,
label_detail: None,
detail: item.detail,
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
sort_text: item.sort_text,
filter_text: None,
preselect: false,
deprecated: false,
insert_text: item.insert_text,
is_snippet: false,
replace_range: item.replace,
replace_range_tab: None,
additional_edits: Vec::new(),
commit_characters: Vec::new(),
command: None,
data: None,
server: PLUGIN_SERVER,
}
}
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
@@ -54,9 +134,26 @@ pub async fn lsp_inline_completion(
) -> IdeResult<Vec<InlineCompletionItem>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
let result = lsp.inline_completion(doc, pos, automatic, cancel.clone()).await;
// Plugins: cancelled (cooperatively) when the editor cancels the request.
let plugin = match plugin_providers(host, window, doc, ProviderKind::InlineCompletion) {
Some((plugins, d)) if !matches!(&result, Ok(items) if !items.is_empty()) => {
let cancel = cancel.clone();
plugin_call(move || plugins.provide_inline_completion(&d, pos, &cancel)).await.flatten()
}
_ => None,
};
host.end_request(request);
result
match plugin {
Some((_, s)) => Ok(vec![InlineCompletionItem {
insert_text: s.insert_text,
range: s.replace,
filter_text: None,
command: None,
server: PLUGIN_SERVER,
}]),
None => result,
}
}
pub async fn lsp_resolve_completion(
@@ -87,7 +184,40 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
host.project(window)?.lsp.goto(doc, pos, kind).await
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
if kind != GotoKind::Definition {
return result;
}
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Definition) else { return result };
let found = plugin_call(move || plugins.provide_definition(&d, pos)).await.unwrap_or_default();
if found.is_empty() {
return result;
}
let mut out = result.unwrap_or_default();
out.extend(found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None }));
Ok(out)
}
/// Gutter markers from plugins (`line-markers` providers) for `doc`.
pub async fn plugin_line_markers(
host: &Host,
window: &str,
doc: DocId,
) -> IdeResult<Vec<crate::plugins::LineMarkerInfo>> {
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::LineMarkers) else {
return Ok(Vec::new());
};
let markers = plugin_call(move || plugins.provide_line_markers(&d)).await.unwrap_or_default();
Ok(markers
.into_iter()
.map(|(plugin_id, m)| crate::plugins::LineMarkerInfo {
plugin_id,
line: m.line,
icon: m.icon,
tooltip: m.tooltip,
target: m.target.map(|l| Location { path: l.path.into(), range: l.range, uri: None }),
})
.collect())
}
/// Implementations of the symbols at `positions`, one list per position (the
@@ -165,10 +295,44 @@ pub async fn lsp_code_actions(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let actions = project.lsp.code_actions(doc, range, only).await?;
let result = project.lsp.code_actions(doc, range, only.clone()).await;
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
None => Vec::new(),
};
let plugin_actions: Vec<CodeAction> = plugin_actions
.into_iter()
.map(|(_, a)| plugin_code_action(a))
.filter(|a| {
only.is_empty()
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
})
.collect();
let actions = if plugin_actions.is_empty() {
result?
} else {
let mut out = result.unwrap_or_default();
out.extend(plugin_actions);
out
};
prepare_actions(project, doc, version, actions).await
}
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
let files =
a.edits.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None }).collect();
CodeAction {
title: a.title,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason: None,
edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
command: None,
data: None,
server: PLUGIN_SERVER,
}
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
pub async fn lsp_code_action_query(
host: &Host,
+790
Переглянути файл
@@ -0,0 +1,790 @@
//! [`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 ide_core::{CancellationToken, TextEdit};
use ide_extension::{ExtensionRegistry, Package};
use ide_language::ProgramPin;
use ide_plugin::{
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus,
StatusItem,
};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
/// Largest storage of one plugin (keys + values).
const MAX_STORAGE: usize = 1 << 20;
const MAX_KEY: usize = 256;
/// Most status bar items per plugin.
const MAX_STATUS_ITEMS: usize = 8;
/// What plugins see of the open projects; provided by the app
/// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop.
pub trait PluginWorkspace: Send + Sync {
/// Roots of the open projects.
fn projects(&self) -> Vec<PathBuf>;
/// Unsaved text of the open document at `path`.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
/// `version`, as one change (one undo step in the editor).
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError>;
/// Code from the project may run (git hooks and config on fetch).
fn trusted(&self, root: &Path) -> bool;
/// Root of the project open in `window`.
fn project_of(&self, window: &str) -> Option<PathBuf>;
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
/// The user's values of `plugin`'s settings: the project's at `root`
/// (over global), or global.
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
serde_json::Map::new()
}
}
/// Where plugin secrets live (the OS keychain on the desktop). Plugins can
/// only ask whether one is set; values go out only inside `net` requests.
pub trait SecretStore: Send + Sync {
fn get(&self, plugin: &str, name: &str) -> Option<String>;
/// Sets, or (`None`) deletes.
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String>;
}
/// Fallback [`SecretStore`]: one file per plugin, readable only by the user.
pub struct FileSecretStore {
pub dir: PathBuf,
}
impl FileSecretStore {
fn file(&self, plugin: &str) -> PathBuf {
self.dir.join(plugin).join("secrets.json")
}
fn load(&self, plugin: &str) -> BTreeMap<String, String> {
std::fs::read(self.file(plugin)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
}
impl SecretStore for FileSecretStore {
fn get(&self, plugin: &str, name: &str) -> Option<String> {
self.load(plugin).remove(name)
}
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
let mut map = self.load(plugin);
match value {
Some(v) => map.insert(name.to_owned(), v.to_owned()),
None => map.remove(name),
};
let file = self.file(plugin);
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?;
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, &file).map_err(|e| e.to_string())
}
}
/// Largest response body a plugin receives.
const MAX_RESPONSE: usize = 8 << 20;
/// Largest stdout / stderr kept from a process.
const MAX_OUTPUT: usize = 4 << 20;
/// Most files `find-files` walks before giving up.
const MAX_WALK: usize = 200_000;
/// Runtime for plugin HTTP requests (actor threads block on it).
pub(super) fn net_runtime() -> &'static tokio::runtime::Runtime {
static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("plugin-net")
.enable_all()
.build()
.expect("plugin network runtime")
})
}
/// Replaces `{{secret:<name>}}` in `value` with the plugin's secrets.
fn fill_secrets(value: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, HostError> {
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(i) = rest.find("{{secret:") {
out.push_str(&rest[..i]);
let after = &rest[i + "{{secret:".len()..];
let end = after.find("}}").ok_or_else(|| HostError::Invalid("unterminated {{secret:…}}".into()))?;
let name = &after[..end];
let secret = lookup(name).ok_or_else(|| HostError::NotFound(format!("secret {name:?} is not set")))?;
out.push_str(&secret);
rest = &after[end + 2..];
}
out.push_str(rest);
Ok(out)
}
/// A plugin task shown in the UI.
struct TaskEntry {
plugin: String,
cancel: CancellationToken,
cancelled: bool,
}
/// 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,
tasks: Mutex<HashMap<u64, TaskEntry>>,
next_task: AtomicU64,
/// (plugin, item id) → item, for windows opened later.
status: Mutex<BTreeMap<(String, String), StatusItem>>,
/// plugin → kinds subscribed at run time (kept across idle reclaim).
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
pub secrets: RwLock<Arc<dyn SecretStore>>,
/// (plugin, `process:` capability) → the approved program.
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
/// Validated UI declarations (settings schemas, declared views).
pub ui: Arc<ide_extension::UiStore>,
}
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>,
) -> Arc<Self> {
Arc::new(Self {
ui,
sink,
registry,
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
pins,
data_dir,
tasks: Mutex::default(),
next_task: AtomicU64::new(1),
status: Mutex::default(),
subscriptions: Mutex::default(),
})
}
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
self.workspace.read().clone()
}
pub fn project_of(&self, window: &str) -> Option<PathBuf> {
self.workspace()?.project_of(window)
}
pub fn status_items(&self) -> Vec<(String, String, StatusItem)> {
self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect()
}
/// Withdraws a plugin's run-time state (it stopped or failed). Its
/// subscriptions go only with `subscriptions`, as they survive restarts.
pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) {
let removed: Vec<String> = {
let mut status = self.status.lock();
let ids: Vec<String> = status.keys().filter(|(p, _)| p == plugin).map(|(_, id)| id.clone()).collect();
for id in &ids {
status.remove(&(plugin.to_owned(), id.clone()));
}
ids
};
for id in removed {
self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None });
}
let tasks: Vec<u64> = self.tasks.lock().iter().filter(|(_, t)| t.plugin == plugin).map(|(id, _)| *id).collect();
for id in tasks {
self.end_task(id);
}
if subscriptions && self.subscriptions.lock().remove(plugin).is_some() {
self.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
/// The user cancelled a task in the UI.
pub fn cancel_task(&self, id: u64) -> bool {
let mut tasks = self.tasks.lock();
let Some(t) = tasks.get_mut(&id) else { return false };
t.cancelled = true;
t.cancel.cancel();
true
}
fn end_task(&self, id: u64) {
if let Some(t) = self.tasks.lock().remove(&id) {
self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() });
}
}
pub fn storage_file(&self, plugin: &str) -> PathBuf {
self.data_dir.join(plugin).join("storage.json")
}
}
pub(super) struct PluginApi {
pub pkg: Package,
pub shared: Arc<Shared>,
}
impl PluginApi {
fn id(&self) -> &str {
self.pkg.id()
}
fn load_storage(&self) -> Result<BTreeMap<String, String>, HostError> {
match std::fs::read(self.shared.storage_file(self.id())) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
Err(e) => Err(HostError::Failed(format!("storage: {e}"))),
}
}
fn save_storage(&self, map: &BTreeMap<String, String>) -> Result<(), HostError> {
let file = self.shared.storage_file(self.id());
let bytes = serde_json::to_vec(map).map_err(|e| HostError::Failed(e.to_string()))?;
let io = |e: std::io::Error| HostError::Failed(format!("storage: {e}"));
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(io)?;
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(io)
}
/// The program approved for the (already checked) `process:` grant,
/// verified right before it starts.
fn approved_program(&self, program: &str) -> Result<PathBuf, HostError> {
let cap = format!("process:{program}");
// Granted without a pinned file: the program wasn't found when it was
// allowed, and installing it later approves nothing by itself.
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
HostError::Failed(format!(
"{cap}: `{program}` was not found when this permission was allowed; \
install it, then allow {cap} again in Settings › Plugins"
))
})?;
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
}
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
impl HostApi for PluginApi {
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String) {
self.shared
.sink
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
}
fn status_item(&self, id: &str, item: Option<StatusItem>) {
let key = (self.id().to_owned(), id.to_owned());
{
let mut status = self.shared.status.lock();
match &item {
Some(item) => {
let count = status.keys().filter(|(p, _)| p == self.id()).count();
if !status.contains_key(&key) && count >= MAX_STATUS_ITEMS {
tracing::warn!(plugin = %self.id(), "more than {MAX_STATUS_ITEMS} status items; {id:?} ignored");
return;
}
if status.get(&key) == Some(item) {
return;
}
status.insert(key, item.clone());
}
None => {
if status.remove(&key).is_none() {
return;
}
}
}
}
self.shared
.sink
.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()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
self.shared.workspace()?.buffer_text(path)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
self.shared.workspace()?.document(path)
}
fn apply_edits(
&self,
_client: Option<&str>,
path: &Path,
version: u64,
edits: Vec<TextEdit>,
) -> Result<u64, HostError> {
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
workspace.apply_edits(path, version, &edits)
}
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
Ok(self.load_storage()?.get(key).cloned())
}
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError> {
if key.is_empty() || key.len() > MAX_KEY {
return Err(HostError::Invalid(format!("keys are 1–{MAX_KEY} bytes")));
}
let mut map = self.load_storage()?;
let changed = match value {
Some(v) => map.insert(key.to_owned(), v.clone()).as_ref() != Some(&v),
None => map.remove(key).is_some(),
};
if !changed {
return Ok(());
}
let size: usize = map.iter().map(|(k, v)| k.len() + v.len()).sum();
if size > MAX_STORAGE {
return Err(HostError::Invalid(format!("storage is limited to {} KiB", MAX_STORAGE >> 10)));
}
self.save_storage(&map)
}
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect())
}
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(None) };
let info = repo.branch_info().map_err(failed)?;
let changed = repo.status().map_err(failed)?.len();
Ok(Some(RepositoryStatus {
root: repo.root().to_string_lossy().into_owned(),
branch: info.head,
commit: info.head_commit,
upstream: info.upstream,
ahead: info.ahead,
behind: info.behind,
operation: info.operation,
changed_files: u32::try_from(changed).unwrap_or(u32::MAX),
}))
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
let workspace = self.shared.workspace();
if !workspace.is_some_and(|w| w.trusted(root)) {
return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into()));
}
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else {
return Err(HostError::NotFound("the project is not in a git repository".into()));
};
match repo.fetch(cancel, &|_| {}) {
Ok(()) => Ok(()),
Err(_) if cancel.is_cancelled() => Err(HostError::Cancelled),
Err(e) => Err(failed(e.message)),
}
}
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
let matcher = globset::GlobBuilder::new(glob)
.literal_separator(true)
.build()
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
.compile_matcher();
let mut out = Vec::new();
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
if n >= MAX_WALK || out.len() >= limit {
break;
}
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
out.push(entry.path().to_string_lossy().into_owned());
}
}
out.sort();
Ok(out)
}
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
workspace.publish_diagnostics(self.id(), path, items)
}
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError> {
let secrets = self.shared.secrets.read().clone();
let id = self.id().to_owned();
let mut headers = reqwest::header::HeaderMap::new();
for (name, value) in &request.headers {
let value = fill_secrets(value, |n| secrets.get(&id, n))?;
let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|e| HostError::Invalid(format!("header {name:?}: {e}")))?;
let mut value = reqwest::header::HeaderValue::from_str(&value)
.map_err(|e| HostError::Invalid(format!("header {name}: {e}")))?;
value.set_sensitive(true);
headers.append(name, value);
}
let method = reqwest::Method::from_bytes(request.method.to_ascii_uppercase().as_bytes())
.map_err(|_| HostError::Invalid(format!("method {:?}", request.method)))?;
let client = ide_http::client::shared().map_err(|e| HostError::Failed(e.message))?;
// A redirect to another host is refused after the fact (the shared
// client follows redirects).
let builder = client.request(method, &request.url).headers(headers).timeout(request.timeout);
let builder = match request.body {
Some(body) => builder.body(body),
None => builder,
};
let cancel = cancel.clone();
let host = ide_plugin::net_host(&request.url).map_err(HostError::Invalid)?;
net_runtime().block_on(async move {
let send = async {
let response = builder.send().await.map_err(|e| HostError::Failed(e.to_string()))?;
if ide_plugin::net_host(response.url().as_str()).ok().as_deref() != Some(host.as_str()) {
return Err(HostError::Denied(format!(
"net:{} (redirected)",
response.url().host_str().unwrap_or("?")
)));
}
let status = response.status().as_u16();
let headers = response
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_owned(), String::from_utf8_lossy(v.as_bytes()).into_owned()))
.collect();
let mut body = Vec::new();
let mut response = response;
while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Failed(e.to_string()))? {
if body.len() + chunk.len() > MAX_RESPONSE {
return Err(HostError::Failed("response larger than 8 MiB".into()));
}
body.extend_from_slice(&chunk);
}
Ok(HttpResponse { status, headers, body })
};
tokio::select! {
r = send => r,
() = cancel.cancelled() => Err(HostError::Cancelled),
}
})
}
fn secret_is_set(&self, name: &str) -> bool {
self.shared.secrets.read().get(self.id(), name).is_some()
}
fn run_process(
&self,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
stdin: Option<String>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<ProcessOutput, HostError> {
let binary = self.approved_program(program)?;
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&cwd);
let mut child = std::process::Command::new(&binary)
.args(&args)
.current_dir(&cwd)
.stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() })
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) {
std::thread::spawn(move || {
use std::io::Write;
let _ = pipe.write_all(input.as_bytes());
});
}
let read = |pipe: Option<Box<dyn std::io::Read + Send>>| {
std::thread::spawn(move || {
use std::io::Read;
let mut out = Vec::new();
if let Some(p) = pipe {
let _ = p.take(MAX_OUTPUT as u64).read_to_end(&mut out);
}
String::from_utf8_lossy(&out).into_owned()
})
};
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = child.try_wait().map_err(|e| HostError::Failed(e.to_string()))? {
break status;
}
if cancel.is_cancelled() || Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(if cancel.is_cancelled() {
HostError::Cancelled
} else {
HostError::Failed(format!("{program} timed out after {timeout:?}"))
});
}
std::thread::sleep(Duration::from_millis(10));
};
Ok(ProcessOutput {
status: status.code(),
stdout: stdout.join().unwrap_or_default(),
stderr: stderr.join().unwrap_or_default(),
})
}
fn subscribe(&self, kind: EventKind, on: bool) {
let changed = {
let mut subs = self.shared.subscriptions.lock();
let set = subs.entry(self.id().to_owned()).or_default();
if on { set.insert(kind) } else { set.remove(&kind) }
};
if changed {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64 {
let id = self.shared.next_task.fetch_add(1, Ordering::Relaxed);
self.shared.tasks.lock().insert(id, TaskEntry { plugin: self.id().to_owned(), cancel, cancelled: false });
self.shared.sink.emit(
client,
PluginEvent::TaskStarted { plugin_id: self.id().to_owned(), task_id: id.to_string(), title, cancellable },
);
id
}
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>) {
if !self.shared.tasks.lock().contains_key(&id) {
return;
}
self.shared.sink.emit(
None,
PluginEvent::TaskProgress {
plugin_id: self.id().to_owned(),
task_id: id.to_string(),
done,
total,
message,
},
);
}
fn task_cancelled(&self, id: u64) -> bool {
self.shared.tasks.lock().get(&id).is_some_and(|t| t.cancelled)
}
fn task_end(&self, id: u64) {
self.shared.end_task(id);
}
fn refresh_view(&self, view: &str) {
self.shared
.sink
.emit(None, PluginEvent::ViewChanged { plugin_id: self.id().to_owned(), view: view.to_owned() });
}
fn post_view_message(&self, view: &str, message: String) -> Result<(), HostError> {
let declared = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
.iter()
.any(|point| self.shared.ui.items(point, Some(self.id())).iter().any(|i| i.key == view));
if !declared {
return Err(HostError::NotFound(format!("the plugin declares no view {view:?}")));
}
let event = PluginEvent::ViewMessage { plugin_id: self.id().to_owned(), view: view.to_owned(), message };
self.shared.sink.emit(None, event);
Ok(())
}
fn open_terminal(
&self,
client: Option<&str>,
title: String,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError> {
let binary = self.approved_program(program)?;
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&cwd);
let event = PluginEvent::OpenTerminal {
plugin_id: self.id().to_owned(),
title,
program: binary.into(),
args,
cwd: cwd.into(),
};
self.shared.sink.emit(client, event);
Ok(())
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
.ui
.items(super::ui_points::SETTINGS, Some(self.id()))
.into_iter()
.find_map(|i| serde_json::from_value(i.decl.get("properties")?.clone()).ok())?;
let setting = schema.into_iter().find(|s| s.key == key)?;
let user = self.shared.workspace().map(|w| w.plugin_settings(self.id(), root)).unwrap_or_default();
let value = user.get(key).filter(|v| setting.accepts(v)).cloned().unwrap_or(setting.default);
serde_json::to_string(&value).ok()
}
}
/// [`PluginWorkspace`] over the desktop host's open projects.
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
impl HostWorkspace {
fn projects(&self) -> Vec<Arc<crate::project::Project>> {
self.0.upgrade().map(|h| h.projects()).unwrap_or_default()
}
/// The project holding an open document at `path`, and its id.
fn open_document(&self, path: &Path) -> Option<(Arc<crate::project::Project>, ide_core::DocId)> {
self.projects().into_iter().find_map(|p| p.documents.by_path(path).map(|doc| (p, doc)))
}
}
impl PluginWorkspace for HostWorkspace {
fn projects(&self) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = HostWorkspace::projects(self).iter().map(|p| p.root.clone()).collect();
roots.sort();
roots.dedup();
roots
}
fn buffer_text(&self, path: &Path) -> Option<String> {
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let (project, doc) = self.open_document(path)?;
let snap = project.documents.snapshot(doc).ok()?;
Some((u64::from(snap.version), snap.text))
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError> {
let (project, doc) =
self.open_document(path).ok_or_else(|| HostError::NotFound(format!("{} is not open", path.display())))?;
let version = u32::try_from(version).map_err(|_| HostError::Stale)?;
match project.documents.apply_edits_at(doc, version, edits) {
Ok(Some(v)) => Ok(u64::from(v)),
Ok(None) => Err(HostError::Stale),
Err(e) if e.kind == ide_core::error::ErrorKind::InvalidInput => Err(HostError::Invalid(e.message)),
Err(e) => Err(HostError::Failed(e.message)),
}
}
fn trusted(&self, root: &Path) -> bool {
HostWorkspace::projects(self).iter().any(|p| p.root == root && p.is_trusted())
}
fn project_of(&self, window: &str) -> Option<PathBuf> {
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
}
fn publish_diagnostics(
&self,
source: &str,
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
let projects = HostWorkspace::projects(self);
let project = projects
.iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
project.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin:{source}"),
items,
});
Ok(())
}
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
Some(p) => p.settings.effective(),
None => host.global_settings.effective(),
};
settings.plugins.get(plugin).cloned().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn api(dir: &Path, pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>) -> PluginApi {
let manifest = ide_extension::Manifest::parse(
"id = \"acme.lint\"\nname = \"Lint\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n",
)
.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(),
);
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("lint");
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
let api = api(tmp.path(), pins.clone());
// Allowed while the program was missing: say how to fix it rather than "not granted".
let err = api.approved_program("lint").unwrap_err().to_string();
assert!(
err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"),
"{err}"
);
pins.write().insert(("acme.lint".into(), "process:lint".into()), ProgramPin::approve(&bin).unwrap());
assert_eq!(api.approved_program("lint").unwrap(), bin);
std::fs::write(&bin, "#!/bin/sh\necho replaced\n").unwrap();
assert!(
matches!(api.approved_program("lint"), Err(HostError::Denied(e)) if e.contains("changed since it was approved"))
);
}
}
+610
Переглянути файл
@@ -0,0 +1,610 @@
//! The `iconThemes` extension point: file and folder icons.
//!
//! ```toml
//! [[contributes.iconThemes]]
//! id = "material"
//! label = "Material Icons"
//! path = "icons/theme.json"
//! ```
//!
//! `theme.json` (paths relative to the package):
//!
//! ```json
//! {
//! "iconDefinitions": { "rust": "icons/rust.svg", "folder-src": "icons/folder-src.svg" },
//! "file": "file", "folder": "folder", "folderExpanded": "folder-open",
//! "fileExtensions": { "rs": "rust" },
//! "fileNames": { "cargo.toml": "cargo" },
//! "folderNames": { "src": "folder-src" }
//! }
//! ```
//!
//! Every SVG is sanitised here (allow-listed elements and attributes, no
//! scripts, event handlers, external references or entities) and stored as an
//! asset; the client fetches the mapping and the icons only when the theme is
//! selected, so installed icon themes cost nothing at start-up.
use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
pub const POINT_ID: &str = "iconThemes";
const MAX_ICONS: usize = 5000;
const MAX_SVG: usize = 64 << 10;
const MAX_MAPPINGS: usize = 20_000;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
id: String,
label: String,
path: PathBuf,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ThemeFile {
icon_definitions: BTreeMap<String, PathBuf>,
#[serde(default)]
file: Option<String>,
#[serde(default)]
folder: Option<String>,
#[serde(default)]
folder_expanded: Option<String>,
#[serde(default)]
file_extensions: BTreeMap<String, String>,
#[serde(default)]
file_names: BTreeMap<String, String>,
#[serde(default)]
folder_names: BTreeMap<String, String>,
}
/// Asset id of a theme's mapping (JSON).
pub fn map_asset(theme: &str) -> String {
format!("iconTheme/{theme}/map")
}
/// Asset id prefix of a theme's SVGs (`…/svg/<icon id>`).
pub fn svg_prefix(theme: &str) -> String {
format!("iconTheme/{theme}/svg/")
}
pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: Decl = decode(POINT_ID, decl)?;
validate_item_id(&d.id, "icon theme id")?;
if d.label.trim().is_empty() {
return Err(format!("icon theme {:?} has an empty label", d.id));
}
let src = pkg.read_text(&d.path)?;
let theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?;
let at = |what: &str| format!("{} ({what})", d.path.display());
if theme.icon_definitions.len() > MAX_ICONS {
return Err(format!("{}: more than {MAX_ICONS} icons", d.path.display()));
}
let mappings = theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len();
if mappings > MAX_MAPPINGS {
return Err(format!("{}: more than {MAX_MAPPINGS} mappings", d.path.display()));
}
let defined: BTreeSet<&str> = theme.icon_definitions.keys().map(String::as_str).collect();
let check_ref = |icon: &str, what: &str| -> Result<(), String> {
if defined.contains(icon) { Ok(()) } else { Err(format!("{}: unknown icon {icon:?}", at(what))) }
};
for (what, icon) in [("file", &theme.file), ("folder", &theme.folder), ("folderExpanded", &theme.folder_expanded)] {
if let Some(icon) = icon {
check_ref(icon, what)?;
}
}
// Extensions may be written with their dot (`.rs`); names keep theirs (`.github`, `.gitignore`).
let lower = |map: &BTreeMap<String, String>, what: &str| -> Result<BTreeMap<String, String>, String> {
let mut out = BTreeMap::new();
for (k, icon) in map {
check_ref(icon, what)?;
let key = k.trim();
let key = if what == "fileExtensions" { key.trim_start_matches('.') } else { key }.to_lowercase();
if key.is_empty() || key.contains(['/', '\\']) {
return Err(format!("{}: invalid key {k:?}", at(what)));
}
out.insert(key, icon.clone());
}
Ok(out)
};
let file_extensions = lower(&theme.file_extensions, "fileExtensions")?;
let file_names = lower(&theme.file_names, "fileNames")?;
let folder_names = lower(&theme.folder_names, "folderNames")?;
let mut assets = Vec::with_capacity(theme.icon_definitions.len() + 1);
let mut files = vec![d.path.clone()];
for (icon, path) in &theme.icon_definitions {
validate_item_id(icon, "icon id")?;
check_relative(path, "icon")?;
let svg = pkg.read_text(path)?;
if svg.len() > MAX_SVG {
return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10));
}
let clean = sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display()))?;
assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean));
files.push(path.clone());
}
let map = serde_json::json!({
"file": theme.file,
"folder": theme.folder,
"folderExpanded": theme.folder_expanded,
"fileExtensions": file_extensions,
"fileNames": file_names,
"folderNames": folder_names,
});
assets.push((map_asset(&d.id), map.to_string()));
let json = serde_json::json!({ "id": d.id, "label": d.label, "icons": theme.icon_definitions.len() });
let mut out = UiDecl::new(d.id.clone(), d.label, json);
out.files = files;
out.assets = assets;
Ok(out)
}
// ---------------------------------------------------------------------------
// SVG sanitiser
const ELEMENTS: &[&str] = &[
"svg",
"g",
"path",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"rect",
"defs",
"lineargradient",
"radialgradient",
"stop",
"clippath",
"mask",
"use",
"symbol",
"pattern",
"text",
"tspan",
"filter",
"fegaussianblur",
"feoffset",
"feblend",
"fecolormatrix",
"feflood",
"fecomposite",
"femerge",
"femergenode",
"fedropshadow",
];
/// Dropped with their content: harmless metadata.
const STRIPPED: &[&str] = &["title", "desc", "metadata"];
const ATTRIBUTES: &[&str] = &[
"id",
"class",
"viewbox",
"width",
"height",
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"cx",
"cy",
"r",
"rx",
"ry",
"fx",
"fy",
"d",
"points",
"transform",
"fill",
"fill-opacity",
"fill-rule",
"clip-rule",
"clip-path",
"mask",
"stroke",
"stroke-width",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-opacity",
"opacity",
"offset",
"stop-color",
"stop-opacity",
"gradientunits",
"gradienttransform",
"spreadmethod",
"patternunits",
"patterncontentunits",
"patterntransform",
"clippathunits",
"maskunits",
"maskcontentunits",
"preserveaspectratio",
"style",
"href",
"xlink:href",
"xmlns",
"xmlns:xlink",
"version",
"font-family",
"font-size",
"font-weight",
"text-anchor",
"dominant-baseline",
"letter-spacing",
"display",
"visibility",
"color",
"filter",
"in",
"in2",
"result",
"stddeviation",
"dx",
"dy",
"mode",
"operator",
"values",
"type",
"flood-color",
"flood-opacity",
"k1",
"k2",
"k3",
"k4",
"filterunits",
"primitiveunits",
"vector-effect",
"shape-rendering",
"isolation",
"mix-blend-mode",
];
/// Parses `src` and re-serialises the allow-listed subset. Fails on anything
/// that could execute, fetch or escape (scripts, `foreignObject`, event
/// handlers, external `href`s, `url()` to anything but a local fragment,
/// DTDs, entities, CDATA).
pub fn sanitize_svg(src: &str) -> Result<String, String> {
let mut out = String::with_capacity(src.len());
let mut stack: Vec<String> = Vec::new();
// Depth inside a stripped element (its content is skipped).
let mut skip_depth = 0usize;
let mut seen_root = false;
let mut rest = src;
while !rest.is_empty() {
let Some(lt) = rest.find('<') else {
if skip_depth == 0 && !stack.is_empty() {
out.push_str(&text(rest)?);
} else if !rest.trim().is_empty() && stack.is_empty() {
return Err("text outside the <svg> element".into());
}
break;
};
if lt > 0 {
let t = &rest[..lt];
if skip_depth == 0 && !stack.is_empty() {
out.push_str(&text(t)?);
} else if stack.is_empty() && !t.trim().is_empty() {
return Err("text outside the <svg> element".into());
}
}
rest = &rest[lt..];
if let Some(after) = rest.strip_prefix("<!--") {
let end = after.find("-->").ok_or("unterminated comment")?;
rest = &after[end + 3..];
continue;
}
if rest.starts_with("<?") {
let end = rest.find("?>").ok_or("unterminated processing instruction")?;
rest = &rest[end + 2..];
continue;
}
if rest.starts_with("<!") {
return Err("DTDs, entities and CDATA are not allowed".into());
}
if let Some(after) = rest.strip_prefix("</") {
let end = after.find('>').ok_or("unterminated closing tag")?;
let name = after[..end].trim().to_ascii_lowercase();
rest = &after[end + 1..];
let open = stack.pop().ok_or_else(|| format!("unexpected </{name}>"))?;
if open != name {
return Err(format!("</{name}> does not close <{open}>"));
}
if skip_depth > 0 {
skip_depth -= 1;
} else {
out.push_str(&format!("</{name}>"));
}
continue;
}
// Start tag.
let end = tag_end(rest).ok_or("unterminated tag")?;
let inner = &rest[1..end];
rest = &rest[end + 1..];
let self_closing = inner.trim_end().ends_with('/');
let inner = inner.trim_end().trim_end_matches('/');
let name_end = inner.find(|c: char| c.is_whitespace()).unwrap_or(inner.len());
let raw_name = &inner[..name_end];
let name = raw_name.to_ascii_lowercase();
if name.is_empty() {
return Err("empty tag".into());
}
if stack.is_empty() {
if seen_root || name != "svg" {
return Err("the file must contain exactly one <svg> element".into());
}
seen_root = true;
}
if !self_closing {
stack.push(name.clone());
}
if skip_depth > 0 {
if !self_closing {
skip_depth += 1;
}
continue;
}
if STRIPPED.contains(&name.as_str()) || name.contains(':') {
// Editor metadata (sodipodi:namedview) or descriptive text.
if !self_closing {
skip_depth = 1;
}
continue;
}
if !ELEMENTS.contains(&name.as_str()) {
return Err(format!("<{raw_name}> is not allowed"));
}
out.push('<');
out.push_str(raw_name);
for (attr, value) in attributes(&inner[name_end..])? {
let key = attr.to_ascii_lowercase();
if key.starts_with("on") {
return Err(format!("event handler {attr} is not allowed"));
}
if key.contains(':') && !matches!(key.as_str(), "xlink:href" | "xmlns:xlink") {
continue; // inkscape:label, sodipodi:docname, …
}
if !ATTRIBUTES.contains(&key.as_str()) && !key.starts_with("data-") && !key.starts_with("aria-") {
return Err(format!("attribute {attr} is not allowed"));
}
check_value(&key, &value).map_err(|e| format!("{attr}: {e}"))?;
out.push(' ');
out.push_str(&attr);
out.push_str("=\"");
out.push_str(&escape(&value));
out.push('"');
}
out.push_str(if self_closing { "/>" } else { ">" });
}
if !stack.is_empty() {
return Err(format!("<{}> is not closed", stack.last().map(String::as_str).unwrap_or("?")));
}
if !seen_root {
return Err("no <svg> element".into());
}
Ok(out)
}
/// Index of the `>` ending the tag at the start of `s`, skipping quoted values.
fn tag_end(s: &str) -> Option<usize> {
let mut quote: Option<char> = None;
for (i, c) in s.char_indices().skip(1) {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '>') => return Some(i),
(None, '<') => return None,
_ => {}
}
}
None
}
fn attributes(s: &str) -> Result<Vec<(String, String)>, String> {
let mut out = Vec::new();
let mut rest = s.trim_start();
while !rest.is_empty() {
let eq = rest.find('=').ok_or_else(|| format!("attribute without value near {:?}", clip(rest)))?;
let name = rest[..eq].trim();
if name.is_empty() || name.contains(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
return Err(format!("malformed attribute near {:?}", clip(rest)));
}
let after = rest[eq + 1..].trim_start();
let quote = after.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("unquoted attribute value")?;
let close = after[1..].find(quote).ok_or("unterminated attribute value")?;
let value = decode_entities(&after[1..1 + close])?;
if out.iter().any(|(n, _): &(String, String)| n.eq_ignore_ascii_case(name)) {
return Err(format!("duplicate attribute {name}"));
}
out.push((name.to_owned(), value));
rest = after[close + 2..].trim_start();
}
Ok(out)
}
fn check_value(key: &str, value: &str) -> Result<(), String> {
let lower = value.to_ascii_lowercase();
if matches!(key, "href" | "xlink:href") {
return if value.starts_with('#') { Ok(()) } else { Err("only local references (#id) are allowed".into()) };
}
if key == "xmlns" || key == "xmlns:xlink" {
return Ok(());
}
if lower.contains("javascript:")
|| lower.contains("expression(")
|| lower.contains("@import")
|| value.contains('\\')
{
return Err("unsafe value".into());
}
let mut rest = lower.as_str();
while let Some(i) = rest.find("url(") {
let inner = rest[i + 4..].trim_start().trim_start_matches(['"', '\'']);
if !inner.starts_with('#') {
return Err("url() may only reference a local fragment".into());
}
rest = &rest[i + 4..];
}
Ok(())
}
fn decode_entities(s: &str) -> Result<String, String> {
if !s.contains('&') {
return Ok(s.to_owned());
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
let semi = rest[i..].find(';').ok_or("unterminated entity")?;
let ent = &rest[i + 1..i + semi];
let c = match ent {
"amp" => '&',
"lt" => '<',
"gt" => '>',
"quot" => '"',
"apos" => '\'',
_ => {
let code = if let Some(hex) = ent.strip_prefix("#x").or_else(|| ent.strip_prefix("#X")) {
u32::from_str_radix(hex, 16).ok()
} else if let Some(dec) = ent.strip_prefix('#') {
dec.parse().ok()
} else {
None
};
code.and_then(char::from_u32).ok_or_else(|| format!("entity &{ent}; is not allowed"))?
}
};
out.push(c);
rest = &rest[i + semi + 1..];
}
out.push_str(rest);
Ok(out)
}
fn text(t: &str) -> Result<String, String> {
Ok(escape(&decode_entities(t)?))
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
fn clip(s: &str) -> &str {
let mut end = s.len().min(24);
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_ordinary_icons() {
let src = r##"<?xml version="1.0"?>
<!-- Material icon -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="x" viewBox="0 0 24 24" inkscape:version="1">
<title>rust</title>
<defs><linearGradient id="g"><stop offset="0" stop-color="#fff"/></linearGradient></defs>
<path fill="url(#g)" d="M1 2h3 &amp; 4" style="opacity:.5"/>
<use href="#g"/>
</svg>"##;
let out = sanitize_svg(src).unwrap();
assert!(out.starts_with("<svg xmlns="), "{out}");
assert!(!out.contains("title") && !out.contains("inkscape") && !out.contains("<!--"));
assert!(out.contains(r#"fill="url(#g)""#));
assert!(out.contains(r#"d="M1 2h3 &amp; 4""#));
assert!(out.ends_with("</svg>"));
}
#[test]
fn rejects_active_content() {
let bad = [
r##"<svg><script>alert(1)</script></svg>"##,
r##"<svg onload="alert(1)"></svg>"##,
r##"<svg><foreignObject/></svg>"##,
r##"<svg><use href="https://evil/x.svg#a"/></svg>"##,
r##"<svg><use xlink:href="data:image/svg+xml,..."/></svg>"##,
r##"<svg><rect style="fill:url(https://evil)"/></svg>"##,
r##"<svg><rect fill="url( 'http://x' )"/></svg>"##,
r##"<!DOCTYPE svg [<!ENTITY x "y">]><svg/>"##,
r##"<svg><![CDATA[x]]></svg>"##,
r##"<svg><a href="#x"/></svg>"##,
r##"<svg><rect width="&xx;"/></svg>"##,
r##"<svg><rect width=10/></svg>"##,
r##"<svg><g></svg>"##,
r##"<svg/><svg/>"##,
r##"<g/>"##,
r##"hello"##,
r##"<svg><rect style="behavior:url(#x);x:expression(1)"/></svg>"##,
];
for src in bad {
assert!(sanitize_svg(src).is_err(), "accepted: {src}");
}
}
#[test]
fn theme_files_validate_and_produce_assets() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::create_dir_all(dir.join("icons")).unwrap();
std::fs::write(dir.join("icons/rust.svg"), r#"<svg viewBox="0 0 16 16"><path d="M0 0h16v16z"/></svg>"#)
.unwrap();
std::fs::write(dir.join("icons/file.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
.unwrap();
std::fs::write(
dir.join("theme.json"),
r#"{"iconDefinitions":{"rust":"icons/rust.svg","file":"icons/file.svg"},"file":"file",
"fileExtensions":{".RS":"rust"},"fileNames":{"Cargo.toml":"rust",".gitignore":"file"},
"folderNames":{".GitHub":"file","src":"rust"}}"#,
)
.unwrap();
let manifest = ide_extension::Manifest::parse(
"id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"",
)
.unwrap();
let pkg = Package::new(manifest, dir.to_path_buf());
let decl = |path: &str| {
let mut t = toml::Table::new();
t.insert("id".into(), "m".into());
t.insert("label".into(), "M".into());
t.insert("path".into(), path.into());
toml::Value::Table(t)
};
let d = icon_theme(&pkg, decl("theme.json")).unwrap();
assert_eq!(d.json["icons"], 2);
assert_eq!(d.files.len(), 3);
let map: serde_json::Value =
serde_json::from_str(&d.assets.iter().find(|(k, _)| *k == map_asset("m")).unwrap().1).unwrap();
assert_eq!(map["fileExtensions"]["rs"], "rust");
assert_eq!(map["fileNames"]["cargo.toml"], "rust");
// Names keep their leading dot (only extensions drop it).
assert_eq!(map["fileNames"][".gitignore"], "file");
assert_eq!(map["folderNames"][".github"], "file");
assert_eq!(map["folderNames"]["src"], "rust");
assert!(map["folderNames"].get("github").is_none());
assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust", svg_prefix("m"))));
std::fs::write(dir.join("bad.json"), r#"{"iconDefinitions":{},"fileExtensions":{"rs":"nope"}}"#).unwrap();
assert!(icon_theme(&pkg, decl("bad.json")).unwrap_err().contains("unknown icon"));
std::fs::write(dir.join("icons/evil.svg"), r#"<svg><script/></svg>"#).unwrap();
std::fs::write(dir.join("evil.json"), r#"{"iconDefinitions":{"e":"icons/evil.svg"}}"#).unwrap();
assert!(icon_theme(&pkg, decl("evil.json")).unwrap_err().contains("evil.svg"));
assert!(icon_theme(&pkg, decl("../x.json")).is_err());
}
}
+288
Переглянути файл
@@ -0,0 +1,288 @@
//! Copying a package into the plugins directory without ever leaving the
//! plugin missing (docs/plugin_plan.md 4.7). 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
//! pointer; a swap interrupted by a crash is undone on the next start
//! ([`recover`]). Older installs used `<plugins>/<id>/` directly; they are
//! moved into the versioned layout on their next update.
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Top-level files copied with a package besides those its declarations reference.
pub const LICENSE_FILES: &[&str] =
&["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING", "README.md"];
/// Written into an installed package: the package hash the grant ledger compares.
pub const HASH_FILE: &str = ".package-hash";
const STAGING: &str = ".staging-";
const OLD: &str = ".old-";
/// `<root>/active`: the version in use.
pub const ACTIVE_FILE: &str = "active";
/// `<root>/previous`: the version an update replaced (for rollback).
pub const PREVIOUS_FILE: &str = "previous";
fn nonce() -> String {
let n = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
format!("{n:x}-{}", std::process::id())
}
/// Copies `files` (plus manifest and licence files) from `src` into `target`
/// atomically, recording `hash`.
pub fn install_package(src: &Path, target: &Path, files: &[PathBuf], hash: &str) -> io::Result<()> {
let parent = target.parent().ok_or_else(|| io::Error::other("plugin target has no parent"))?;
let name = target.file_name().and_then(|n| n.to_str()).ok_or_else(|| io::Error::other("bad target"))?;
std::fs::create_dir_all(parent)?;
let staging = parent.join(format!("{STAGING}{name}-{}", nonce()));
let result = (|| {
std::fs::create_dir_all(&staging)?;
std::fs::copy(src.join(ide_extension::MANIFEST_FILE), staging.join(ide_extension::MANIFEST_FILE))?;
for file in files {
let to = staging.join(file);
if let Some(dir) = to.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::copy(src.join(file), to)?;
}
for lic in LICENSE_FILES {
if src.join(lic).is_file() {
std::fs::copy(src.join(lic), staging.join(lic))?;
}
}
std::fs::write(staging.join(HASH_FILE), hash)?;
swap(&staging, target, |from, to| std::fs::rename(from, to))
})();
if result.is_err() {
let _ = std::fs::remove_dir_all(&staging);
}
result
}
/// A version as a directory name (semver: `[0-9A-Za-z.+-]`).
fn version_dir(version: &str) -> io::Result<&str> {
let ok = !version.is_empty()
&& version.len() <= 64
&& !version.starts_with('.')
&& version.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-'));
if ok { Ok(version) } else { Err(io::Error::other(format!("version {version:?} cannot name a directory"))) }
}
fn read_pointer(root: &Path, name: &str) -> Option<String> {
let v = std::fs::read_to_string(root.join(name)).ok()?;
let v = v.trim();
(!v.is_empty() && version_dir(v).is_ok()).then(|| v.to_owned())
}
/// Replaces the pointer file `name` atomically.
fn write_pointer(root: &Path, name: &str, version: &str) -> io::Result<()> {
let tmp = root.join(format!(".{name}.tmp"));
std::fs::write(&tmp, version)?;
std::fs::rename(&tmp, root.join(name))
}
/// The directory of a plugin's active version under its `root`
/// (`<plugins>/<id>`): `<root>/<active>`, or `root` itself in the flat layout.
pub fn active_dir(root: &Path) -> Option<PathBuf> {
if let Some(v) = read_pointer(root, ACTIVE_FILE) {
let dir = root.join(v);
return dir.join(ide_extension::MANIFEST_FILE).is_file().then_some(dir);
}
root.join(ide_extension::MANIFEST_FILE).is_file().then(|| root.to_path_buf())
}
/// The version an update replaced, if it is still there.
pub fn previous_version(root: &Path) -> Option<String> {
read_pointer(root, PREVIOUS_FILE).filter(|v| root.join(v).join(ide_extension::MANIFEST_FILE).is_file())
}
/// Moves a flat install (`<root>/plugin.toml`) to `<root>/<version>/`.
fn migrate_flat(root: &Path, version_of: impl Fn(&Path) -> Option<String>) -> io::Result<()> {
if read_pointer(root, ACTIVE_FILE).is_some() || !root.join(ide_extension::MANIFEST_FILE).is_file() {
return Ok(());
}
let version = version_of(root).unwrap_or_else(|| "0.0.0".into());
let version = version_dir(&version).map(str::to_owned).unwrap_or_else(|_| "0.0.0".into());
let parent = root.parent().unwrap_or(Path::new("."));
let name = root.file_name().and_then(|n| n.to_str()).ok_or_else(|| io::Error::other("bad plugin directory"))?;
// `.old-<name>-…` is restored by `recover` if this is interrupted.
let old = parent.join(format!("{OLD}{name}-{}", nonce()));
std::fs::rename(root, &old)?;
std::fs::create_dir_all(root)?;
std::fs::rename(&old, root.join(&version))?;
write_pointer(root, ACTIVE_FILE, &version)
}
/// Installs `files` of `src` as `<root>/<version>` and makes it the active
/// version. Returns the version it replaced (now `previous`).
pub fn install_version(
src: &Path,
root: &Path,
version: &str,
files: &[PathBuf],
hash: &str,
version_of: impl Fn(&Path) -> Option<String>,
) -> io::Result<Option<String>> {
let version = version_dir(version)?;
std::fs::create_dir_all(root)?;
migrate_flat(root, version_of)?;
install_package(src, &root.join(version), files, hash)?;
let previous = read_pointer(root, ACTIVE_FILE).filter(|v| v != version);
if let Some(p) = &previous {
write_pointer(root, PREVIOUS_FILE, p)?;
}
write_pointer(root, ACTIVE_FILE, version)?;
Ok(previous)
}
/// Makes `version` active again (rollback). The replaced version stays until [`prune`].
pub fn activate_version(root: &Path, version: &str) -> io::Result<()> {
let version = version_dir(version)?;
if !root.join(version).join(ide_extension::MANIFEST_FILE).is_file() {
return Err(io::Error::other(format!("version {version} is not installed")));
}
let _ = std::fs::remove_file(root.join(PREVIOUS_FILE));
write_pointer(root, ACTIVE_FILE, version)
}
/// Removes versions other than the active and the previous one.
pub fn prune(root: &Path) {
let keep: Vec<String> = [ACTIVE_FILE, PREVIOUS_FILE].iter().filter_map(|f| read_pointer(root, f)).collect();
if keep.is_empty() {
return;
}
let Ok(entries) = std::fs::read_dir(root) else { return };
for e in entries.flatten() {
let path = e.path();
let name = e.file_name().to_string_lossy().into_owned();
if path.is_dir() && !keep.contains(&name) {
let _ = std::fs::remove_dir_all(&path);
}
}
}
/// Moves `staging` to `target`, keeping the old `target` until the new one is in place.
fn swap(staging: &Path, target: &Path, rename: impl Fn(&Path, &Path) -> io::Result<()>) -> io::Result<()> {
if !target.exists() {
return rename(staging, target);
}
let parent = target.parent().unwrap_or(Path::new("."));
let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("plugin");
let old = parent.join(format!("{OLD}{name}-{}", nonce()));
rename(target, &old)?;
if let Err(e) = rename(staging, target) {
// Put the previous version back.
rename(&old, target)?;
return Err(e);
}
if let Err(e) = std::fs::remove_dir_all(&old) {
tracing::warn!("removing the previous version {}: {e}", old.display());
}
Ok(())
}
/// Undoes interrupted installs in `dir`: restores `.old-<name>-*` when
/// `<name>` is missing, and removes leftover staging and old directories.
pub fn recover(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
// Inside a versioned plugin: staging and old versions of a version swap.
if path.is_dir() && read_pointer(&path, ACTIVE_FILE).is_some() {
recover(&path);
continue;
}
let Some(file) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue };
if file.starts_with(STAGING) {
let _ = std::fs::remove_dir_all(&path);
} else if let Some(rest) = file.strip_prefix(OLD) {
// `<name>-<nonce>-<pid>`: the name is everything before the last two parts.
let parts: Vec<&str> = rest.rsplitn(3, '-').collect();
let name = parts.get(2).copied().unwrap_or_default();
let target = dir.join(name);
// A layout migration interrupted after creating the new root: it is still empty.
if !name.is_empty() && target.is_dir() && active_dir(&target).is_none() {
let _ = std::fs::remove_dir_all(&target);
}
if !name.is_empty() && !target.exists() {
tracing::warn!("restoring {} after an interrupted install", target.display());
let _ = std::fs::rename(&path, &target);
} else {
let _ = std::fs::remove_dir_all(&path);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_swap_keeps_the_old_version() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("p");
let staging = tmp.path().join(".staging-p-1");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("v"), "1").unwrap();
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("v"), "2").unwrap();
let calls = std::cell::Cell::new(0);
let err = swap(&staging, &target, |a, b| {
calls.set(calls.get() + 1);
if calls.get() == 2 { Err(io::Error::other("disk full")) } else { std::fs::rename(a, b) }
});
assert!(err.is_err());
assert_eq!(std::fs::read_to_string(target.join("v")).unwrap(), "1");
swap(&staging, &target, |a, b| std::fs::rename(a, b)).unwrap();
assert_eq!(std::fs::read_to_string(target.join("v")).unwrap(), "2");
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1, "old version removed");
}
#[test]
fn versions_are_installed_side_by_side_and_rolled_back() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let root = tmp.path().join("plugins/acme.tools");
let version_of = |_: &Path| Some("0.9.0".to_owned());
// A flat install from before versioning.
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join(ide_extension::MANIFEST_FILE), "v0.9").unwrap();
assert_eq!(active_dir(&root), Some(root.clone()));
std::fs::write(src.join(ide_extension::MANIFEST_FILE), "v1").unwrap();
assert_eq!(install_version(&src, &root, "1.0.0", &[], "h1", version_of).unwrap().as_deref(), Some("0.9.0"));
assert_eq!(active_dir(&root), Some(root.join("1.0.0")));
std::fs::write(src.join(ide_extension::MANIFEST_FILE), "v2").unwrap();
assert_eq!(install_version(&src, &root, "2.0.0", &[], "h2", version_of).unwrap().as_deref(), Some("1.0.0"));
assert_eq!(previous_version(&root).as_deref(), Some("1.0.0"));
prune(&root);
assert!(!root.join("0.9.0").exists(), "older than previous: pruned");
activate_version(&root, "1.0.0").unwrap();
assert_eq!(
std::fs::read_to_string(active_dir(&root).unwrap().join(ide_extension::MANIFEST_FILE)).unwrap(),
"v1"
);
assert_eq!(previous_version(&root), None);
assert!(install_version(&src, &root, "../x", &[], "h", version_of).is_err());
}
#[test]
fn recover_restores_an_interrupted_swap() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".old-acme.tools-1a2b-77")).unwrap();
std::fs::write(tmp.path().join(".old-acme.tools-1a2b-77/v"), "1").unwrap();
std::fs::create_dir_all(tmp.path().join(".staging-acme.tools-1a2c-77")).unwrap();
std::fs::create_dir_all(tmp.path().join("x")).unwrap();
std::fs::create_dir_all(tmp.path().join(".old-x-1-2")).unwrap();
recover(tmp.path());
assert_eq!(std::fs::read_to_string(tmp.path().join("acme.tools/v")).unwrap(), "1");
let mut names: Vec<_> =
std::fs::read_dir(tmp.path()).unwrap().flatten().map(|e| e.file_name().into_string().unwrap()).collect();
names.sort();
assert_eq!(names, ["acme.tools", "x"]);
}
}
+293
Переглянути файл
@@ -0,0 +1,293 @@
//! The marketplace (docs/plugin_plan.md 4.7): 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
//! revokes — at start-up and on every refresh (the desktop refreshes daily).
use super::{PluginHost, PluginSource, PluginStatus};
use ide_core::{IdeError, IdeResult};
use ide_extension::Manifest;
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// The last index read, in the plugins directory.
pub const INDEX_FILE: &str = "market-index.json";
const DOWNLOADS: &str = ".downloads";
const MAX_INDEX: usize = 16 << 20;
const MAX_PACKAGE: usize = 256 << 20;
/// A marketplace entry with what is installed.
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MarketPlugin {
#[serde(flatten)]
pub entry: IndexEntry,
/// Installed version, if any.
pub installed: Option<String>,
}
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
if url.starts_with("https://") || url.starts_with("http://") {
let client = ide_http::client::shared().map_err(|e| e.message)?;
let url = url.to_owned();
return super::api::net_runtime().block_on(async move {
let response = client.get(&url).send().await.map_err(|e| format!("{url}: {e}"))?;
if !response.status().is_success() {
return Err(format!("{url}: HTTP {}", response.status()));
}
let bytes = response.bytes().await.map_err(|e| format!("{url}: {e}"))?;
if bytes.len() > max {
return Err(format!("{url}: larger than {} MiB", max >> 20));
}
Ok(bytes.to_vec())
});
}
let path = url.strip_prefix("file://").unwrap_or(url);
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
if bytes.len() > max {
return Err(format!("{path}: larger than {} MiB", max >> 20));
}
Ok(bytes)
}
/// `href` relative to the index at `base`.
pub fn resolve(base: &str, href: &str) -> String {
if href.contains("://") || href.starts_with('/') {
return href.to_owned();
}
match base.rfind('/') {
Some(i) => format!("{}/{href}", &base[..i]),
None => href.to_owned(),
}
}
impl PluginHost {
pub(super) fn load_market_index(dir: &Path) -> Option<Index> {
let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?;
Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()
}
/// The marketplace index URL (none: no marketplace).
pub fn market_url(&self) -> Option<String> {
self.state.lock().market_url.clone()
}
pub fn set_market_url(&self, url: Option<String>) {
self.state.lock().market_url = url.filter(|u| !u.trim().is_empty());
self.save_state();
}
/// Reads the index again, keeps it, and applies its revocations.
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?;
let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?;
let index = Index::parse(&text).map_err(IdeError::invalid)?;
let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), &text);
*self.market.write() = Some(index);
// Trust may have changed: who signed what, what is revoked.
let installed: Vec<_> =
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
for plugin in installed {
let publisher = self.verified_publisher(plugin.dir());
plugin.meta.lock().publisher = publisher;
}
self.apply_revocations();
Ok(self.market_plugins())
}
/// The plugins of the last index read.
pub fn market_plugins(&self) -> Vec<MarketPlugin> {
let Some(index) = self.market.read().clone() else { return Vec::new() };
let plugins = self.plugins.read();
index
.plugins
.iter()
.filter(|e| index.revocation(&e.id, &e.version).is_none())
.cloned()
.map(|entry| {
let installed = plugins
.get(&entry.id)
.filter(|p| p.source == PluginSource::Installed)
.map(|p| p.manifest().version.clone());
MarketPlugin { entry, installed }
})
.collect()
}
/// Downloads a marketplace plugin, checks it against the index (digest and
/// signature), and unpacks it for the install page. Returns the folder to
/// install from.
pub fn download_market_plugin(&self, id: &str) -> IdeResult<PathBuf> {
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
let index = self.market.read().clone().ok_or_else(|| IdeError::unavailable("refresh the marketplace first"))?;
let entry = index
.plugins
.iter()
.find(|e| e.id == id)
.cloned()
.ok_or_else(|| IdeError::not_found(format!("{id} is not in the marketplace")))?;
let bytes = read_url(&resolve(&url, &entry.download), MAX_PACKAGE).map_err(IdeError::unavailable)?;
let downloads = self.plugins_dir.join(DOWNLOADS);
std::fs::create_dir_all(&downloads)?;
let archive = downloads.join(format!("{}-{}.{}", entry.id, entry.version, package::PACKAGE_EXTENSION));
std::fs::write(&archive, bytes)?;
let dir = self.unpack_package(&archive)?;
let _ = std::fs::remove_file(&archive);
let manifest = Manifest::load(&dir).map_err(IdeError::invalid)?;
let files = self.package_files_in(&dir)?;
let digest = package::package_digest(&dir, &files)?;
if (manifest.id.as_str(), manifest.version.as_str(), digest.as_str())
!= (&entry.id, &entry.version, &entry.digest)
{
let _ = std::fs::remove_dir_all(&dir);
return Err(IdeError::invalid("the download does not match the marketplace's entry"));
}
if self.verified_publisher(&dir).is_none() {
let _ = std::fs::remove_dir_all(&dir);
return Err(IdeError::invalid("the marketplace package is not signed by a trusted publisher"));
}
Ok(dir)
}
/// Unpacks a package file (`.oiplugin`) next to the downloads; returns the folder.
pub fn unpack_package(&self, archive: &Path) -> IdeResult<PathBuf> {
let stem = archive.file_stem().and_then(|s| s.to_str()).unwrap_or("package");
let dir = self.plugins_dir.join(DOWNLOADS).join(format!("{stem}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let unpacked = package::unpack(archive, &dir).map_err(IdeError::invalid)?;
// Kept with the files: install checks it and keeps it with the installed copy.
if let Some(sig) = unpacked.signature {
let json = serde_json::to_string_pretty(&sig).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(dir.join(SIGNATURE_FILE), json)?;
}
Ok(dir)
}
/// Files of the package in `dir` (what its declarations reference, and
/// the component): what a package file holds besides the manifest.
pub fn package_files_of(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
self.package_files_in(dir)
}
pub(super) fn package_files_in(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
let manifest = Manifest::load(dir).map_err(IdeError::invalid)?;
let pkg = ide_extension::Package::new(manifest, dir.to_path_buf());
let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?;
Ok(set.files.into_iter().chain(pkg.manifest.wasm.clone()).collect())
}
/// The publisher whose valid, trusted signature the package in `dir` carries.
pub(super) fn verified_publisher(&self, dir: &Path) -> Option<String> {
let sig = read_signature(dir)?;
let index = self.market.read().clone()?;
let manifest = Manifest::load(dir).ok()?;
let files = self.package_files_in(dir).ok()?;
let digest = package::package_digest(dir, &files).ok()?;
index
.verify(&sig, &manifest.id, &manifest.version, &digest)
.map_err(|e| tracing::warn!("{}: {e}", dir.display()))
.ok()
}
/// A signature that is present but doesn't verify (tampered, foreign prefix,
/// revoked or unknown key): installing is refused.
pub(super) fn signature_problem(&self, dir: &Path) -> Option<String> {
let sig = read_signature(dir)?;
let manifest = Manifest::load(dir).ok()?;
let files = self.package_files_in(dir).ok()?;
let digest = package::package_digest(dir, &files).ok()?;
if let Err(e) = sig.check(&manifest.id, &manifest.version, &digest) {
return Some(e);
}
let index = self.market.read().clone()?;
index.verify(&sig, &manifest.id, &manifest.version, &digest).err()
}
/// Why an installed plugin must not run (index revocations, revoked signing key).
pub(super) fn revoked_reason(&self, id: &str, version: &str, dir: &Path) -> Option<String> {
let market = self.market.read();
let index = market.as_ref()?;
if let Some(r) = index.revocation(id, version) {
return Some(r.reason.clone());
}
let sig = read_signature(dir)?;
index.key_revoked(&sig.key).then(|| "its publisher's signing key was revoked".to_owned())
}
/// Disables installed plugins the index revokes (telling the user once),
/// and starts again those it no longer revokes.
pub(super) fn apply_revocations(&self) {
let installed: Vec<_> =
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
let mut changed = false;
for plugin in installed {
let reason = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir());
let current = plugin.meta.lock().error.clone().filter(|e| e.starts_with("revoked: "));
match (reason, current) {
(Some(r), Some(c)) if c == format!("revoked: {r}") => {}
(Some(reason), was) => {
self.stop(&plugin);
{
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Failed;
meta.error = Some(format!("revoked: {reason}"));
}
if was.is_none() {
self.emit(
None,
super::PluginEvent::Notification {
plugin_id: plugin.id().to_owned(),
level: ide_plugin::NotificationLevel::Warning,
title: format!("{} was disabled", plugin.manifest().name),
body: format!("The marketplace revoked this plugin: {reason}"),
},
);
}
changed = true;
}
(None, Some(_)) => {
{
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Inactive;
meta.error = None;
}
if !self.state.lock().disabled.contains(plugin.id()) {
self.start(&plugin);
}
changed = true;
}
(None, None) => {}
}
}
if changed {
self.changed();
}
}
}
pub(super) fn read_signature(dir: &Path) -> Option<PackageSignature> {
let text = std::fs::read_to_string(dir.join(SIGNATURE_FILE)).ok()?;
serde_json::from_str(&text).ok()
}
/// Keeps the signature of a package with its installed copy.
pub(super) fn copy_signature(src: &Path, installed: &Path) {
if src.join(SIGNATURE_FILE).is_file() {
let _ = std::fs::copy(src.join(SIGNATURE_FILE), installed.join(SIGNATURE_FILE));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn downloads_resolve_against_the_index() {
assert_eq!(resolve("https://x.dev/market/index.json", "p/a.oiplugin"), "https://x.dev/market/p/a.oiplugin");
assert_eq!(resolve("/srv/market/index.json", "a.oiplugin"), "/srv/market/a.oiplugin");
assert_eq!(resolve("https://x.dev/index.json", "https://cdn.dev/a"), "https://cdn.dev/a");
}
}
File diff suppressed because it is too large. Load diff
+71
Переглянути файл
@@ -0,0 +1,71 @@
//! First-party extension points registered by the host, plus
//! [`register_builtin_points`], the one place that lists them. Feature crates
//! own their points (`ide_language::plugin_point`); adding a contribution type
//! means adding a point here or in its crate, and a `defineUiPoint` projection
//! on the client for `Ui` points — nothing else.
use ide_extension::{ExtensionRegistry, Package, UiDecl, UiPoint, UiStore, decode, validate_item_id, validate_when};
use ide_language::plugin_point::{LanguagesPoint, PluginLanguages};
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
/// Everywhere and the keymap editor; shown before the plugin's code runs.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CommandDecl {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Enablement, evaluated by the client against context keys (`editorLang == rust`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<String>,
}
fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let c: CommandDecl = decode(COMMANDS, decl)?;
validate_item_id(&c.id, "command id")?;
if c.title.trim().is_empty() {
return Err(format!("command {:?} has an empty title", c.id));
}
if c.title.len() > MAX_TITLE || c.category.as_ref().is_some_and(|x| x.len() > MAX_TITLE) {
return Err(format!("command {:?}: title or category longer than {MAX_TITLE} bytes", c.id));
}
if let Some(when) = &c.when {
validate_when(when).map_err(|e| format!("command {:?}: {e}", c.id))?;
}
let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c };
let json = serde_json::to_value(&c).map_err(|e| e.to_string())?;
Ok(UiDecl::new(c.id.clone(), c.title, json))
}
/// Registers every first-party extension point.
pub fn register_builtin_points(
registry: &ExtensionRegistry,
ui: &Arc<UiStore>,
languages: &Arc<PluginLanguages>,
run_detectors: &Arc<super::run_detectors::RunDetectors>,
providers: &Arc<super::providers::Providers>,
) -> Result<(), String> {
registry.register(Arc::new(UiPoint::new(COMMANDS, ui.clone(), command)))?;
registry.register(Arc::new(super::theme::ThemesPoint::new(ui.clone())))?;
registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?;
registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?;
registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?;
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
use super::ui_points as p;
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
registry.register(Arc::new(UiPoint::new(p::STATUS_BAR, ui.clone(), p::status_item)))?;
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
Ok(())
}
+332
Переглянути файл
@@ -0,0 +1,332 @@
//! Editor providers of plugins (docs/plugin_plan.md P6): 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.
//!
//! ```toml
//! [[contributes.providers]]
//! kinds = ["definition", "line-markers"]
//! languages = ["java", "xml"] # editor language ids; "*" for any
//! ```
//!
//! And `[[contributes.secrets]]`: secrets the user can store for the plugin
//! (shown in Settings › Plugins), used only through `net` request headers.
//!
//! ```toml
//! [[contributes.secrets]]
//! name = "api-key"
//! label = "API key"
//! description = "Create one at https://example.com/keys"
//! ```
use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
pub const POINT_ID: &str = "providers";
pub const SECRETS_POINT: &str = "secrets";
/// What a plugin can provide in the editor.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "kebab-case")]
pub enum ProviderKind {
Completion,
InlineCompletion,
CodeActions,
Definition,
LineMarkers,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
kinds: Vec<ProviderKind>,
languages: Vec<String>,
}
/// (sequence, plugin, kinds, languages) of one declaration.
type Declared = (u64, String, Vec<ProviderKind>, Vec<String>);
/// Provider declarations of enabled plugins.
#[derive(Default)]
pub struct Providers {
items: RwLock<Vec<Declared>>,
seq: AtomicU64,
}
impl Providers {
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, ..)| *s != seq);
}
})
}
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for (_, plugin, kinds, languages) in self.items.read().iter() {
if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) {
out.push(plugin.clone());
}
}
out
}
/// Whether any plugin provides `kind` (for any language).
pub fn any(&self, kind: ProviderKind) -> bool {
self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind))
}
}
pub struct ProvidersPoint {
store: Arc<Providers>,
}
impl ProvidersPoint {
pub fn new(store: Arc<Providers>) -> Self {
Self { store }
}
}
impl ExtensionPoint for ProvidersPoint {
fn id(&self) -> &'static str {
POINT_ID
}
fn placement(&self) -> Placement {
Placement::Workspace
}
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let d: Decl = decode(POINT_ID, decl)?;
if pkg.manifest.wasm.is_none() {
return Err("contributes.providers: providers need code (`wasm`)".into());
}
if d.kinds.is_empty() || d.languages.is_empty() || d.languages.len() > 64 {
return Err("contributes.providers: give `kinds` and 1–64 `languages`".into());
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
|| l.len() > 64
|| !l.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c)))
{
return Err(format!("contributes.providers: invalid language id {l:?}"));
}
}
let label = format!(
"{} for {}",
d.kinds
.iter()
.map(|k| serde_json::to_value(k).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default())
.collect::<Vec<_>>()
.join(", "),
d.languages.join(", ")
);
let (store, plugin) = (self.store.clone(), pkg.id().to_owned());
Ok(Prepared::new(label, move || Ok(store.add(plugin, d))))
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct SecretDecl {
name: String,
label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
/// `[[contributes.secrets]]` (a UI point: Settings › Plugins lists them).
pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: SecretDecl = decode(SECRETS_POINT, decl)?;
validate_item_id(&d.name, "secret name")?;
if d.label.trim().is_empty() || d.label.len() > 128 || d.description.as_ref().is_some_and(|x| x.len() > 1024) {
return Err(format!("secret {:?}: `label` 1–128 bytes, `description` up to 1 KiB", d.name));
}
let json = serde_json::to_value(&d).map_err(|e| e.to_string())?;
Ok(UiDecl::new(d.name.clone(), d.label, json))
}
// ---- dispatch ---------------------------------------------------------------
use super::{LoadedPlugin, PluginHost, PluginStatus};
use ide_core::CancellationToken;
use ide_plugin::{
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument,
Runtime,
};
use std::sync::Weak;
use std::time::{Duration, Instant};
/// How long a superseded inline completion may take to return after its
/// request was cancelled before it is interrupted.
const CANCEL_GRACE: Duration = Duration::from_millis(250);
static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
/// Makes `host`'s plugin providers part of completion, code actions, go to
/// definition and inline completion (`None` removes them).
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
}
/// The plugin host whose providers take part, when any declares `kind`.
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?;
host.providers.any(kind).then_some(host)
}
impl PluginHost {
/// Usable plugins declaring `kind` for `language`.
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
self.providers
.plugins_for(kind, language)
.into_iter()
.filter_map(|id| self.get(&id).ok())
.filter(|p| {
let m = p.meta.lock();
matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty()
})
.collect()
}
/// One edit-path call (soft / hard deadline); `None` when it failed or was late.
fn edit_call<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)
}
pub(super) fn finish_provider_call<R>(
&self,
plugin: &Arc<LoadedPlugin>,
actor: &Arc<ide_plugin::Actor>,
started: Instant,
result: Result<R, ActorError>,
) -> Option<R> {
let elapsed = started.elapsed();
match result {
Ok(r) => {
self.record_call(plugin, elapsed, true, true);
Some(r)
}
Err(ActorError::SoftTimeout) => {
plugin.meta.lock().stats.slow_calls += 1;
None
}
Err(ActorError::Cancelled) => None,
Err(ActorError::Call(CallError::Guest(e))) => {
self.record_call(plugin, elapsed, false, true);
tracing::debug!(plugin = %plugin.id(), "provider: {e}");
None
}
Err(ActorError::Call(CallError::Trap(_)) | ActorError::Stopped) => {
self.record_call(plugin, elapsed, false, false);
self.reap(plugin, actor, None);
None
}
}
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
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
}
pub fn provide_code_actions(
&self,
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
}
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
}
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
}
/// Ghost text from the first plugin that has some. Not bound by the
/// edit-path soft deadline (it may need the network) but by `cancel`:
/// the editor cancels a request as soon as the user types on.
pub fn provide_inline_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
cancel: &CancellationToken,
) -> Option<(String, InlineSuggestion)> {
for p in self.provider_plugins(ProviderKind::InlineCompletion, &doc.language) {
if cancel.is_cancelled() {
return None;
}
let Ok((actor, activated)) = self.ensure_active(&p, None) else { continue };
if activated {
self.changed();
}
let (d, timeout) = (doc.clone(), self.limits.timeout);
let started = Instant::now();
let result =
actor.call_cancellable(cancel, CANCEL_GRACE, move |rt| rt.provide_inline_completion(&d, at, timeout));
if let Some(Some(s)) = self.finish_provider_call(&p, &actor, started, result) {
return Some((p.id().to_owned(), s));
}
}
None
}
}
+223
Переглянути файл
@@ -0,0 +1,223 @@
//! Plugin languages of a remote project (docs/plugin_plan.md 3, placement
//! rules): `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
//! the file (path + hash, kept in `plugin-pins.json`) the first time and
//! starts only that file afterwards; a program missing from a sync is revoked.
//! Revoking and granting again on the desktop re-pins a replaced program.
use ide_extension::Capability;
use ide_extension::ledger::{self, BinaryGrant};
use ide_language::plugin_point::{PluginLanguages, validate_definition};
use ide_language::{LanguageConfig, ProgramPin};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
pub const PINS_FILE: &str = "plugin-pins.json";
/// The server command the desktop calls.
pub const SYNC_COMMAND: &str = "plugin_sync_languages";
/// One plugin language as the desktop sends it.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteLanguage {
pub plugin: String,
pub language: LanguageConfig,
/// Program names of the plugin's granted `process:` capabilities.
#[serde(default)]
pub granted: Vec<String>,
}
/// What the server made of a sync.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SyncReport {
pub languages: usize,
/// `plugin: program` → the file it starts.
pub pinned: BTreeMap<String, PathBuf>,
/// Granted programs not found on this machine's `PATH`.
pub unresolved: Vec<String>,
/// Definitions that failed validation (not applied).
pub rejected: Vec<String>,
}
#[derive(Default, Serialize, Deserialize)]
struct PinsFile {
/// `plugin\nprocess:program` → the approved file.
#[serde(default)]
pins: BTreeMap<String, BinaryGrant>,
}
type PathVar = Arc<dyn Fn() -> Option<std::ffi::OsString> + Send + Sync>;
/// The server's side: an overlay for its language registry, filled by syncs.
pub struct RemotePluginLanguages {
store: Arc<PluginLanguages>,
pins: Arc<Mutex<BTreeMap<String, BinaryGrant>>>,
file: Option<PathBuf>,
applied: Mutex<Vec<ide_extension::Disposable>>,
path_var: PathVar,
}
fn key(plugin: &str, cap: &Capability) -> String {
format!("{plugin}\n{cap}")
}
impl RemotePluginLanguages {
/// Pins persist in `data_dir` (none: in memory, for tests).
pub fn new(data_dir: Option<&std::path::Path>) -> Arc<Self> {
Self::with_path(data_dir, Arc::new(|| std::env::var_os("PATH")))
}
pub fn with_path(data_dir: Option<&std::path::Path>, path_var: PathVar) -> Arc<Self> {
let file = data_dir.map(|d| d.join(PINS_FILE));
let saved: PinsFile = file
.as_ref()
.and_then(|f| std::fs::read_to_string(f).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let pins = Arc::new(Mutex::new(saved.pins));
let store = PluginLanguages::new();
let policy_pins = pins.clone();
store.set_process_policy(Arc::new(move |plugin, cap| {
policy_pins.lock().get(&key(plugin, cap)).map(|b| ProgramPin { path: b.path.clone(), hash: b.hash.clone() })
}));
Arc::new(Self { store, pins, file, applied: Mutex::default(), path_var })
}
/// For [`ide_language::LanguageRegistry::attach_plugins`].
pub fn store(&self) -> &Arc<PluginLanguages> {
&self.store
}
/// Replaces every plugin language with `languages`.
pub fn sync(&self, languages: Vec<RemoteLanguage>) -> SyncReport {
let mut report = SyncReport::default();
let mut wanted: BTreeMap<String, (String, Capability)> = BTreeMap::new();
let mut accepted = Vec::new();
for l in languages {
if let Err(e) = validate_definition(&l.language) {
report.rejected.push(format!("{} ({}): {e}", l.language.id, l.plugin));
continue;
}
for program in &l.granted {
let cap = Capability::Process(program.clone());
// Only programs the definition starts: a grant is not a licence for anything else.
if ide_language::plugin_point::required_capabilities(&l.language).contains(&cap) {
wanted.insert(key(&l.plugin, &cap), (program.clone(), cap));
}
}
accepted.push(l);
}
{
let mut pins = self.pins.lock();
pins.retain(|k, _| wanted.contains_key(k));
for (k, (program, _)) in &wanted {
if pins.contains_key(k) {
continue;
}
let resolved = ledger::resolve_program(program, (self.path_var)().as_deref()).and_then(|path| {
ledger::file_hash(&path).map(|hash| BinaryGrant { path, hash }).map_err(|e| e.to_string())
});
match resolved {
Ok(bin) => {
pins.insert(k.clone(), bin);
}
Err(e) => {
tracing::info!("process:{program}: {e}");
report.unresolved.push(program.clone());
}
}
}
for (k, bin) in pins.iter() {
report.pinned.insert(k.replace("\nprocess:", ": "), bin.path.clone());
}
self.save(&pins);
}
let mut applied = self.applied.lock();
applied.clear(); // disposes the previous sync's languages
for l in accepted {
applied.push(self.store.add(l.plugin, l.language));
}
report.languages = applied.len();
self.store.invalidate();
report
}
fn save(&self, pins: &BTreeMap<String, BinaryGrant>) {
let Some(file) = &self.file else { return };
let json = serde_json::to_string_pretty(&PinsFile { pins: pins.clone() }).unwrap_or_default();
let tmp = file.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, json).and_then(|()| std::fs::rename(&tmp, file)) {
tracing::warn!("saving {}: {e}", file.display());
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use ide_core::LanguageId;
use ide_language::LanguageRegistry;
use std::os::unix::fs::PermissionsExt;
fn toy(granted: &[&str]) -> RemoteLanguage {
let language = ide_language::plugin_point::parse_definition(
"id = \"toy\"\nname = \"Toy\"\nextensions = [\"toy\"]\n\
[[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n",
)
.unwrap();
RemoteLanguage {
plugin: "acme.toy".into(),
language,
granted: granted.iter().map(|s| (*s).to_owned()).collect(),
}
}
#[test]
fn syncs_languages_and_pins_granted_programs_on_this_machine() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir(&bin).unwrap();
std::fs::write(bin.join("toylsp"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(bin.join("toylsp"), std::fs::Permissions::from_mode(0o755)).unwrap();
let path = bin.clone().into_os_string();
let remote = RemotePluginLanguages::with_path(Some(tmp.path()), Arc::new(move || Some(path.clone())));
let registry = LanguageRegistry::load(None).unwrap();
registry.attach_plugins(remote.store().clone());
let project = registry.fork();
let id = LanguageId::new("toy");
// Not granted: the language, no server.
let report = remote.sync(vec![toy(&[])]);
assert_eq!(report.languages, 1);
assert_eq!(project.detect(std::path::Path::new("/r/a.toy"), None), id);
assert!(project.get(&id).unwrap().language_servers.is_empty());
// Granted: resolved here and pinned; persisted.
let report = remote.sync(vec![toy(&["toylsp", "rm"])]);
assert_eq!(report.pinned.len(), 1, "only programs the definition starts: {report:?}");
let pin = project.get(&id).unwrap().language_servers[0].pinned.clone().unwrap();
assert!(pin.verify().is_ok());
assert!(tmp.path().join(PINS_FILE).is_file());
// Replaced: refused at start until revoked and granted again.
std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho x\n").unwrap();
remote.sync(vec![toy(&["toylsp"])]);
assert!(project.get(&id).unwrap().language_servers[0].pinned.as_ref().unwrap().verify().is_err());
remote.sync(vec![toy(&[])]);
remote.sync(vec![toy(&["toylsp"])]);
assert!(project.get(&id).unwrap().language_servers[0].pinned.as_ref().unwrap().verify().is_ok());
// Missing programs are reported; invalid definitions rejected; empty sync removes all.
let mut bad = toy(&[]);
bad.language.id = LanguageId::new("a b");
let report = remote.sync(vec![bad]);
assert_eq!(report.rejected.len(), 1);
assert_eq!(project.detect(std::path::Path::new("/r/a.toy"), None), LanguageId::plain_text());
}
}
+502
Переглянути файл
@@ -0,0 +1,502 @@
//! Code plugins of remote projects (docs/plugin_plan.md 3, placement rule 1):
//! 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.
//!
//! The desktop stays the authority over what is installed and granted. It
//! pushes its live code plugins with [`SYNC_PACKAGES`]: first their digests
//! and grants, then the files of those the server doesn't have (one package
//! per request); a package missing from a sync is uninstalled. The server
//! installs them under `<data>/remote-plugins` and records the desktop's
//! grants (a `process:` grant is pinned to the program on this machine's
//! `PATH`).
//!
//! The commands a window needs at run time (`plugin_run_command`,
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
//! `plugin_cancel_task`, `plugin_set_active_file`, `plugin_line_markers`,
//! `plugin_view_render`, `plugin_view_event`)
//! carry the desktop commands' names and arguments, so a remote window's UI
//! routes them here unchanged. Plugin results come back as `plugin`
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
//! keychain and are asked for with a `plugin_secret` request
//! ([`SecretRequest`]) when a request header needs one (rule 4).
use super::{
Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore,
};
use crate::rpc::Dispatcher;
use base64::Engine as _;
use ide_core::{IdeError, IdeResult};
use ide_extension::{MANIFEST_FILE, ledger};
use ide_jsonrpc::Peer;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;
/// The server command the desktop calls with its packages.
pub const SYNC_PACKAGES: &str = "plugin_sync_packages";
/// Server → client notification carrying a [`PluginEventMessage`].
pub const EVENT_NOTIFICATION: &str = "plugin";
/// Server → client request ([`SecretRequest`] → `Option<String>`).
pub const SECRET_REQUEST: &str = "plugin_secret";
/// Under the server's data directory.
pub const PLUGINS_DIR: &str = "remote-plugins";
const INCOMING_DIR: &str = "remote-plugins.incoming";
const SECRET_TIMEOUT: Duration = Duration::from_secs(10);
const EVENT_FLUSH: Duration = Duration::from_millis(250);
const TICKS_PER_SUPERVISION: u32 = 20;
/// One live code plugin of the desktop.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemotePackage {
pub id: String,
/// [`package_digest`] of the package.
pub digest: String,
/// Capabilities the user granted on the desktop.
pub granted: Vec<String>,
}
/// One file of a package (`path` relative to the package, `data` base64).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PackageFile {
pub path: String,
pub data: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageSyncReport {
/// Packages the server needs the files of (send them in another sync).
pub missing: Vec<String>,
pub installed: Vec<String>,
pub removed: Vec<String>,
/// `id: reason`.
pub failed: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PluginEventMessage {
/// The window that invoked the plugin (`None`: every window of the connection).
pub window: Option<String>,
pub event: PluginEvent,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SecretRequest {
pub plugin: String,
pub name: String,
}
/// Identifies a package's content: the manifest, the files its declarations
/// reference and the component.
pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
let mut all = files.to_vec();
all.push(PathBuf::from(MANIFEST_FILE));
Ok(ledger::package_hash(dir, &all)?)
}
/// Whether a plugin event means something to a remote window (the desktop
/// owns the plugin list, themes and consent).
pub fn forwarded(event: &PluginEvent) -> bool {
matches!(
event,
PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::TaskStarted { .. }
| PluginEvent::TaskProgress { .. }
| PluginEvent::TaskEnded { .. }
| PluginEvent::SubscriptionsChanged
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
)
}
// ---- desktop side -----------------------------------------------------------
impl PluginHost {
fn package_files(&self, plugin: &super::LoadedPlugin) -> IdeResult<Vec<PathBuf>> {
let set = self.registry.prepare(&plugin.pkg).map_err(IdeError::invalid)?;
Ok(set.files.into_iter().chain(plugin.manifest().wasm.clone()).collect())
}
/// The live code plugins (installed or linked) for a remote project's server.
pub fn remote_packages(&self) -> Vec<RemotePackage> {
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
plugins
.iter()
.filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some())
.filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive))
.filter_map(|p| {
let files = self.package_files(p).ok()?;
let digest = package_digest(p.dir(), &files).ok()?;
let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect();
Some(RemotePackage { id: p.id().to_owned(), digest, granted })
})
.collect()
}
/// The files of a package for [`SYNC_PACKAGES`], the manifest first.
pub fn remote_package_files(&self, id: &str) -> IdeResult<Vec<PackageFile>> {
let plugin = self.get(id)?;
let files = self.package_files(&plugin)?;
std::iter::once(PathBuf::from(MANIFEST_FILE))
.chain(files)
.map(|rel| {
let bytes = std::fs::read(plugin.dir().join(&rel))?;
let path = rel.to_str().ok_or_else(|| IdeError::invalid("non UTF-8 file name"))?.replace('\\', "/");
Ok(PackageFile { path, data: base64::engine::general_purpose::STANDARD.encode(bytes) })
})
.collect()
}
/// A secret a plugin declares, for its remote instance (`plugin_secret`).
pub fn declared_secret(&self, id: &str, name: &str) -> Option<String> {
let plugin = self.get(id).ok()?;
if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == name) {
return None;
}
let store = self.shared.secrets.read().clone();
store.get(plugin.id(), name)
}
}
// ---- server side ------------------------------------------------------------
/// The plugin host of `openidea-server`.
pub struct RemotePlugins {
host: Arc<PluginHost>,
incoming: PathBuf,
sync: Mutex<()>,
}
/// Secrets from the desktop's keychain, asked for when used.
struct ClientSecrets {
peer: Arc<OnceLock<Peer>>,
runtime: tokio::runtime::Handle,
}
impl SecretStore for ClientSecrets {
fn get(&self, plugin: &str, name: &str) -> Option<String> {
let peer = self.peer.get()?.clone();
let request = SecretRequest { plugin: plugin.to_owned(), name: name.to_owned() };
let ask = async move {
tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Option<String>>(SECRET_REQUEST, request, None)).await
};
// Plugin threads block on the runtime; a runtime worker (a host
// command asking) must leave its thread to the other tasks first.
let answer = if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(|| self.runtime.block_on(ask))
} else {
self.runtime.block_on(ask)
};
match answer {
Ok(Ok(value)) => value,
Ok(Err(e)) => {
tracing::warn!("plugin secret {plugin}/{name}: {e}");
None
}
Err(_) => None,
}
}
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
Err("secrets are stored on the desktop".into())
}
}
impl RemotePlugins {
/// Creates the host over `<data_dir>/remote-plugins`, loads what earlier
/// sessions installed and starts event delivery and supervision.
pub fn start(data_dir: &Path, projects: &Arc<crate::Host>, peer: Arc<OnceLock<Peer>>) -> IdeResult<Arc<Self>> {
let sink_peer = peer.clone();
let sink = move |client: Option<&str>, event: PluginEvent| {
if forwarded(&event)
&& let Some(p) = sink_peer.get()
{
let _ = p.notify(EVENT_NOTIFICATION, PluginEventMessage { window: client.map(str::to_owned), event });
}
};
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() }));
let trust = Arc::downgrade(projects);
host.set_project_policy(Arc::new(move |root| {
trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false))
}));
super::set_editor_providers(Some(&host));
ide_language::set_external_syntax(Some(host.syntax_provider()));
ide_run::set_external_detection(Some(host.run_detection()));
host.load_all();
host.startup_finished();
spawn_supervision(Arc::downgrade(&host))?;
Ok(Arc::new(Self { host, incoming: data_dir.join(INCOMING_DIR), sync: Mutex::new(()) }))
}
pub fn host(&self) -> &Arc<PluginHost> {
&self.host
}
/// Makes the installed packages match `packages`: uninstalls the others,
/// follows changed grants, installs those with `files` and reports the
/// rest as missing.
pub fn sync(
&self,
packages: Vec<RemotePackage>,
mut files: BTreeMap<String, Vec<PackageFile>>,
) -> PackageSyncReport {
let _guard = self.sync.lock();
let mut report = PackageSyncReport::default();
let wanted: BTreeSet<&str> = packages.iter().map(|p| p.id.as_str()).collect();
let installed: Vec<String> = self
.host
.plugins
.read()
.values()
.filter(|p| p.source == PluginSource::Installed)
.map(|p| p.id().to_owned())
.collect();
for id in installed.iter().filter(|id| !wanted.contains(id.as_str())) {
match self.host.uninstall(id) {
Ok(()) => report.removed.push(id.clone()),
Err(e) => report.failed.push(format!("{id}: {e}")),
}
}
for pkg in packages {
if !valid_id(&pkg.id) {
report.failed.push(format!("{}: invalid plugin id", pkg.id));
continue;
}
let consent = Consent { optional: pkg.granted.clone() };
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
if let Err(e) = self.follow_grants(&pkg, &consent) {
report.failed.push(format!("{}: {e}", pkg.id));
}
continue;
}
match files.remove(&pkg.id) {
None => report.missing.push(pkg.id),
Some(list) => match self.install(&pkg, list, &consent) {
Ok(()) => report.installed.push(pkg.id),
Err(e) => report.failed.push(format!("{}: {}", pkg.id, e.message)),
},
}
}
report
}
fn current_digest(&self, id: &str) -> Option<String> {
let plugin = self.host.plugins.read().get(id).cloned()?;
if plugin.source != PluginSource::Installed || plugin.invalid {
return None;
}
let files = self.host.package_files(&plugin).ok()?;
package_digest(plugin.dir(), &files).ok()
}
fn follow_grants(&self, pkg: &RemotePackage, consent: &Consent) -> IdeResult<()> {
let info = self.host.info_of(&pkg.id)?;
let have: BTreeSet<&str> = info.capabilities.iter().filter(|c| c.granted).map(|c| c.id.as_str()).collect();
let want: BTreeSet<&str> = pkg.granted.iter().map(String::as_str).collect();
if have != want || info.status == super::PluginStatus::NeedsConsent {
self.host.grant(&pkg.id, consent)?;
}
Ok(())
}
fn install(&self, pkg: &RemotePackage, files: Vec<PackageFile>, consent: &Consent) -> IdeResult<()> {
let dir = self.incoming.join(&pkg.id);
let _ = std::fs::remove_dir_all(&dir);
let result = (|| {
let mut rels = Vec::new();
for f in files {
let rel = safe_relative(&f.path)?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(f.data.as_bytes())
.map_err(|e| IdeError::invalid(format!("{}: {e}", f.path)))?;
let to = dir.join(&rel);
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&to, bytes)?;
if rel != Path::new(MANIFEST_FILE) {
rels.push(rel);
}
}
if package_digest(&dir, &rels)? != pkg.digest {
return Err(IdeError::invalid("the package arrived incomplete or changed"));
}
let info = self.host.install_from_dir(&dir, consent)?;
if info.id != pkg.id {
let _ = self.host.uninstall(&info.id);
return Err(IdeError::invalid(format!("the package is plugin {:?}", info.id)));
}
Ok(())
})();
let _ = std::fs::remove_dir_all(&dir);
result
}
/// The run-time commands of remote windows plus [`SYNC_PACKAGES`].
pub fn register(self: &Arc<Self>, d: &mut Dispatcher) {
let this = self.clone();
d.add(SYNC_PACKAGES, move |c| {
let this = this.clone();
async move {
let packages: Vec<RemotePackage> = c.arg("packages")?;
let files: Option<BTreeMap<String, Vec<PackageFile>>> = c.arg("files")?;
blocking(move || Ok(this.sync(packages, files.unwrap_or_default()))).await
}
});
let host = Arc::downgrade(&self.host);
let h = host.clone();
d.add("plugin_run_command", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let plugin_id: String = c.arg("pluginId")?;
let command_id: String = c.arg("commandId")?;
let arg: Option<String> = c.arg("arg")?;
let editor: Option<PluginEditor> = c.arg("editor")?;
let invocation = Invocation {
client: Some(c.window.clone()),
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
editor: editor.map(PluginEditor::context),
};
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
});
let h = host.clone();
d.add("plugin_post_events", move |c| {
let h = h.clone();
async move {
alive(&h)?.post_events(Some(&c.window), c.arg("events")?);
Ok(())
}
});
let h = host.clone();
d.add("plugin_event_kinds", move |_| {
let h = h.clone();
async move { Ok(alive(&h)?.event_kinds().into_iter().collect::<Vec<_>>()) }
});
let h = host.clone();
d.add("plugin_status_items", move |_| {
let h = h.clone();
async move {
Ok(alive(&h)?
.status_items()
.into_iter()
.map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item })
.collect::<Vec<_>>())
}
});
let h = host.clone();
d.add("plugin_cancel_task", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.cancel_task(&c.arg::<String>("taskId")?)) }
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
async move {
alive(&h)?.set_active_file(&c.window, c.arg("path")?);
Ok(())
}
});
let h = host.clone();
d.add("plugin_view_render", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
super::views::render(
&c.host,
plugins,
&c.window,
c.arg("pluginId")?,
c.arg("view")?,
c.arg("viewport")?,
c.arg("doc")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_view_event", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
super::views::event(
&c.host,
plugins,
&c.window,
c.arg("pluginId")?,
c.arg("view")?,
c.arg("event")?,
c.arg("doc")?,
)
.await
}
});
d.add("plugin_line_markers", |c| async move {
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
});
}
}
fn alive(host: &Weak<PluginHost>) -> IdeResult<Arc<PluginHost>> {
host.upgrade().ok_or_else(|| IdeError::unavailable("the plugin host has shut down"))
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
fn spawn_supervision(host: Weak<PluginHost>) -> IdeResult<()> {
std::thread::Builder::new().name("remote-plugins".into()).spawn(move || {
for n in 1u32.. {
std::thread::sleep(EVENT_FLUSH);
let Some(host) = host.upgrade() else { return };
host.flush_events();
if n % TICKS_PER_SUPERVISION == 0 {
host.tick();
}
}
})?;
Ok(())
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& !id.starts_with('.')
&& id.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
}
/// `path` as a relative path inside a package (no `..`, no root).
fn safe_relative(path: &str) -> IdeResult<PathBuf> {
let p = Path::new(path);
if path.is_empty() || !p.components().all(|c| matches!(c, Component::Normal(_))) {
return Err(IdeError::invalid(format!("invalid file path {path:?} in package")));
}
Ok(p.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_paths_stay_inside_the_package() {
assert!(safe_relative("dist/plugin.wasm").is_ok());
for bad in ["", "../x", "/etc/passwd", "a/../../b", "./x"] {
assert!(safe_relative(bad).is_err(), "{bad}");
}
assert!(valid_id("ai-completion"));
assert!(!valid_id("../x") && !valid_id(".data") && !valid_id(""));
}
}
+168
Переглянути файл
@@ -0,0 +1,168 @@
//! 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").
//!
//! ```toml
//! [[contributes.runDetectors]]
//! id = "forge-test"
//! name = "forge test"
//! files = ["foundry.toml"] # any of these at the project root
//! command = "forge"
//! args = ["test"]
//! test = true # results go to the test tree
//! ```
//!
//! The command is a `process:` capability like a language server's: the
//! configuration is offered only while it is granted, and starts the approved
//! file (checked right before it starts).
use ide_extension::{
Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, check_relative, decode, validate_item_id,
};
use ide_language::ProgramPin;
use ide_run::{EXTERNAL_PREFIX, ExternalDetection, RunConfig};
use parking_lot::RwLock;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
pub const POINT_ID: &str = "runDetectors";
const MAX_FILES: usize = 16;
const MAX_ARGS: usize = 64;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Detector {
pub id: String,
pub name: String,
pub files: Vec<PathBuf>,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub test: bool,
}
/// Detectors of enabled plugins.
#[derive(Default)]
pub struct RunDetectors {
items: RwLock<Vec<(u64, String, Detector)>>,
seq: AtomicU64,
}
impl RunDetectors {
fn add(self: &Arc<Self>, plugin: String, detector: Detector) -> Disposable {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, detector));
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, _, _)| *s != seq);
}
})
}
}
pub struct RunDetectorsPoint {
store: Arc<RunDetectors>,
}
impl RunDetectorsPoint {
pub fn new(store: Arc<RunDetectors>) -> Self {
Self { store }
}
}
impl ExtensionPoint for RunDetectorsPoint {
fn id(&self) -> &'static str {
POINT_ID
}
fn placement(&self) -> Placement {
Placement::Workspace
}
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let d: Detector = decode(POINT_ID, decl)?;
validate_item_id(&d.id, "run detector id")?;
if d.name.trim().is_empty() || d.name.len() > 128 {
return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id));
}
if d.files.is_empty() || d.files.len() > MAX_FILES {
return Err(format!("run detector {:?}: give 1–{MAX_FILES} marker `files`", d.id));
}
for f in &d.files {
check_relative(f, "files")?;
}
let program = d.command.trim();
if program.is_empty() || program.contains(char::is_whitespace) {
return Err(format!("run detector {:?}: `command` is one program name (arguments go in `args`)", d.id));
}
if d.args.len() > MAX_ARGS {
return Err(format!("run detector {:?}: at most {MAX_ARGS} arguments", d.id));
}
let cap = Capability::Process(program.to_owned());
let (store, plugin, label) = (self.store.clone(), pkg.id().to_owned(), d.name.clone());
let key = d.id.clone();
Ok(Prepared::new(label, move || Ok(store.add(plugin, d))).key(key).capability(cap))
}
}
type Pins = Arc<RwLock<HashMap<(String, String), ProgramPin>>>;
/// [`ExternalDetection`] over the detectors and the approved programs.
pub struct PluginRunDetection {
pub(crate) store: Arc<RunDetectors>,
pub(crate) pins: Pins,
}
fn config_id(plugin: &str, detector: &str) -> String {
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
}
impl PluginRunDetection {
fn pin(&self, plugin: &str, d: &Detector) -> Option<ProgramPin> {
let cap = Capability::Process(d.command.trim().to_owned());
self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
}
}
impl ExternalDetection for PluginRunDetection {
fn detect(&self, root: &Path) -> Vec<RunConfig> {
let items = self.store.items.read().clone();
items
.into_iter()
.filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists()))
.map(|(_, plugin, d)| RunConfig {
id: config_id(&plugin, &d.id),
name: d.name.clone(),
kind: "plugin".into(),
command: d.command.trim().to_owned(),
args: d.args.clone(),
cwd: None,
env: Default::default(),
detected: true,
is_test: d.test,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
})
.collect()
}
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String> {
let items = self.store.items.read();
let (plugin, d) = items
.iter()
.find(|(_, plugin, d)| config_id(plugin, &d.id) == config.id)
.map(|(_, p, d)| (p.clone(), d.clone()))
.ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?;
drop(items);
let pin = self.pin(&plugin, &d).ok_or_else(|| {
format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command)
})?;
pin.verify().map(Path::to_path_buf)
}
}
+72 -3
Переглянути файл
@@ -1,9 +1,21 @@
//! Theme contributions: design-token overrides applied as CSS variables on `:root`.
//! Theme and editor-scheme files are JSON that may contain `//` and `/* */` comments.
//! The `themes` extension point: design-token overrides applied as CSS
//! variables on `:root`. Theme and editor-scheme files are JSON that may
//! contain `//` and `/* */` comments.
//!
//! ```toml
//! [[contributes.themes]]
//! path = "themes/harbor-night.json"
//! editor-scheme = "themes/harbor-night.scheme.json" # optional
//! ```
use ide_extension::{ExtensionPoint, Package, Placement, Prepared, UiContribution, UiStore, decode};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
pub const POINT_ID: &str = "themes";
const MAX_TOKENS: usize = 512;
const MAX_VALUE_LEN: usize = 256;
@@ -48,7 +60,7 @@ impl ThemeDef {
}
pub fn validate(&self) -> Result<(), String> {
crate::manifest::validate_command_id(&self.id).map_err(|e| e.replace("command id", "theme id"))?;
ide_extension::validate_item_id(&self.id, "theme id")?;
if self.name.trim().is_empty() {
return Err("theme name must not be empty".into());
}
@@ -71,6 +83,63 @@ impl ThemeDef {
}
}
pub struct ThemesPoint {
store: Arc<UiStore>,
}
impl ThemesPoint {
pub fn new(store: Arc<UiStore>) -> Self {
Self { store }
}
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct Decl {
path: Option<PathBuf>,
#[serde(default)]
editor_scheme: Option<PathBuf>,
/// The theme JSON inline (runtime `register-theme`).
definition: Option<String>,
}
impl ExtensionPoint for ThemesPoint {
fn id(&self) -> &'static str {
POINT_ID
}
fn placement(&self) -> Placement {
Placement::Ui
}
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()),
};
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()))?;
}
let item = UiContribution {
plugin_id: pkg.id().to_owned(),
point: POINT_ID.into(),
key: theme.id.clone(),
decl: serde_json::to_value(&theme).map_err(|e| e.to_string())?,
};
let store = self.store.clone();
let mut prepared = Prepared::new(theme.name.clone(), move || Ok(store.insert(item))).key(theme.id);
prepared.files = files;
Ok(prepared)
}
}
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
/// parse errors still point at the right line.
fn strip_json_comments(src: &str) -> String {
+236
Переглянути файл
@@ -0,0 +1,236 @@
//! UI-facing plugin types and the event stream.
use super::theme::ThemeDef;
use ide_extension::{CapabilityInfo, ContributionInfo, UiContribution};
pub use ide_plugin::{ActionPerformed, ActionSource, EventKind, NotificationLevel, StatusItem, WorkbenchEvent};
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PluginSource {
/// Installed under `<app-data>/plugins/<id>/`.
Installed,
/// Loaded from `<project>/.openidea/plugins/<dir>/` while that project is open.
Dev,
/// A development plugin linked from any folder (`openidea-plugin link`).
Linked,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PluginStatus {
/// Code instantiated and activated.
Active,
/// Has code that has not been needed yet: contributions are live, no Wasm instance.
Inactive,
/// No code; contributions only.
Static,
Disabled,
/// Waiting for the user to confirm its permissions (project and new plugins).
NeedsConsent,
/// Failed to load, or failed repeatedly and was stopped for this session (see `error`).
Failed,
}
impl PluginStatus {
/// Contributions are in effect.
pub fn is_live(self) -> bool {
matches!(self, Self::Active | Self::Inactive | Self::Static)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
pub source: PluginSource,
/// Package directory.
pub path: ide_core::IpcPath,
pub enabled: bool,
pub status: PluginStatus,
pub error: Option<String>,
pub has_code: bool,
/// Declared capabilities with risk and grant state.
pub capabilities: Vec<CapabilityInfo>,
/// What it contributes while live (point + label).
pub contributions: Vec<ContributionInfo>,
/// `activation` events of the manifest.
pub activation: Vec<String>,
/// Non-fatal problems (unsupported contribution points).
pub warnings: Vec<String>,
/// Statistics of this session.
pub stats: PluginStats,
/// The marketplace publisher whose signature the package carries (none: unsigned or untrusted).
pub publisher: Option<String>,
/// The version an update replaced, to roll back to.
pub previous_version: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginStats {
pub activations: u32,
/// Last activation (instantiate + `activate`), ms.
pub activation_ms: f64,
pub calls: u32,
pub errors: u32,
/// Slowest call, ms.
pub max_call_ms: f64,
/// 95th percentile of the recent calls, ms.
pub p95_call_ms: f64,
/// Edit-path calls whose result came too late and was dropped.
pub slow_calls: u32,
/// Traps (panics, budgets, permission violations); each drops the instance.
pub crashes: u32,
/// Instances reclaimed after being idle.
pub reclaims: u32,
}
/// Number of declarations of one extension point.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContributionCount {
pub point: String,
pub count: u32,
}
/// A plugin package available for installation from a local catalog
/// directory (the samples shipped with the IDE). Not installed by listing it.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CatalogPlugin {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
/// Package directory; install with `install_from_dir(path)`.
pub path: ide_core::IpcPath,
pub has_code: bool,
pub capabilities: Vec<CapabilityInfo>,
pub contributions: Vec<ContributionCount>,
/// Why the package cannot be installed as is (e.g. its WASM is not built).
pub problem: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ActiveTheme {
pub plugin_id: String,
pub theme: ThemeDef,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginsSnapshot {
pub plugins: Vec<PluginInfo>,
pub active_theme: Option<ActiveTheme>,
/// UI contributions of live plugins, projected by the client.
pub ui: Vec<UiContribution>,
}
/// Backend → UI notifications (Tauri event `ide://plugin`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PluginEvent {
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
CommandsChanged,
#[serde(rename_all = "camelCase")]
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
/// A plugin's status bar item was set (`item`) or removed.
#[serde(rename_all = "camelCase")]
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
/// A plugin began a task: show it with the background tasks.
#[serde(rename_all = "camelCase")]
TaskStarted { plugin_id: String, task_id: String, title: String, cancellable: bool },
#[serde(rename_all = "camelCase")]
TaskProgress { plugin_id: String, task_id: String, done: u32, total: Option<u32>, message: Option<String> },
#[serde(rename_all = "camelCase")]
TaskEnded { plugin_id: String, task_id: String },
/// The kinds of workbench events plugins want changed: re-fetch
/// `plugin_event_kinds` (the UI reports only those).
SubscriptionsChanged,
#[serde(rename_all = "camelCase")]
ThemeChanged { theme: Option<ActiveTheme> },
/// A plugin needs capabilities the user has not granted yet (first use
/// after migrating from 0.1): show its consent dialog.
#[serde(rename_all = "camelCase")]
ConsentRequested { plugin_id: String, capabilities: Vec<CapabilityInfo> },
/// A view (or the custom editors) of a plugin must be rendered again.
#[serde(rename_all = "camelCase")]
ViewChanged { plugin_id: String, view: String },
/// For the script of a view's `html` body.
#[serde(rename_all = "camelCase")]
ViewMessage { plugin_id: String, view: String, message: String },
/// Open a terminal running `program` (already approved and verified).
#[serde(rename_all = "camelCase")]
OpenTerminal {
plugin_id: String,
title: String,
program: ide_core::IpcPath,
args: Vec<String>,
cwd: ide_core::IpcPath,
},
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
/// (`None` = broadcast, e.g. during activation).
pub trait PluginEventSink: Send + Sync {
fn emit(&self, client: Option<&str>, event: PluginEvent);
}
impl<F: Fn(Option<&str>, PluginEvent) + Send + Sync> PluginEventSink for F {
fn emit(&self, client: Option<&str>, event: PluginEvent) {
self(client, event)
}
}
/// The focused editor when a plugin command is invoked.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginEditor {
pub path: String,
pub language: String,
pub selections: Vec<ide_core::Range>,
}
impl PluginEditor {
pub fn context(self) -> ide_plugin::EditorContext {
ide_plugin::EditorContext { path: self.path.into(), language: self.language, selections: self.selections }
}
}
/// A plugin's status bar item.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginStatusItem {
pub plugin_id: String,
pub id: String,
pub item: StatusItem,
}
/// A gutter marker from a plugin's `line-markers` provider.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LineMarkerInfo {
pub plugin_id: String,
/// 0-based.
pub line: u32,
/// `navigate` (default), `implemented`, `implementing`, `run`.
pub icon: Option<String>,
pub tooltip: String,
/// Opened on click.
pub target: Option<ide_core::Location>,
}
+420
Переглянути файл
@@ -0,0 +1,420 @@
//! Declarative UI contributions (docs/plugin_plan.md P7): 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.
//!
//! ```toml
//! [[contributes.menus]]
//! menu = "editor.context" # projectView.context, editorTab.context, tools
//! command = "check"
//! when = "editorLang == java"
//!
//! [[contributes.keymaps]]
//! command = "check"
//! key = "Ctrl+Alt+C" # `mac` for a different binding on macOS (`Meta+Alt+C`)
//!
//! [[contributes.statusBar]]
//! id = "branch"
//! text = "…" # code replaces it with `set-status-item("branch", …)`
//! command = "show"
//!
//! [contributes.settings]
//! schema = "settings.schema.json" # {"properties": {"key": {"type": "boolean", "default": true, …}}}
//!
//! [[contributes.views]]
//! id = "clusters"
//! title = "Kubernetes"
//! icon = "server"
//! location = "right" # left, right, bottom
//!
//! [[contributes.customEditors]]
//! id = "table"
//! title = "Table"
//! patterns = ["*.csv", "*.tsv"]
//! priority = "default" # or "option": only through Open With
//! ```
use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::path::PathBuf;
pub const MENUS: &str = "menus";
pub const KEYMAPS: &str = "keymaps";
pub const STATUS_BAR: &str = "statusBar";
pub const SETTINGS: &str = "settings";
pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
/// Command ids the package declares.
fn commands(pkg: &Package) -> BTreeSet<String> {
pkg.manifest
.declarations(super::COMMANDS)
.iter()
.filter_map(|d| d.get("id").and_then(|v| v.as_str()).map(str::to_owned))
.collect()
}
fn declared_command(pkg: &Package, point: &str, command: &str) -> Result<(), String> {
if commands(pkg).contains(command) {
Ok(())
} else {
Err(format!("contributes.{point}: command {command:?} is not declared under [[contributes.commands]]"))
}
}
fn title(point: &str, t: &str) -> Result<(), String> {
if t.trim().is_empty() || t.len() > MAX_TITLE {
return Err(format!("contributes.{point}: titles are 1–{MAX_TITLE} bytes"));
}
Ok(())
}
fn json<T: Serialize>(v: &T) -> Result<Value, String> {
serde_json::to_value(v).map_err(|e| e.to_string())
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct MenuDecl {
menu: String,
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
}
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: MenuDecl = decode(MENUS, decl)?;
if !MENU_IDS.contains(&d.menu.as_str()) {
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
}
declared_command(pkg, MENUS, &d.command)?;
if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
}
Ok(UiDecl::new(format!("{}/{}", d.menu, d.command), d.command.clone(), json(&d)?))
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct KeymapDecl {
command: String,
key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
mac: Option<String>,
}
const MODIFIERS: [&str; 4] = ["Meta", "Ctrl", "Alt", "Shift"];
const NAMED_KEYS: [&str; 22] = [
"Enter",
"Escape",
"Tab",
"Space",
"Backspace",
"Delete",
"Insert",
"Home",
"End",
"PageUp",
"PageDown",
"Up",
"Down",
"Left",
"Right",
"Minus",
"Equal",
"Comma",
"Period",
"Slash",
"Semicolon",
"Quote",
];
/// `Ctrl+Alt+C`, `Meta+K Meta+C` (a chord): at least one modifier per
/// stroke but for function keys, so plugins can't take plain typing.
pub fn validate_binding(binding: &str) -> Result<(), String> {
let strokes: Vec<&str> = binding.split(' ').collect();
if strokes.is_empty() || strokes.len() > 2 {
return Err(format!("{binding:?}: one stroke, or two for a chord"));
}
for stroke in strokes {
let parts: Vec<&str> = stroke.split('+').collect();
let (key, mods) = parts.split_last().ok_or_else(|| format!("{binding:?}: empty stroke"))?;
let mut seen = BTreeSet::new();
for m in mods {
if !MODIFIERS.contains(m) || !seen.insert(*m) {
return Err(format!("{binding:?}: unknown or repeated modifier {m:?}"));
}
}
let function = key.strip_prefix('F').and_then(|n| n.parse::<u8>().ok()).is_some_and(|n| (1..=24).contains(&n));
let plain = key.len() == 1 && key.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());
if !(function || plain || NAMED_KEYS.contains(key)) {
return Err(format!("{binding:?}: unknown key {key:?}"));
}
if !function && seen.iter().all(|m| *m == "Shift") {
return Err(format!("{binding:?}: needs Ctrl, Alt or Meta (plain keys are for typing)"));
}
}
Ok(())
}
pub(super) fn keymap(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: KeymapDecl = decode(KEYMAPS, decl)?;
declared_command(pkg, KEYMAPS, &d.command)?;
validate_binding(&d.key).map_err(|e| format!("contributes.keymaps: {e}"))?;
if let Some(m) = &d.mac {
validate_binding(m).map_err(|e| format!("contributes.keymaps: {e}"))?;
}
Ok(UiDecl::new(d.command.clone(), d.key.clone(), json(&d)?))
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct StatusDecl {
id: String,
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
tooltip: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default = "right")]
align: String,
#[serde(default)]
priority: u32,
}
fn right() -> String {
"right".into()
}
pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: StatusDecl = decode(STATUS_BAR, decl)?;
validate_item_id(&d.id, "status bar item id")?;
if d.text.is_empty() || d.text.len() > 128 || d.tooltip.as_ref().is_some_and(|t| t.len() > 1024) {
return Err(format!("status bar item {:?}: `text` 1–128 bytes, `tooltip` up to 1 KiB", d.id));
}
if d.align != "left" && d.align != "right" {
return Err(format!("status bar item {:?}: `align` is left or right", d.id));
}
if d.priority > 1000 {
return Err(format!("status bar item {:?}: `priority` is 0–1000", d.id));
}
if let Some(c) = &d.command {
declared_command(pkg, STATUS_BAR, c)?;
}
Ok(UiDecl::new(d.id.clone(), d.text.clone(), json(&d)?))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SettingsDecl {
#[serde(default)]
title: Option<String>,
schema: PathBuf,
}
#[derive(Debug, Deserialize)]
struct SchemaFile {
properties: serde_json::Map<String, Value>,
}
/// One setting of a plugin.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingSchema {
pub key: String,
/// `boolean`, `string`, `number` or `integer`.
#[serde(rename = "type")]
pub kind: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub default: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<String>>,
#[serde(default)]
pub multiline: bool,
}
impl SettingSchema {
/// Whether `v` is a valid value of this setting.
pub fn accepts(&self, v: &Value) -> bool {
match self.kind.as_str() {
"boolean" => v.is_boolean(),
"integer" => v.is_i64() || v.is_u64(),
"number" => v.is_number(),
_ => v
.as_str()
.is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))),
}
}
}
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) {
return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]"));
}
let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
let kind = o.get("type").and_then(Value::as_str).unwrap_or("");
let default = match kind {
"boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)),
"string" => o.get("default").cloned().unwrap_or(Value::String(String::new())),
"number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)),
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
};
let options = match o.get("enum") {
None => None,
Some(Value::Array(a)) if kind == "string" && !a.is_empty() && a.len() <= 100 => Some(
a.iter()
.map(|v| v.as_str().map(str::to_owned).ok_or_else(|| format!("setting {key:?}: `enum` lists strings")))
.collect::<Result<Vec<_>, _>>()?,
),
Some(_) => return Err(format!("setting {key:?}: `enum` is 1–100 strings of a string setting")),
};
let text = |name: &str, max: usize| -> Result<Option<String>, String> {
match o.get(name) {
None => Ok(None),
Some(Value::String(s)) if s.len() <= max => Ok(Some(s.clone())),
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
}
};
let s = SettingSchema {
key: key.to_owned(),
kind: kind.to_owned(),
title: text("title", MAX_TITLE)?.unwrap_or_else(|| key.to_owned()),
description: text("description", 4096)?,
default,
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
};
if !s.accepts(&s.default) {
return Err(format!("setting {key:?}: the default does not match its type"));
}
Ok(s)
}
/// The validated settings of a `[contributes.settings]` schema file.
pub fn parse_settings_schema(text: &str) -> Result<Vec<SettingSchema>, String> {
let file: SchemaFile = serde_json::from_str(text).map_err(|e| e.to_string())?;
if file.properties.len() > MAX_SETTINGS {
return Err(format!("more than {MAX_SETTINGS} settings"));
}
file.properties.iter().map(|(k, v)| setting(k, v)).collect()
}
pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: SettingsDecl = decode(SETTINGS, decl)?;
let text = pkg.read_text(&d.schema)?;
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
title_ok(&title)?;
let mut decl =
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
decl.files.push(d.schema);
Ok(decl)
}
fn title_ok(t: &str) -> Result<(), String> {
title(SETTINGS, t)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct ViewDecl {
id: String,
title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
icon: Option<String>,
#[serde(default = "right")]
location: String,
}
pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: ViewDecl = decode(VIEWS, decl)?;
validate_item_id(&d.id, "view id")?;
title(VIEWS, &d.title)?;
if pkg.manifest.wasm.is_none() {
return Err(format!("view {:?}: views are rendered by the plugin's code (`wasm`)", d.id));
}
if !["left", "right", "bottom"].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right or bottom", d.id));
}
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
}
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
id: String,
title: String,
patterns: Vec<String>,
#[serde(default = "default_priority")]
priority: String,
}
fn default_priority() -> String {
"default".into()
}
pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: CustomEditorDecl = decode(CUSTOM_EDITORS, decl)?;
validate_item_id(&d.id, "custom editor id")?;
title(CUSTOM_EDITORS, &d.title)?;
if pkg.manifest.wasm.is_none() {
return Err(format!("custom editor {:?}: needs the plugin's code (`wasm`)", d.id));
}
if d.patterns.is_empty() || d.patterns.len() > MAX_PATTERNS {
return Err(format!("custom editor {:?}: 1–{MAX_PATTERNS} file name `patterns`", d.id));
}
for p in &d.patterns {
if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() {
return Err(format!("custom editor {:?}: invalid file name pattern {p:?}", d.id));
}
}
if d.priority != "default" && d.priority != "option" {
return Err(format!("custom editor {:?}: `priority` is default or option", d.id));
}
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bindings_need_a_modifier() {
for ok in ["Ctrl+Alt+C", "Meta+Shift+K", "F5", "Shift+F6", "Meta+K Meta+C", "Alt+Enter"] {
assert!(validate_binding(ok).is_ok(), "{ok}");
}
for bad in ["C", "Shift+C", "Hyper+C", "Ctrl+Ctrl+C", "Ctrl+é", "Ctrl+A B C", ""] {
assert!(validate_binding(bad).is_err(), "{bad}");
}
}
#[test]
fn settings_schemas_are_typed() {
let s = parse_settings_schema(
r#"{"properties": {
"context": {"type": "string", "title": "Context", "default": ""},
"watch": {"type": "boolean", "default": true},
"mode": {"type": "string", "enum": ["a", "b"], "default": "b"},
"limit": {"type": "integer", "default": 100}
}}"#,
)
.unwrap();
assert_eq!(s.len(), 4);
assert!(s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("a")));
assert!(!s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("c")));
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err());
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
}
}
+53
Переглянути файл
@@ -0,0 +1,53 @@
//! Declarative views and custom editors (docs/plugin_plan.md P7) as the
//! window commands `plugin_view_render` / `plugin_view_event` serve them —
//! the desktop for local projects, `openidea-server` for remote ones.
use super::PluginHost;
use ide_core::{DocId, IdeError, IdeResult};
use ide_plugin::{EditorContext, Invocation, View, ViewEvent, Viewport};
use std::sync::Arc;
/// The window's project, plus the document a custom editor shows.
pub fn view_invocation(host: &crate::Host, window: &str, doc: Option<DocId>) -> IdeResult<Invocation> {
let project = host.project(window).ok();
let editor = match doc {
None => None,
Some(doc) => {
let project = project.as_ref().ok_or_else(|| IdeError::not_found("no project is open in this window"))?;
let info = project.documents.snapshot(doc)?;
Some(EditorContext { path: info.path.0, language: info.language.0, selections: Vec::new() })
}
};
Ok(Invocation { client: Some(window.to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor })
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
#[allow(clippy::too_many_arguments)]
pub async fn render(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
view: String,
viewport: Option<Viewport>,
doc: Option<DocId>,
) -> IdeResult<View> {
let invocation = view_invocation(host, window, doc)?;
blocking(move || plugins.render_view(&plugin_id, &view, viewport, invocation)).await
}
pub async fn event(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
view: String,
event: ViewEvent,
doc: Option<DocId>,
) -> IdeResult<()> {
let invocation = view_invocation(host, window, doc)?;
blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await
}
+4 -1
Переглянути файл
@@ -9,7 +9,10 @@
//! * server → client notification `event` `{ window, event }` (an `IdeEvent`
//! for that window, or every window when `window` is null);
//! * server → client notification `channel` `{ id, data }`: one value of a
//! streaming argument.
//! streaming argument;
//! * server → client notification `plugin` `{ window, event }` and request
//! `plugin_secret` `{ plugin, name }` → `string | null`: code plugins of the
//! remote project (`plugins::remote_host`).
use crate::Host;
use crate::ops::{self, Emit};
+1
Переглянути файл
@@ -273,6 +273,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "plugin_set_theme"
| "plugin_set_theme_frame"
| "plugin_catalog"
| "plugin_assets"
| "notebook_open"
| "notebook_get"
| "notebook_edit"
+262
Переглянути файл
@@ -0,0 +1,262 @@
//! Plan P8: 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};
use ide_host::plugins::*;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::Arc;
type Events = Arc<Mutex<Vec<PluginEvent>>>;
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_name() == "target" {
continue;
}
if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() }
}
}
/// A copy of `from` (under `dir/name`) whose manifest is rewritten by `f`.
fn variant(from: &Path, dir: &Path, name: &str, f: impl Fn(&str) -> String) -> PathBuf {
let pkg = dir.join(name);
copy_dir(from, &pkg);
let manifest = pkg.join("plugin.toml");
std::fs::write(&manifest, f(&std::fs::read_to_string(&manifest).unwrap())).unwrap();
pkg
}
struct Fixture {
host: PluginHost,
plugins_dir: PathBuf,
events: Events,
_tmp: tempfile::TempDir,
work: PathBuf,
}
fn fixture() -> Fixture {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let events: Events = Arc::default();
let sink = {
let events = events.clone();
move |_: Option<&str>, e: PluginEvent| events.lock().push(e)
};
let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), Limits::default()).unwrap();
host.load_all();
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
Fixture { host, plugins_dir, events, work, _tmp: tmp }
}
fn inv() -> Invocation {
Invocation { client: Some("main".into()), workspace_root: None, editor: None }
}
fn misbehave() -> PathBuf {
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
}
/// P8 acceptance: an update that fails to start rolls back to the version it replaced.
#[test]
fn a_failing_update_rolls_back_automatically() {
let f = fixture();
let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\""));
f.host.install_from_dir(&v1, &Consent::all()).unwrap();
assert_eq!(f.host.run_command("misbehave", "echo", Some("hi"), inv()).unwrap().as_deref(), Some("hi"));
let broken =
variant(&misbehave(), &f.work, "v2", |t| t.replace("version = \"0.0.2\"", "version = \"2.0.0-broken\""));
let info = f.host.install_from_dir(&broken, &Consent::all()).unwrap();
assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("2.0.0-broken", Some("1.0.0")));
let root = f.plugins_dir.join("misbehave");
assert!(root.join("2.0.0-broken").is_dir() && root.join("1.0.0").is_dir(), "versions side by side");
// Its first start fails: back to 1.0.0, without the user doing anything.
f.events.lock().clear();
let err = f.host.run_command("misbehave", "echo", Some("x"), inv()).unwrap_err();
assert!(err.message.contains("version 1.0.0 was restored"), "{}", err.message);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.version.as_str(), info.status), ("1.0.0", PluginStatus::Inactive));
assert!(!root.join("2.0.0-broken").exists(), "the failed version is removed");
assert!(
f.events
.lock()
.iter()
.any(|e| matches!(e, PluginEvent::Notification { title, .. } if title.contains("rolled back")))
);
assert_eq!(f.host.run_command("misbehave", "echo", Some("ok"), inv()).unwrap().as_deref(), Some("ok"));
// A working update is confirmed by its first start; the user can still roll it back.
let v3 = variant(&misbehave(), &f.work, "v3", |t| t.replace("version = \"0.0.2\"", "version = \"3.0.0\""));
f.host.install_from_dir(&v3, &Consent::all()).unwrap();
f.host.run_command("misbehave", "echo", None, inv()).unwrap();
assert_eq!(f.host.info_of("misbehave").unwrap().version, "3.0.0");
let info = f.host.roll_back_update("misbehave").unwrap();
assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", None));
assert!(f.host.roll_back_update("misbehave").is_err(), "nothing older to go back to");
// A restart loads the active version.
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
assert_eq!(again.info_of("misbehave").unwrap().version, "1.0.0");
f.host.uninstall("misbehave").unwrap();
assert!(!root.exists(), "every version goes");
}
#[test]
fn installs_from_before_versioning_keep_working_and_move_on_update() {
let f = fixture();
// The flat layout of earlier releases.
copy_dir(&misbehave(), &f.plugins_dir.join("misbehave"));
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
assert_eq!(again.info_of("misbehave").unwrap().version, "0.0.2");
let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\""));
let info = again.install_from_dir(&v1, &Consent::all()).unwrap();
assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", Some("0.0.2")));
assert!(f.plugins_dir.join("misbehave/0.0.2/plugin.toml").is_file());
assert!(f.plugins_dir.join("misbehave/1.0.0/plugin.toml").is_file());
}
// ---- marketplace ------------------------------------------------------------------
/// `hello` as `acme.hello` at `version`, packed and signed with `key`.
fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost) -> (PathBuf, IndexEntry) {
let src = variant(&repo().join("plugins/hello"), dir, &format!("src-{version}"), |t| {
t.replace("id = \"hello\"", "id = \"acme.hello\"")
.replace("version = \"0.3.0\"", &format!("version = \"{version}\""))
});
let files = host.package_files_of(&src).unwrap();
let digest = package::package_digest(&src, &files).unwrap();
let sig = key.sign("acme", "acme.hello", version, &digest);
let out = dir.join(format!("acme.hello-{version}.oiplugin"));
package::pack(&src, &files, &out, Some(&sig)).unwrap();
let entry = IndexEntry {
id: "acme.hello".into(),
name: "Hello".into(),
version: version.into(),
description: String::new(),
authors: vec![],
publisher: "acme".into(),
download: out.file_name().unwrap().to_string_lossy().into_owned(),
digest,
capabilities: vec![],
};
(out, entry)
}
fn write_index(
dir: &Path,
key: &PublisherKey,
plugins: Vec<IndexEntry>,
revoked: Vec<Revocation>,
key_revoked: bool,
) -> String {
let index = Index {
format: package::INDEX_FORMAT,
publishers: vec![Publisher {
id: "acme".into(),
name: "ACME".into(),
keys: vec![KeyEntry { key: key.public_base64(), revoked: key_revoked }],
}],
plugins,
revoked,
};
let path = dir.join("index.json");
std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let url = write_index(&market, &key, vec![entry.clone()], vec![], false);
assert!(f.host.refresh_market().is_err(), "no marketplace configured yet");
f.host.set_market_url(Some(url.clone()));
let listed = f.host.refresh_market().unwrap();
assert_eq!((listed[0].entry.id.as_str(), listed[0].installed.as_deref()), ("acme.hello", None));
let dir = f.host.download_market_plugin("acme.hello").unwrap();
let preview = f.host.preview_install(&dir).unwrap();
assert_eq!((preview.unsigned, preview.publisher.as_deref()), (false, Some("acme")));
let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!(info.publisher.as_deref(), Some("acme"));
assert_eq!(f.host.market_plugins()[0].installed.as_deref(), Some("1.0.0"));
// A changed file breaks the signature: refused.
let tampered = f.host.download_market_plugin("acme.hello").unwrap();
let manifest = std::fs::read_to_string(tampered.join("plugin.toml")).unwrap();
std::fs::write(tampered.join("plugin.toml"), manifest.replace("Sample plugin", "Totally safe plugin")).unwrap();
let err = f.host.preview_install(&tampered).unwrap_err();
assert!(err.message.contains("changed after signing"), "{}", err.message);
// Someone else's key, or another publisher's prefix: not trusted.
let stranger = PublisherKey::from_seed([4; 32]);
let (foreign, _) = signed_hello(&f.work, "1.0.1", &stranger, &f.host);
let unpacked = f.host.unpack_package(&foreign).unwrap();
assert!(f.host.preview_install(&unpacked).unwrap_err().message.contains("not one of"));
// The index revokes the installed version: disabled, the user told.
f.events.lock().clear();
let revoked =
vec![Revocation { id: "acme.hello".into(), versions: vec!["1.0.0".into()], reason: "steals tokens".into() }];
write_index(&market, &key, vec![entry.clone()], revoked, false);
f.host.refresh_market().unwrap();
let info = f.host.info_of("acme.hello").unwrap();
assert_eq!((info.status, info.error.as_deref()), (PluginStatus::Failed, Some("revoked: steals tokens")));
assert!(
f.events
.lock()
.iter()
.any(|e| matches!(e, PluginEvent::Notification { body, .. } if body.contains("steals tokens")))
);
assert!(f.host.market_plugins().is_empty(), "revoked versions are not offered");
assert!(f.host.install_from_dir(&dir, &Consent::all()).is_err(), "nor installable");
f.host.set_enabled("acme.hello", true).unwrap();
assert_eq!(f.host.info_of("acme.hello").unwrap().status, PluginStatus::Failed, "enabling doesn't bring it back");
// Revocations hold across restarts (the index is kept).
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
assert_eq!(again.info_of("acme.hello").unwrap().status, PluginStatus::Failed);
// A revoked signing key disables what it signed.
write_index(&market, &key, vec![entry.clone()], vec![], true);
f.host.refresh_market().unwrap();
let info = f.host.info_of("acme.hello").unwrap();
assert!(info.error.as_deref().unwrap_or("").contains("signing key was revoked"), "{:?}", info.error);
// Lifted: it runs again.
write_index(&market, &key, vec![entry], vec![], false);
f.host.refresh_market().unwrap();
let info = f.host.info_of("acme.hello").unwrap();
assert_eq!((info.status, info.error), (PluginStatus::Inactive, None));
}
#[test]
fn package_files_install_from_disk_unsigned() {
let f = fixture();
let src = variant(&misbehave(), &f.work, "pkg", |t| t.to_owned());
let files = f.host.package_files_of(&src).unwrap();
let out = f.work.join("misbehave-0.0.2.oiplugin");
package::pack(&src, &files, &out, None).unwrap();
let dir = f.host.unpack_package(&out).unwrap();
let preview = f.host.preview_install(&dir).unwrap();
assert_eq!((preview.unsigned, preview.publisher), (true, None));
let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!((info.status, info.publisher), (PluginStatus::Inactive, None));
}
File diff suppressed because it is too large. Load diff
+311
Переглянути файл
@@ -0,0 +1,311 @@
//! 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 over the remote protocol; Checkstyle then checks the file on
//! the server's disk when it is saved, and secrets come from the desktop.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::plugins::remote_host::{
EVENT_NOTIFICATION, PackageSyncReport, PluginEventMessage, RemotePlugins, SECRET_REQUEST, SYNC_PACKAGES,
SecretRequest,
};
use ide_host::plugins::{Consent, PluginEvent, PluginHost, PluginStatus, WorkbenchEvent};
use ide_host::rpc::{self, Dispatcher, EventMessage, Invoke};
use ide_host::{Host, HostConfig};
use ide_jsonrpc::{Handler, Peer};
use ide_plugin::Limits;
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
#[derive(Default)]
struct Client {
events: Mutex<Vec<EventMessage>>,
plugin_events: Mutex<Vec<PluginEventMessage>>,
secret_requests: Mutex<Vec<SecretRequest>>,
}
impl Handler for Client {
fn on_notification(&self, method: &str, params: Value) {
match method {
"event" => self.events.lock().push(serde_json::from_value(params).unwrap()),
EVENT_NOTIFICATION => self.plugin_events.lock().push(serde_json::from_value(params).unwrap()),
_ => {}
}
}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
if method == SECRET_REQUEST {
let r: SecretRequest = serde_json::from_value(params).unwrap();
let value = (r.name == "api-key").then(|| "sk-desktop".to_owned());
self.secret_requests.lock().push(r);
return Ok(serde_json::to_value(value).unwrap());
}
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
}
}
async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result<Value, ide_core::IdeError> {
let call = Invoke { window: "main".into(), cmd: cmd.into(), args };
peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply)
}
async fn sync(peer: &Peer, desktop: &PluginHost, files: &[&str]) -> PackageSyncReport {
let files: serde_json::Map<String, Value> = files
.iter()
.map(|id| ((*id).to_owned(), serde_json::to_value(desktop.remote_package_files(id).unwrap()).unwrap()))
.collect();
let args = json!({ "packages": desktop.remote_packages(), "files": files });
serde_json::from_value(invoke(peer, SYNC_PACKAGES, args).await.unwrap()).unwrap()
}
fn official(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official").join(name)
}
/// A `checkstyle` that reports a tab on line 2 of the file it is given.
fn fake_checkstyle(bin: &Path) {
std::fs::create_dir_all(bin).unwrap();
std::fs::write(
bin.join("checkstyle"),
"#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q '\t' \"$file\" || { echo '<checkstyle/>'; exit 0; }\n\
echo '<?xml version=\"1.0\"?><checkstyle><file name=\"'\"$file\"'\">'\n\
echo '<error line=\"2\" column=\"1\" severity=\"warning\" message=\"Line contains a tab character.\" source=\"com.puppycrawl.tools.checkstyle.checks.whitespace.FileTabCharacterCheck\"/>'\n\
echo '</file></checkstyle>'\nexit 1\n",
)
.unwrap();
std::fs::set_permissions(bin.join("checkstyle"), std::fs::Permissions::from_mode(0o755)).unwrap();
}
async fn until(what: &str, secs: u64, mut done: impl FnMut() -> bool) {
for _ in 0..secs * 50 {
if done() {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("timed out waiting for {what}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
fake_checkstyle(&bin);
let path_var = {
let bin = bin.clone();
Arc::new(move || Some(bin.clone().into_os_string()))
};
// The desktop: Checkstyle and AI Completion installed and granted.
let desktop =
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.unwrap();
desktop.set_program_path(path_var.clone());
for name in ["checkstyle", "ai-completion"] {
let info = desktop.install_from_dir(&official(name), &Consent::all()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
}
// The server, with the project on "its" disk.
// Canonical, as the UI reports paths (macOS: /var → /private/var).
let project = tmp.path().join("remote/demo");
std::fs::create_dir_all(project.join("src")).unwrap();
let project = std::fs::canonicalize(project).unwrap();
let base = tmp.path().join("server");
let peer_slot = Arc::new(OnceLock::new());
let sink_peer = peer_slot.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
plugins.host().set_program_path(path_var);
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client::default());
let peer = Peer::connect(cr, cw, client.clone());
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
// Digests first; then the files of what the server lacks; then nothing to do.
let first = sync(&peer, &desktop, &[]).await;
assert_eq!(first.missing, ["openidea.ai-completion", "openidea.checkstyle"], "{first:?}");
let second = sync(&peer, &desktop, &["openidea.ai-completion", "openidea.checkstyle"]).await;
assert_eq!(second.installed.len(), 2, "{second:?}");
assert!(second.failed.is_empty(), "{second:?}");
assert_eq!(sync(&peer, &desktop, &[]).await, PackageSyncReport::default());
let remote = plugins.host().info_of("openidea.checkstyle").unwrap();
assert_eq!(remote.status, PluginStatus::Inactive, "{:?}", remote.error);
assert!(remote.capabilities.iter().any(|c| c.id == "process:checkstyle" && c.granted));
// The remote window's UI routes the run-time commands to the server.
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
// Saving a file that exists only on the server: Checkstyle reads that file.
let java = project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
let saved = WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned());
invoke(&peer, "plugin_post_events", json!({ "events": [saved] })).await.unwrap();
let published = || {
client.events.lock().iter().find_map(|m| match &m.event {
IdeEvent::Diagnostics { path, source, items } if source == "plugin:openidea.checkstyle" => {
Some((path.0.clone(), items.clone()))
}
_ => None,
})
};
until("remote checkstyle diagnostics", 20, || published().is_some()).await;
let (path, items) = published().unwrap();
assert_eq!(path, java);
assert_eq!((items.len(), items[0].range.start.line), (1, 1));
// Secrets stay on the desktop: the server asks for them when used.
let secrets = plugins.host().secrets("openidea.ai-completion").unwrap();
assert_eq!(secrets, [("api-key".to_owned(), true)]);
assert_eq!(client.secret_requests.lock()[0].plugin, "openidea.ai-completion");
// A command of a remote plugin, with this window as its client.
invoke(
&peer,
"plugin_run_command",
json!({ "pluginId": "openidea.ai-completion", "commandId": "toggle", "arg": null, "editor": null }),
)
.await
.unwrap();
until("the command's notification", 5, || {
client
.plugin_events
.lock()
.iter()
.any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::Notification { .. }))
})
.await;
// Disabled on the desktop: gone from the server.
desktop.set_enabled("openidea.checkstyle", false).unwrap();
let removed = sync(&peer, &desktop, &[]).await;
assert_eq!(removed.removed, ["openidea.checkstyle"]);
assert!(plugins.host().info_of("openidea.checkstyle").is_err());
plugins.host().shutdown();
}
/// Plan P7 remote acceptance: 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)]
async fn remote_projects_get_plugin_views_and_custom_editors() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(
bin.join("kubectl"),
"#!/bin/sh\ncase \"$*\" in\n\
*'get namespaces'*) echo '{\"items\":[{\"metadata\":{\"name\":\"remote-ns\"}}]}' ;;\n\
*) echo '{\"items\":[]}' ;;\nesac\n",
)
.unwrap();
std::fs::set_permissions(bin.join("kubectl"), std::fs::Permissions::from_mode(0o755)).unwrap();
let path_var = {
let bin = bin.clone();
Arc::new(move || Some(bin.clone().into_os_string()))
};
let desktop =
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.unwrap();
desktop.set_program_path(path_var.clone());
for name in ["csv-editor", "kubernetes"] {
let info = desktop.install_from_dir(&official(name), &Consent::all()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
}
let project = tmp.path().join("remote/demo");
std::fs::create_dir_all(&project).unwrap();
let project = std::fs::canonicalize(project).unwrap();
std::fs::write(project.join("people.csv"), "name,city\nAda,London\n").unwrap();
let base = tmp.path().join("server");
let peer_slot = Arc::new(OnceLock::new());
let sink_peer = peer_slot.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
plugins.host().set_program_path(path_var);
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client::default());
let peer = Peer::connect(cr, cw, client.clone());
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
let report = sync(&peer, &desktop, &["openidea.csv-editor", "openidea.kubernetes"]).await;
assert_eq!(report.installed.len(), 2, "{report:?}");
// CSV Editor: the document is the server's.
let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap();
let doc = opened["id"].as_u64().expect("doc id");
let args = |extra: Value| {
let mut a = json!({ "pluginId": "openidea.csv-editor", "view": "table", "doc": doc });
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
a
};
let view =
invoke(&peer, "plugin_view_render", args(json!({ "viewport": { "firstRow": 0, "rows": 50 } }))).await.unwrap();
assert_eq!(view["body"]["kind"], "table");
assert_eq!(view["body"]["value"]["rows"], json!([["Ada", "London"]]));
let cell = json!({ "event": { "kind": "cell", "row": 0, "column": 1, "value": "Oslo" } });
invoke(&peer, "plugin_view_event", args(cell)).await.unwrap();
let snap = invoke(&peer, "doc_snapshot", json!({ "doc": doc })).await.unwrap();
assert_eq!(snap["text"], "name,city\nAda,Oslo\n");
// Kubernetes: the server's kubectl; the terminal opens in this window.
let k8s =
|event: Value| json!({ "pluginId": "openidea.kubernetes", "view": "cluster", "doc": null, "event": event });
let view = invoke(
&peer,
"plugin_view_render",
json!({ "pluginId": "openidea.kubernetes", "view": "cluster", "doc": null }),
)
.await
.unwrap();
assert_eq!(view["body"]["value"][0]["label"], "remote-ns", "{view}");
invoke(&peer, "plugin_view_event", k8s(json!({ "kind": "select", "key": "remote-ns/pods/api-0" }))).await.unwrap();
invoke(&peer, "plugin_view_event", k8s(json!({ "kind": "button", "id": "logs" }))).await.unwrap();
until("the log terminal", 5, || {
client.plugin_events.lock().iter().any(|m| {
m.window.as_deref() == Some("main")
&& matches!(&m.event, PluginEvent::OpenTerminal { program, .. } if program.0.starts_with(bin.canonicalize().unwrap()))
})
})
.await;
plugins.host().shutdown();
}
+1
Переглянути файл
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
ide-extension.workspace = true
ide-text.workspace = true
serde.workspace = true
serde_json.workspace = true
+45 -3
Переглянути файл
@@ -8,16 +8,20 @@ use ide_core::{LanguageId, Range, SymbolKind};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
mod markers;
pub mod plugin_point;
mod registry;
mod syntax;
pub use markers::marked_root;
pub use plugin_point::PluginLanguages;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES,
MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats,
SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
WebReferenceKind, merge_visibility, set_external_syntax,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
@@ -138,6 +142,9 @@ pub struct LanguageServerConfig {
/// Register source files with tsserver when no project configuration exists.
#[serde(default)]
pub tsserver_project: bool,
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -157,6 +164,41 @@ pub struct DebuggerConfig {
pub connect: Option<String>,
#[serde(default)]
pub install_hint: Option<String>,
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
}
/// The program a plugin's `process:` grant approved (docs/plugin_plan.md 4.2):
/// 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.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProgramPin {
pub path: PathBuf,
/// blake3 of the file when approved.
pub hash: String,
}
impl ProgramPin {
/// Approves `path` as it is now.
pub fn approve(path: impl Into<PathBuf>) -> std::io::Result<Self> {
let path = path.into();
let hash = ide_extension::ledger::file_hash(&path)?;
Ok(Self { path, hash })
}
/// Checks the file right before it is started; the error tells the user what to do.
pub fn verify(&self) -> Result<&Path, String> {
match ide_extension::ledger::file_hash(&self.path) {
Ok(hash) if hash == self.hash => Ok(&self.path),
Ok(_) => Err(format!(
"{} changed since it was approved; review the plugin's permissions in Settings › Plugins",
self.path.display()
)),
Err(e) => Err(format!("{}: {e}", self.path.display())),
}
}
}
fn default_transport() -> String {
+313
Переглянути файл
@@ -0,0 +1,313 @@
//! The `languages` extension point: language definitions contributed by
//! plugins, in the same format as `languages/<id>.toml`.
//!
//! ```toml
//! [[contributes.languages]]
//! path = "languages/solidity.toml"
//! ```
//!
//! A definition that starts programs (`language-servers.command`,
//! `debugger.command`) needs `process:<program>` for each: a declaration is a
//! capability too, or registering languages would bypass the sandbox.
//! Committed definitions live in [`PluginLanguages`], an overlay over the
//! built-in registry (built-in definitions win on id clashes): see
//! [`crate::LanguageRegistry::attach_plugins`]. A server or debugger whose
//! program is not granted is left out; the language itself still works.
use crate::{LanguageConfig, ProgramPin};
use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode};
use parking_lot::RwLock;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
pub const POINT_ID: &str = "languages";
/// Decides whether a plugin may start a program: the approved file for a
/// granted `process:` capability, or `None`.
pub type ProcessPolicy = dyn Fn(&str, &Capability) -> Option<ProgramPin> + Send + Sync;
/// Languages of enabled plugins, in commit order.
#[derive(Default)]
pub struct PluginLanguages {
items: RwLock<Vec<(u64, String, LanguageConfig)>>,
seq: AtomicU64,
revision: AtomicU64,
policy: RwLock<Option<Arc<ProcessPolicy>>>,
}
impl PluginLanguages {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn all(&self) -> Vec<LanguageConfig> {
self.items.read().iter().map(|(_, _, c)| c.clone()).collect()
}
/// `(plugin id, definition)` pairs.
pub fn with_owners(&self) -> Vec<(String, LanguageConfig)> {
self.items.read().iter().map(|(_, p, c)| (p.clone(), c.clone())).collect()
}
/// The plugin that contributes language `id`.
pub fn owner_of(&self, id: &ide_core::LanguageId) -> Option<String> {
self.items.read().iter().find(|(_, _, c)| &c.id == id).map(|(_, p, _)| p.clone())
}
/// Changes whenever [`effective`](Self::effective) may give something else.
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
/// Who may start what. Without a policy no plugin program starts.
pub fn set_process_policy(&self, policy: Arc<ProcessPolicy>) {
*self.policy.write() = Some(policy);
self.invalidate();
}
/// Grants changed: registries recompute [`effective`](Self::effective).
pub fn invalidate(&self) {
self.revision.fetch_add(1, Ordering::Release);
}
/// The definitions as they take effect: servers and the debugger whose
/// program is granted are pinned to the approved file, the others are
/// left out.
pub fn effective(&self) -> Vec<LanguageConfig> {
let policy = self.policy.read().clone();
let pin = |plugin: &str, program: &str| {
policy.as_ref().and_then(|p| p(plugin, &Capability::Process(program.to_owned())))
};
self.items
.read()
.iter()
.map(|(_, plugin, cfg)| {
let mut cfg = cfg.clone();
cfg.language_servers.retain_mut(|s| {
s.pinned = pin(plugin, &s.command);
s.pinned.is_some()
});
let debugger_pin = cfg.debugger.as_ref().and_then(|d| pin(plugin, &d.command));
match (&mut cfg.debugger, debugger_pin) {
(Some(d), Some(p)) => d.pinned = Some(p),
(debugger, _) => *debugger = None,
}
cfg
})
.collect()
}
/// Adds a definition until the returned handle is dropped.
pub fn add(self: &Arc<Self>, plugin: String, cfg: LanguageConfig) -> Disposable {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, cfg));
self.revision.fetch_add(1, Ordering::Release);
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, _, _)| *s != seq);
this.revision.fetch_add(1, Ordering::Release);
}
})
}
}
pub struct LanguagesPoint {
store: Arc<PluginLanguages>,
}
impl LanguagesPoint {
pub fn new(store: Arc<PluginLanguages>) -> Self {
Self { store }
}
}
#[derive(Deserialize)]
#[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>,
}
/// Parses and checks one language definition.
pub fn parse_definition(src: &str) -> Result<LanguageConfig, String> {
let cfg: LanguageConfig = toml::from_str(src).map_err(|e| format!("invalid language definition: {e}"))?;
validate_definition(&cfg)?;
Ok(cfg)
}
/// Checks a definition that did not come from a package file (a remote sync).
pub fn validate_definition(cfg: &LanguageConfig) -> Result<(), String> {
let id = cfg.id.as_str();
if id.is_empty() || cfg.name.trim().is_empty() {
return Err("language `id` and `name` must not be empty".into());
}
if !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+')) || id.len() > 64 {
return Err(format!("invalid language id {id:?}"));
}
if let Some(first) = &cfg.first_line {
regex_lite::Regex::new(first).map_err(|e| format!("invalid first-line pattern: {e}"))?;
}
Ok(())
}
/// The programs a definition starts, as `process:` capabilities.
pub fn required_capabilities(cfg: &LanguageConfig) -> Vec<Capability> {
let mut caps: Vec<Capability> = Vec::new();
let servers = cfg.language_servers.iter().map(|s| s.command.as_str());
for program in servers.chain(cfg.debugger.iter().map(|d| d.command.as_str())) {
let cap = Capability::Process(program.to_owned());
if !program.trim().is_empty() && !caps.contains(&cap) {
caps.push(cap);
}
}
caps
}
impl ExtensionPoint for LanguagesPoint {
fn id(&self) -> &'static str {
POINT_ID
}
fn placement(&self) -> Placement {
Placement::Workspace
}
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 caps = required_capabilities(&cfg);
let (store, plugin, key, label) = (
self.store.clone(),
pkg.id().to_owned(),
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);
}
for cap in caps {
prepared = prepared.capability(cap);
}
Ok(prepared)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_extension::{ExtensionRegistry, Manifest, Scope};
#[test]
fn languages_point_registers_and_requires_process_capabilities() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("l")).unwrap();
std::fs::write(
tmp.path().join("l/sol.toml"),
"id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\
[[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\nargs = [\"--lsp\"]\n",
)
.unwrap();
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.languages]]\npath = \"l/sol.toml\"";
let pkg = Package::new(Manifest::parse(src).unwrap(), tmp.path().to_path_buf());
let store = PluginLanguages::new();
let reg = ExtensionRegistry::new();
reg.register(Arc::new(LanguagesPoint::new(store.clone()))).unwrap();
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();
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()
};
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"));
}
#[test]
fn registry_overlay_follows_the_store_and_the_grants() {
use crate::LanguageRegistry;
use ide_core::LanguageId;
use std::path::Path;
let base = LanguageRegistry::load(None).unwrap();
let store = PluginLanguages::new();
base.attach_plugins(store.clone());
// Projects fork before plugins load: the fork follows the store anyway.
let project = base.fork();
assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text());
let sol = parse_definition(
"id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\
[[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\n\
[debugger]\nadapter = \"soldbg\"\ncommand = \"soldbg\"\n",
)
.unwrap();
let rust_clash = parse_definition("id = \"rust\"\nname = \"Evil Rust\"\nextensions = [\"rs\"]").unwrap();
let ext_clash = parse_definition(
"id = \"rusty\"\nname = \"Rusty\"\nextensions = [\"rs\", \"rsy\"]\nfilenames = [\"Cargo.lock\"]",
)
.unwrap();
let _sol = store.add("acme.sol".into(), sol);
let _clash = store.add("acme.evil".into(), rust_clash);
let _ext = store.add("acme.evil".into(), ext_clash);
assert_eq!(project.detect(Path::new("a.sol"), None).as_str(), "solidity");
let rust = project.get(&LanguageId::new("rust")).unwrap();
assert_eq!(rust.name, "Rust", "built-in definitions win on id clashes");
assert_eq!(project.detect(Path::new("main.rs"), None).as_str(), "rust", "and keep their extensions");
assert_ne!(project.detect(Path::new("Cargo.lock"), None).as_str(), "rusty", "and file names");
assert_eq!(project.detect(Path::new("x.rsy"), None).as_str(), "rusty");
// No policy: the language works, its programs do not start.
let cfg = project.get(&LanguageId::new("solidity")).unwrap();
assert!(cfg.language_servers.is_empty() && cfg.debugger.is_none());
let pin = ProgramPin { path: "/opt/solc".into(), hash: "h".into() };
let granted = pin.clone();
store.set_process_policy(Arc::new(move |plugin, cap| {
(plugin == "acme.sol" && *cap == Capability::Process("solc".into())).then(|| granted.clone())
}));
let cfg = project.get(&LanguageId::new("solidity")).unwrap();
assert_eq!(cfg.language_servers[0].pinned.as_ref(), Some(&pin));
assert!(cfg.debugger.is_none(), "soldbg was not granted");
drop(_sol);
assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text());
assert!(base.get(&LanguageId::new("solidity")).is_none());
}
#[test]
fn program_pins_check_the_file() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("tool");
std::fs::write(&bin, "v1").unwrap();
let pin = ProgramPin { path: bin.clone(), hash: ide_extension::ledger::file_hash(&bin).unwrap() };
assert_eq!(pin.verify().unwrap(), bin);
std::fs::write(&bin, "v2").unwrap();
assert!(pin.verify().unwrap_err().contains("changed since it was approved"));
}
}
+95 -18
Переглянути файл
@@ -1,10 +1,11 @@
use crate::LanguageConfig;
use crate::plugin_point::PluginLanguages;
use ide_core::settings::{FileSettings, FileTypeAssociation};
use ide_core::{IdeError, IdeResult, LanguageId};
use parking_lot::RwLock;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
/// Built-in definitions, embedded at compile time. Add new languages here.
const BUILTIN: &[(&str, &str)] = &[
@@ -88,12 +89,22 @@ const BUILTIN: &[(&str, &str)] = &[
];
pub struct LanguageRegistry {
languages: Vec<LanguageConfig>,
/// Lookup tables of the definitions with the user's file types applied.
tables: RwLock<Arc<Tables>>,
/// Built-in definitions and the user's `languages/*.toml`.
base: Vec<LanguageConfig>,
/// Languages of enabled plugins, shared by every fork; `base` wins on id clashes.
plugins: OnceLock<Arc<PluginLanguages>>,
state: RwLock<Arc<State>>,
file_types: RwLock<FileTypes>,
}
/// The effective definitions and their lookup tables (the user's file types applied).
struct State {
languages: Vec<LanguageConfig>,
tables: Tables,
/// [`PluginLanguages::revision`] they were built from.
plugins_revision: u64,
}
/// The user's file type associations (Settings › Editor › File Types) and
/// per-file overrides (Override File Type), resolved for one project.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -247,12 +258,73 @@ impl LanguageRegistry {
}
}
let tables = Tables::build(&languages, &FileTypes::default());
Self { languages, tables: RwLock::new(Arc::new(tables)), file_types: RwLock::default() }
let state = State { languages: languages.clone(), tables, plugins_revision: 0 };
Self {
base: languages,
plugins: OnceLock::new(),
state: RwLock::new(Arc::new(state)),
file_types: RwLock::default(),
}
}
/// Adds the languages of enabled plugins (once, before projects fork the
/// registry). They follow the store: installing, disabling or changing a
/// grant takes effect on the next lookup in every fork.
pub fn attach_plugins(&self, plugins: Arc<PluginLanguages>) {
if self.plugins.set(plugins).is_err() {
tracing::warn!("plugin languages are already attached");
}
}
/// A registry with the same definitions and no user file types (one per project).
pub fn fork(&self) -> Self {
Self::from_configs(self.languages.clone())
let fork = Self::from_configs(self.base.clone());
if let Some(p) = self.plugins.get() {
let _ = fork.plugins.set(p.clone());
}
fork
}
/// The current effective state, rebuilt when the plugin languages changed.
fn state(&self) -> Arc<State> {
let Some(plugins) = self.plugins.get() else { return self.state.read().clone() };
let revision = plugins.revision();
{
let state = self.state.read();
if state.plugins_revision == revision {
return state.clone();
}
}
let file_types = self.file_types.read();
let mut state = self.state.write();
if state.plugins_revision != revision {
*state = Arc::new(self.build(&file_types, Some(revision)));
}
state.clone()
}
fn build(&self, file_types: &FileTypes, revision: Option<u64>) -> State {
let mut languages = self.base.clone();
let mut plugins_revision = 0;
if let Some(plugins) = self.plugins.get() {
plugins_revision = revision.unwrap_or_else(|| plugins.revision());
// A plugin adds languages; it can't take over a built-in or user
// definition's id, extensions or file names (File Types can).
let base_ext: std::collections::HashSet<String> =
self.base.iter().flat_map(|l| &l.extensions).map(|e| e.to_ascii_lowercase()).collect();
let base_names: std::collections::HashSet<&str> =
self.base.iter().flat_map(|l| &l.filenames).map(String::as_str).collect();
for mut cfg in plugins.effective() {
if languages.iter().any(|l| l.id == cfg.id) {
continue;
}
cfg.extensions.retain(|e| !base_ext.contains(&e.to_ascii_lowercase()));
cfg.filenames.retain(|n| !base_names.contains(n.as_str()));
languages.push(cfg);
}
}
let tables = Tables::build(&languages, file_types);
State { languages, tables, plugins_revision }
}
/// A copy with the same definitions and file types (to compare detection before / after a change).
@@ -268,17 +340,19 @@ impl LanguageRegistry {
if *current == file_types {
return false;
}
*self.tables.write() = Arc::new(Tables::build(&self.languages, &file_types));
let state = self.build(&file_types, None);
*self.state.write() = Arc::new(state);
*current = file_types;
true
}
/// Built-in, user and plugin definitions.
pub fn all(&self) -> Vec<LanguageConfig> {
self.languages.clone()
self.state().languages.clone()
}
pub fn get(&self, id: &LanguageId) -> Option<LanguageConfig> {
self.languages.iter().find(|l| &l.id == id).cloned()
self.state().languages.iter().find(|l| &l.id == id).cloned()
}
/// The `framework-ui-modes` mode of a `language` file at `path`: the first
@@ -286,7 +360,8 @@ impl LanguageRegistry {
/// directory without one: loose files). `None` keeps the language's own
/// `ui-mode`.
pub fn framework_ui_mode(&self, language: &LanguageId, path: &Path, project_root: Option<&Path>) -> Option<String> {
let cfg = self.languages.iter().find(|l| &l.id == language)?;
let state = self.state();
let cfg = state.languages.iter().find(|l| &l.id == language)?;
cfg.framework_ui_modes
.iter()
.find(|f| crate::marked_root(project_root, path, &f.root_markers).is_some())
@@ -296,7 +371,8 @@ impl LanguageRegistry {
/// Creating or removing `path` can change a file's `framework_ui_mode`:
/// it is a framework's root marker.
pub fn is_framework_marker(&self, path: &Path) -> bool {
self.languages
self.state()
.languages
.iter()
.flat_map(|l| &l.framework_ui_modes)
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
@@ -307,33 +383,34 @@ impl LanguageRegistry {
/// then hashbang / first line; falls back to plaintext. The user's patterns
/// win over the definitions'.
pub fn detect(&self, path: &Path, first_line: Option<&str>) -> LanguageId {
let t = self.tables.read().clone();
let state = self.state();
let (t, languages) = (&state.tables, &state.languages);
if !t.overrides.is_empty()
&& let Some(&i) = t.overrides.get(path)
{
return self.languages[i].id.clone();
return languages[i].id.clone();
}
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if let Some(&i) = t.by_name.get(file_name) {
return self.languages[i].id.clone();
return languages[i].id.clone();
}
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
return self.languages[*i].id.clone();
return languages[*i].id.clone();
}
let lower = file_name.to_ascii_lowercase();
let mut rest = lower.as_str();
while let Some(dot) = rest.find('.') {
rest = &rest[dot + 1..];
if let Some(&i) = t.by_ext.get(rest) {
return self.languages[i].id.clone();
return languages[i].id.clone();
}
}
if let Some(line) = first_line {
if let Some((_, i)) = t.hashbangs.iter().find(|(h, _)| hashbang_runs(line, h)) {
return self.languages[*i].id.clone();
return languages[*i].id.clone();
}
if let Some((_, i)) = t.first_line.iter().find(|(re, _)| re.is_match(line)) {
return self.languages[*i].id.clone();
return languages[*i].id.clone();
}
}
LanguageId::plain_text()
+3
Переглянути файл
@@ -197,6 +197,7 @@ impl SyntaxCache {
let query = g.outline_query()?;
Some(outline::outline(g, query, tree, text))
})
.or_else(|| super::external::outline(lang, text))
},
)
}
@@ -211,6 +212,7 @@ impl SyntaxCache {
self.with_tree(doc, lang, version, text, |g, tree, text| {
Some(folding::folding(g.spec, g.kinds(), tree, text, self.syntax.comment_tokens(lang)))
})
.or_else(|| super::external::folding(lang, text))
.or_else(|| Some(self.syntax.custom_folding(lang, text)))
},
)
@@ -227,6 +229,7 @@ impl SyntaxCache {
self.with_tree(doc, lang, version, text, |g, tree, text| {
Some(highlight::highlights(g.highlight_query()?, tree, text))
})
.or_else(|| super::external::highlights(lang, text))
},
))
}
+239
Переглянути файл
@@ -0,0 +1,239 @@
//! 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
//! 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
//! document version by the [`super::SyntaxCache`] like the grammars' answers.
use super::highlight::SyntaxHighlightKind;
use super::lines::LineIndex;
use crate::{FoldKind, FoldRange};
use ide_core::{LanguageId, OutlineItem, SymbolKind};
use parking_lot::RwLock;
use std::sync::Arc;
/// A highlighted range (UTF-8 byte offsets); `kind` is a kebab-case
/// [`SyntaxHighlightKind`] name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalToken {
pub start: u32,
pub end: u32,
pub kind: String,
}
/// A structure-view symbol (UTF-8 byte offsets); `parent` indexes an earlier
/// symbol of the same list.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalSymbol {
pub name: String,
pub detail: Option<String>,
pub kind: String,
pub start: u32,
pub end: u32,
pub name_start: u32,
pub name_end: u32,
pub parent: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalFold {
pub start_line: u32,
pub end_line: u32,
pub kind: String,
}
/// Answers for languages it provides, `None` for the others.
pub trait ExternalSyntax: Send + Sync {
fn highlights(&self, lang: &LanguageId, text: &str) -> Option<Vec<ExternalToken>>;
fn outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<ExternalSymbol>>;
fn folding(&self, lang: &LanguageId, text: &str) -> Option<Vec<ExternalFold>>;
}
static PROVIDER: RwLock<Option<Arc<dyn ExternalSyntax>>> = RwLock::new(None);
/// Installs (or removes) the provider every project's syntax cache consults.
pub fn set_external_syntax(provider: Option<Arc<dyn ExternalSyntax>>) {
*PROVIDER.write() = provider;
}
fn provider() -> Option<Arc<dyn ExternalSyntax>> {
PROVIDER.read().clone()
}
fn clamp(text: &str, offset: u32) -> usize {
let mut at = (offset as usize).min(text.len());
while !text.is_char_boundary(at) {
at -= 1;
}
at
}
/// `[from, to, kind]` triples in UTF-16 offsets, sorted and non-overlapping.
pub(crate) fn highlights(lang: &LanguageId, text: &str) -> Option<Vec<u32>> {
let mut tokens = provider()?.highlights(lang, text)?;
tokens.sort_by_key(|t| (t.start, t.end));
let lines = LineIndex::new(text);
let mut data = Vec::with_capacity(tokens.len() * 3);
let mut covered = 0usize;
for t in tokens {
let Some(kind) = SyntaxHighlightKind::from_name(&t.kind) else { continue };
let (start, end) = (clamp(text, t.start), clamp(text, t.end));
if end <= start || start < covered {
continue;
}
covered = end;
let index = SyntaxHighlightKind::ALL.iter().position(|k| *k == kind).unwrap_or_default() as u32;
data.extend([lines.utf16_at(start) as u32, lines.utf16_at(end) as u32, index]);
}
Some(data)
}
fn symbol_kind(name: &str) -> SymbolKind {
let camel: String = {
let mut out = String::with_capacity(name.len());
let mut upper = false;
for c in name.chars() {
if c == '-' {
upper = true;
} else if upper {
out.push(c.to_ascii_uppercase());
upper = false;
} else {
out.push(c);
}
}
out
};
serde_json::from_value(serde_json::Value::String(camel)).unwrap_or(SymbolKind::Unknown)
}
pub(crate) fn outline(lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
let symbols = provider()?.outline(lang, text)?;
let lines = LineIndex::new(text);
// Built bottom-up: children are attached to their parent (an earlier index).
let mut items: Vec<Option<OutlineItem>> = Vec::with_capacity(symbols.len());
let mut parents: Vec<Option<usize>> = Vec::with_capacity(symbols.len());
for (i, s) in symbols.iter().enumerate() {
let (start, end) = (clamp(text, s.start), clamp(text, s.end.max(s.start)));
let name_start = clamp(text, s.name_start).clamp(start, end);
let name_end = clamp(text, s.name_end).clamp(name_start, end);
items.push(Some(OutlineItem {
name: s.name.clone(),
detail: s.detail.clone(),
kind: symbol_kind(&s.kind),
range: lines.range(start, end),
selection_range: lines.range(name_start, name_end),
children: Vec::new(),
visibility: None,
}));
parents.push(s.parent.map(|p| p as usize).filter(|&p| p < i));
}
for i in (0..items.len()).rev() {
if let Some(p) = parents[i] {
let child = items[i].take().expect("each item is moved once");
if let Some(parent) = items[p].as_mut() {
parent.children.insert(0, child);
}
}
}
Some(items.into_iter().flatten().collect())
}
pub(crate) fn folding(lang: &LanguageId, text: &str) -> Option<Vec<FoldRange>> {
let folds = provider()?.folding(lang, text)?;
let last_line = LineIndex::new(text).line_of(text.len()) as u32;
let mut out: Vec<FoldRange> = folds
.into_iter()
.filter(|f| f.start_line < f.end_line && f.end_line <= last_line)
.map(|f| FoldRange {
start_line: f.start_line,
end_line: f.end_line,
kind: match f.kind.as_str() {
"comment" => FoldKind::Comment,
"imports" => FoldKind::Imports,
_ => FoldKind::Region,
},
})
.collect();
out.sort_by_key(|f| (f.start_line, std::cmp::Reverse(f.end_line)));
out.dedup_by_key(|f| (f.start_line, f.end_line));
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
struct Fake;
impl ExternalSyntax for Fake {
fn highlights(&self, lang: &LanguageId, _: &str) -> Option<Vec<ExternalToken>> {
(lang.as_str() == "fake").then(|| {
vec![
ExternalToken { start: 9, end: 12, kind: "keyword".into() },
// "é" is 2 bytes, 1 UTF-16 unit.
ExternalToken { start: 0, end: 6, kind: "string".into() },
ExternalToken { start: 2, end: 4, kind: "number".into() }, // overlaps: dropped
ExternalToken { start: 13, end: 14, kind: "nonsense".into() },
ExternalToken { start: 7, end: 99, kind: "doc-comment".into() },
]
})
}
fn outline(&self, lang: &LanguageId, _: &str) -> Option<Vec<ExternalSymbol>> {
let sym = |name: &str, kind: &str, start, end, parent| ExternalSymbol {
name: name.into(),
detail: None,
kind: kind.into(),
start,
end,
name_start: start,
name_end: start + 1,
parent,
};
(lang.as_str() == "fake").then(|| {
vec![
sym("C", "class", 0, 14, None),
sym("f", "function", 8, 12, Some(0)),
sym("g", "event", 12, 13, Some(0)),
sym("bad", "method", 1, 2, Some(9)),
]
})
}
fn folding(&self, lang: &LanguageId, _: &str) -> Option<Vec<ExternalFold>> {
(lang.as_str() == "fake").then(|| {
vec![
ExternalFold { start_line: 0, end_line: 1, kind: "comment".into() },
ExternalFold { start_line: 1, end_line: 1, kind: "region".into() },
ExternalFold { start_line: 0, end_line: 9, kind: "region".into() },
]
})
}
}
#[test]
fn converts_and_validates_provider_results() {
set_external_syntax(Some(Arc::new(Fake)));
let text = "\"héllo\"\nfn x(){}";
let lang = LanguageId::new("fake");
assert!(highlights(&LanguageId::new("other"), text).is_none());
let data = highlights(&lang, text).unwrap();
let string = SyntaxHighlightKind::ALL.iter().position(|k| *k == SyntaxHighlightKind::String).unwrap() as u32;
let doc = SyntaxHighlightKind::ALL.iter().position(|k| *k == SyntaxHighlightKind::DocComment).unwrap() as u32;
// UTF-16: `"héllo"` is 7 units (bytes 0..8 → 0..7); byte 6 is inside "o\"" → 5.
assert_eq!(&data[..3], &[0, 5, string]);
assert_eq!(&data[3..], &[6, 16, doc], "clamped to the text, keyword dropped as overlapping");
let outline = outline(&lang, text).unwrap();
assert_eq!(outline.len(), 2, "orphan with a bad parent stays top-level");
assert_eq!(outline[0].kind, SymbolKind::Class);
assert_eq!(
outline[0].children.iter().map(|c| c.kind).collect::<Vec<_>>(),
[SymbolKind::Function, SymbolKind::Event]
);
assert_eq!(outline[1].kind, SymbolKind::Method);
let folds = folding(&lang, text).unwrap();
assert_eq!(folds, [FoldRange { start_line: 0, end_line: 1, kind: FoldKind::Comment }]);
set_external_syntax(None);
}
}
+18
Переглянути файл
@@ -72,6 +72,24 @@ impl SyntaxHighlightKind {
Self::Operator,
Self::Punctuation,
];
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| {
let camel = serde_json::to_value(k).ok();
let camel = camel.as_ref().and_then(|v| v.as_str()).unwrap_or_default();
let mut kebab = String::with_capacity(camel.len() + 2);
for c in camel.chars() {
if c.is_ascii_uppercase() {
kebab.push('-');
kebab.push(c.to_ascii_lowercase());
} else {
kebab.push(c);
}
}
kebab == name
})
}
}
/// Highlighted ranges of a whole document, packed like LSP semantic tokens.
+2
Переглянути файл
@@ -2,6 +2,7 @@
mod cache;
mod configuration;
mod external;
mod folding;
mod grammars;
mod highlight;
@@ -14,6 +15,7 @@ mod web;
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
pub use configuration::ConfigurationKey;
pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, set_external_syntax};
use grammars::{SPECS, Spec};
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
use ide_core::{LanguageId, OutlineItem, Pos, Range};
+9 -2
Переглянути файл
@@ -96,8 +96,12 @@ impl Launcher for ProcessLauncher {
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
let search = server_search_path(config);
let binary = ide_core::tool_path::find_command(&config.command, &search, root)
.and_then(|b| resolve_rustup_proxy(&config.command, b));
let binary = match &config.pinned {
// A plugin's server: the approved program, unchanged since.
Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()),
None => ide_core::tool_path::find_command(&config.command, &search, root)
.and_then(|b| resolve_rustup_proxy(&config.command, b)),
};
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone())));
if let Some(addr) = &config.connect {
@@ -120,6 +124,9 @@ impl Launcher for ProcessLauncher {
}
fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool {
if let Some(pin) = &config.pinned {
return pin.path.is_file();
}
ide_core::tool_path::find_command(&config.command, &server_search_path(config), root).is_some()
}
}
+1
Переглянути файл
@@ -228,6 +228,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
max_java: None,
launch_error: None,
tsserver_project: false,
pinned: None,
}
}
+3
Переглянути файл
@@ -132,6 +132,7 @@ async fn missing_binary_is_reported_with_install_hint() {
max_java: None,
launch_error: None,
tsserver_project: false,
pinned: None,
};
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
let path = dir.path().join("a.nope");
@@ -398,6 +399,7 @@ async fn typescript_language_server_end_to_end() {
max_java: None,
launch_error: None,
tsserver_project: false,
pinned: None,
};
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
let doc = DocId::next();
@@ -444,6 +446,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
max_java: None,
launch_error: None,
tsserver_project: false,
pinned: None,
}
}
+8 -5
Переглянути файл
@@ -1,13 +1,14 @@
[package]
name = "ide-plugin"
description = "Sandboxed WebAssembly plugin host (wasmtime component model + WASI preview 2)"
description = "WebAssembly runtime for plugins (wasmtime component model + WASI preview 2)"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ide-core.workspace = true
ide-language.workspace = true
ide-extension.workspace = true
blake3.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
@@ -15,7 +16,9 @@ toml.workspace = true
thiserror.workspace = true
tracing.workspace = true
parking_lot.workspace = true
tempfile.workspace = true
zip.workspace = true
wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation"] }
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"] }
[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] }
+280
Переглянути файл
@@ -0,0 +1,280 @@
//! One thread per active plugin owning its [`Runtime`] (docs/plugin_plan.md 4.5).
//!
//! Callers queue jobs and wait on a reply channel instead of holding a lock
//! across the call, so an edit-path caller can stop waiting at its soft
//! deadline while the call runs on (and is interrupted at the hard deadline
//! by the store's epoch callback), and the call can be cancelled from any
//! thread. A trap poisons the instance: the actor drops it and ends; the
//! host's supervisor decides when to start a new one.
//!
//! wasmtime's async API would let one thread drive every instance, but needs
//! async host bindings throughout; with a plugin's instance only ever entered
//! by one job at a time a parked thread per active plugin costs little, and
//! idle reclaim ends it.
use crate::runtime::{CallCancel, CallError, Runtime};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
type Job = Box<dyn FnOnce(&mut Option<Runtime>) + Send>;
/// Why an [`Actor::call`] produced no result.
#[derive(Debug)]
pub enum ActorError {
/// The plugin's own outcome: an error it returned, or a trap (the actor has ended).
Call(CallError),
/// The caller's wait ran out; the result, if any, is dropped.
SoftTimeout,
/// The actor ended (an earlier call trapped, or it was stopped) before running the call.
Stopped,
/// The caller cancelled the call (`call_cancellable`).
Cancelled,
}
pub struct Actor {
tx: Mutex<Option<mpsc::Sender<Job>>>,
thread: Mutex<Option<JoinHandle<()>>>,
cancel: Arc<CallCancel>,
alive: Arc<AtomicBool>,
/// The trap that ended the actor, until someone takes it to report it.
crash: Arc<Mutex<Option<String>>>,
busy: Arc<AtomicBool>,
/// Milliseconds since `epoch` at the last call's end.
last_used: Arc<AtomicU64>,
epoch: Instant,
}
impl Actor {
/// Moves an activated runtime onto its own thread.
pub fn spawn(name: &str, runtime: Runtime) -> std::io::Result<Self> {
let (tx, rx) = mpsc::channel::<Job>();
let cancel = runtime.cancel_handle();
let alive = Arc::new(AtomicBool::new(true));
let busy = Arc::new(AtomicBool::new(false));
let last_used = Arc::new(AtomicU64::new(0));
let epoch = Instant::now();
let thread = {
let (alive, busy, last_used) = (alive.clone(), busy.clone(), last_used.clone());
std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || {
let mut slot = Some(runtime);
while let Ok(job) = rx.recv() {
busy.store(true, Ordering::Release);
job(&mut slot);
last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
busy.store(false, Ordering::Release);
if slot.is_none() {
break;
}
}
alive.store(false, Ordering::Release);
// Queued jobs are dropped with `rx`: their callers see `Stopped`.
drop(rx);
if let Some(mut rt) = slot {
rt.deactivate();
}
})?
};
Ok(Self {
tx: Mutex::new(Some(tx)),
thread: Mutex::new(Some(thread)),
cancel,
alive,
crash: Arc::default(),
busy,
last_used,
epoch,
})
}
/// Runs `f` on the plugin's thread. With `wait`, gives up after that long
/// (counting the time queued behind other calls) and the call, if it has
/// not started yet, is skipped. Without, waits for the outcome: the
/// runtime's own deadline bounds it.
pub fn call<R: Send + 'static>(
&self,
wait: Option<Duration>,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
let give_up = wait.map(|w| Instant::now() + w);
let (reply, result) = mpsc::sync_channel(1);
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let job: Job = Box::new(move |slot| {
if give_up.is_some_and(|t| Instant::now() >= t) {
return;
}
let Some(rt) = slot.as_mut() else { return };
let outcome = f(rt);
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
// Before the reply: the caller sees the actor dead.
alive.store(false, Ordering::Release);
*slot = None;
}
let _ = reply.send(outcome);
});
{
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) };
tx.send(job).map_err(|_| ActorError::Stopped)?;
}
let 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)
}
/// Like [`call`](Self::call) without a soft deadline, but `cancel` (the
/// caller's request was superseded) cancels the call cooperatively, and
/// interrupts it if it has not returned `grace` later. Returns
/// [`ActorError::Cancelled`] then.
pub fn call_cancellable<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
let (reply, result) = mpsc::sync_channel(1);
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let skip = cancel.clone();
let job: Job = Box::new(move |slot| {
if skip.is_cancelled() {
return;
}
let Some(rt) = slot.as_mut() else { return };
let outcome = f(rt);
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
*slot = None;
}
let _ = reply.send(outcome);
});
{
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) };
tx.send(job).map_err(|_| ActorError::Stopped)?;
}
let mut cancelled_at: Option<Instant> = None;
loop {
match result.recv_timeout(Duration::from_millis(5)) {
Ok(outcome) if cancelled_at.is_some() => {
drop(outcome);
return Err(ActorError::Cancelled);
}
Ok(outcome) => return outcome.map_err(ActorError::Call),
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped });
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if !cancel.is_cancelled() {
continue;
}
match cancelled_at {
None => {
cancelled_at = Some(Instant::now());
if self.is_busy() {
self.cancel.cancel_soft();
}
}
Some(at) if at.elapsed() >= grace => {
// Did not return in time: interrupt it (the instance is rebuilt).
self.cancel();
return Err(ActorError::Cancelled);
}
Some(_) => {}
}
}
}
/// Queues `f` without waiting; `done` gets the outcome on the actor's
/// thread (`Stopped` if the actor ended before running it).
pub fn post<R: Send + 'static>(
&self,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
done: impl FnOnce(Result<R, ActorError>) + Send + 'static,
) {
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let job: Job = Box::new(move |slot| {
let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) };
let outcome = f(rt);
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
*slot = None;
}
done(outcome.map_err(ActorError::Call));
});
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
// A job that can't be queued is dropped, and with it `done` (unrun):
// the caller sees the actor dead on its next look.
if let Some(tx) = tx.as_ref() {
let _ = tx.send(job);
}
}
/// Cooperatively cancels the running call (see [`call_cancellable`](Self::call_cancellable)).
pub fn cancel_soft(&self) {
if self.is_busy() {
self.cancel.cancel_soft();
}
}
/// Interrupts the running call (it fails as a trap: the instance is dropped).
pub fn cancel(&self) {
if self.busy.load(Ordering::Acquire) {
self.cancel.cancel();
}
}
/// The trap that ended this actor; `Some` only for the first caller, which
/// reports it (also when the caller that saw it had stopped waiting).
pub fn take_crash(&self) -> Option<String> {
self.crash.lock().unwrap_or_else(|e| e.into_inner()).take()
}
/// The runtime is still usable (no call trapped, not stopped).
pub fn is_alive(&self) -> bool {
self.alive.load(Ordering::Acquire)
}
pub fn is_busy(&self) -> bool {
self.busy.load(Ordering::Acquire)
}
/// Time since the last call ended (or since start).
pub fn idle_for(&self) -> Duration {
let last = Duration::from_millis(self.last_used.load(Ordering::Acquire));
self.epoch.elapsed().saturating_sub(last)
}
/// Deactivates the plugin and ends the thread after the queued calls.
/// With `join`, waits for that (bounded by the runtime's deadlines).
pub fn stop(&self, join: bool) {
self.tx.lock().unwrap_or_else(|e| e.into_inner()).take();
if join {
let thread = self.thread.lock().unwrap_or_else(|e| e.into_inner()).take();
if let Some(t) = thread
&& t.thread().id() != std::thread::current().id()
{
let _ = t.join();
}
}
}
}
impl Drop for Actor {
fn drop(&mut self) {
self.stop(false);
}
}
+15 -763
Переглянути файл
@@ -1,771 +1,23 @@
//! Sandboxed WebAssembly plugin host (Zed-style).
//! The WebAssembly runtime for plugins (wasmtime, component model, WASI
//! preview 2). Package handling, extension points and grants live in
//! `ide-extension`; the host that assembles them is `ide-host::plugins`.
//!
//! A plugin is a directory with a `plugin.toml` manifest ([`PluginManifest`])
//! and, optionally, a WebAssembly component implementing the
//! `openidea:plugin/[email protected]` world (`wit/plugin.wit`). Contribution-only
//! plugins (themes, languages) need no code.
//!
//! Each plugin with code runs in its own wasmtime `Store` with WASI preview 2
//! (no files, env, network), a fuel budget and wall-clock deadline per call,
//! and a linear-memory cap. A trap (panic, budget exhausted, permission
//! violation) drops the instance and marks the plugin `Failed` for the session;
//! the IDE keeps running.
//!
//! Sources: `<plugins_dir>/<id>/` (installed) and `<project>/.openidea/plugins/*/`
//! (dev plugins, loaded by [`PluginHost::sync_project`]).
//! Each plugin with code runs in its own wasmtime `Store`: no files, env or
//! network beyond what [`HostApi`] grants, a fuel budget and wall-clock
//! deadline per call, and a linear-memory cap. A trap (panic, budget
//! exhausted, permission violation, cancellation) poisons the instance; the
//! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread.
mod manifest;
mod package;
mod actor;
mod runtime;
mod sandbox;
mod theme;
mod types;
mod wit_host;
pub use manifest::{
API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest, ThemeDecl,
pub use actor::{Actor, ActorError};
pub use runtime::{
API_VERSION, CANCELLED, CallCancel, CallEffects, CallError, HostApi, PluginEngine, Runtime, RuntimeSetup,
};
pub use sandbox::resolve_in_root;
pub use theme::{ThemeDef, ThemeKind};
pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root};
pub use types::*;
use ide_core::{IdeError, IdeResult};
use ide_language::LanguageConfig;
use parking_lot::{Condvar, Mutex, RwLock};
use runtime::{CallEffects, CallError, PluginEngine, Registrations, Runtime, RuntimeSetup};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const STATE_FILE: &str = "state.json";
/// Top-level files copied with a package on install besides those the manifest references.
const LICENSE_FILES: &[&str] =
&["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING"];
/// Dev plugins live here, relative to a project root.
pub const DEV_PLUGINS_DIR: &str = ".openidea/plugins";
#[derive(Default, Serialize, Deserialize)]
struct PersistedState {
#[serde(default)]
disabled: BTreeSet<String>,
#[serde(default)]
theme: Option<ThemeRef>,
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
struct ThemeRef {
plugin: String,
theme: String,
/// The theme's window frame colour as the UI resolved it (`#rrggbb[aa]`), so
/// new windows open in it before the plugin is loaded.
#[serde(default, skip_serializing_if = "Option::is_none")]
frame: Option<String>,
}
struct Meta {
status: PluginStatus,
error: Option<String>,
}
struct LoadedPlugin {
manifest: PluginManifest,
dir: PathBuf,
source: PluginSource,
/// Project that owns a dev plugin.
dev_root: Option<PathBuf>,
/// Manifest contributions, restored on every (re)start.
static_regs: Registrations,
registrations: Arc<Mutex<Registrations>>,
meta: Mutex<Meta>,
runtime: Mutex<Option<Runtime>>,
}
impl LoadedPlugin {
fn is_live(&self) -> bool {
matches!(self.meta.lock().status, PluginStatus::Active | PluginStatus::Static)
}
fn set_failed(&self, error: String) {
let mut meta = self.meta.lock();
meta.status = PluginStatus::Failed;
meta.error = Some(error);
}
}
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
pub struct PluginHost {
project_policy: RwLock<ProjectPolicy>,
engine: PluginEngine,
plugins_dir: PathBuf,
sink: Arc<dyn PluginEventSink>,
limits: Limits,
plugins: RwLock<BTreeMap<String, Arc<LoadedPlugin>>>,
state: Mutex<PersistedState>,
/// client (window) → path of its focused editor.
active_files: Arc<Mutex<HashMap<String, String>>>,
/// Serializes loading / unloading so concurrent windows can't race.
lifecycle: Mutex<()>,
/// Set once the first [`load_all`](Self::load_all) has finished.
initial_load: (Mutex<bool>, Condvar),
}
impl PluginHost {
/// Creates the host; call [`load_all`](Self::load_all) to load installed plugins.
pub fn new(plugins_dir: PathBuf, sink: Arc<dyn PluginEventSink>, limits: Limits) -> IdeResult<Self> {
std::fs::create_dir_all(&plugins_dir)?;
let state = std::fs::read_to_string(plugins_dir.join(STATE_FILE))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Ok(Self {
project_policy: RwLock::new(Arc::new(|_| false)),
engine: PluginEngine::new().map_err(IdeError::internal)?,
plugins_dir,
sink,
limits,
plugins: RwLock::default(),
state: Mutex::new(state),
active_files: Arc::default(),
lifecycle: Mutex::new(()),
initial_load: (Mutex::new(false), Condvar::new()),
})
}
pub fn plugins_dir(&self) -> &Path {
&self.plugins_dir
}
/// Loads every package under the plugins directory (already loaded ones are kept).
pub fn load_all(&self) {
let _guard = self.lifecycle.lock();
for dir in package_dirs(&self.plugins_dir) {
if !self.is_loaded_from(&dir) {
self.load_dir(&dir, PluginSource::Installed, None);
}
}
let (done, cv) = &self.initial_load;
*done.lock() = true;
cv.notify_all();
self.emit(None, PluginEvent::CommandsChanged);
}
/// Waits (at most `timeout`) for the first [`load_all`](Self::load_all), so a
/// listing made at startup already holds the installed plugins and the
/// selected theme. Returns whether loading has finished.
pub fn wait_initial_load(&self, timeout: Duration) -> bool {
let (done, cv) = &self.initial_load;
let mut done = done.lock();
if !*done {
cv.wait_while_for(&mut done, |d| !*d, timeout);
}
*done
}
/// Loads new dev plugins from `<root>/.openidea/plugins/` and unloads vanished ones.
pub fn set_project_policy(&self, policy: ProjectPolicy) {
*self.project_policy.write() = policy;
}
pub fn restrict_project(&self, root: &Path) {
let _guard = self.lifecycle.lock();
let ids: Vec<_> = self
.plugins
.read()
.iter()
.filter(|(_, p)| p.dev_root.as_deref() == Some(root))
.map(|(id, _)| id.clone())
.collect();
for id in ids {
self.unload(&id);
}
self.emit(None, PluginEvent::CommandsChanged);
}
pub fn sync_project(&self, root: &Path) {
if !(self.project_policy.read())(root) {
self.restrict_project(root);
return;
}
let _guard = self.lifecycle.lock();
let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins"));
let stale: Vec<String> = self
.plugins
.read()
.iter()
.filter(|(_, p)| p.dev_root.as_deref() == Some(root) && !dirs.contains(&p.dir))
.map(|(id, _)| id.clone())
.collect();
let mut changed = !stale.is_empty();
for id in stale {
self.unload(&id);
}
for dir in dirs {
if !self.is_loaded_from(&dir) {
self.load_dir(&dir, PluginSource::Dev, Some(root.to_path_buf()));
changed = true;
}
}
if changed {
self.emit(None, PluginEvent::CommandsChanged);
}
}
/// Installed plugins plus the dev plugins of `project` (if any).
pub fn list(&self, project: Option<&Path>) -> PluginsSnapshot {
let mut plugins: Vec<PluginInfo> = self
.plugins
.read()
.values()
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
.map(|p| self.info(p))
.collect();
plugins.sort_by_key(|p| p.name.to_lowercase());
PluginsSnapshot { plugins, active_theme: self.active_theme() }
}
pub fn info_of(&self, id: &str) -> IdeResult<PluginInfo> {
Ok(self.info(&*self.get(id)?))
}
pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
{
let mut state = self.state.lock();
if enabled {
state.disabled.remove(id)
} else {
state.disabled.insert(id.to_owned())
};
}
self.save_state();
self.stop(&plugin);
if enabled {
self.start(&plugin);
} else {
plugin.meta.lock().status = PluginStatus::Disabled;
}
self.emit(None, PluginEvent::CommandsChanged);
self.emit_theme_if_owned(id);
Ok(())
}
/// Re-reads a plugin from disk (e.g. after rebuilding a dev plugin).
pub fn reload(&self, id: &str) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
let (dir, source, root) = (plugin.dir.clone(), plugin.source, plugin.dev_root.clone());
self.unload(id);
let new_id = self.load_dir(&dir, source, root);
self.emit(None, PluginEvent::CommandsChanged);
self.emit_theme_if_owned(&new_id);
self.info_of(&new_id)
}
/// Installs the package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder.
pub fn install_from_path(&self, path: &Path) -> IdeResult<PluginInfo> {
if package::is_zip(path) && path.is_file() {
// Outside the plugins directory: a crash must not leave a package that loads.
let unpacked = tempfile::Builder::new().prefix("openidea-plugin-").tempdir()?;
let root = package::unpack_zip(path, unpacked.path())?;
return self.install_from_dir(&root);
}
if path.file_name().is_some_and(|name| name == MANIFEST_FILE) && path.is_file() {
return self.install_from_dir(path.parent().unwrap_or(path));
}
self.install_from_dir(path)
}
/// Copies a plugin package (manifest + referenced files) into the plugins
/// directory, replacing an installed version with the same id.
pub fn install_from_dir(&self, src: &Path) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let manifest = PluginManifest::load(src).map_err(IdeError::invalid)?;
for file in manifest.files() {
if !src.join(file).is_file() {
return Err(IdeError::invalid(format!(
"{} is missing (referenced by plugin.toml); build the plugin first",
file.display()
)));
}
}
if let Some(wasm) = &manifest.wasm {
self.engine.compile(&src.join(wasm)).map_err(IdeError::invalid)?;
}
let id = manifest.id.clone();
if let Some(existing) = self.plugins.read().get(&id)
&& existing.source == PluginSource::Dev
{
return Err(IdeError::new(
ide_core::error::ErrorKind::AlreadyExists,
format!("a dev plugin with id {id:?} is loaded from {}", existing.dir.display()),
));
}
let target = self.plugins_dir.join(&id);
let same_dir = src.canonicalize().ok().zip(target.canonicalize().ok()).is_some_and(|(a, b)| a == b);
self.unload(&id);
if !same_dir {
let staging = self.plugins_dir.join(format!(".install-{id}"));
let _ = std::fs::remove_dir_all(&staging);
let copy = || -> std::io::Result<()> {
std::fs::create_dir_all(&staging)?;
std::fs::copy(src.join(MANIFEST_FILE), staging.join(MANIFEST_FILE))?;
for file in manifest.files() {
let to = staging.join(file);
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(src.join(file), to)?;
}
for name in LICENSE_FILES {
if src.join(name).is_file() {
std::fs::copy(src.join(name), staging.join(name))?;
}
}
if target.exists() {
std::fs::remove_dir_all(&target)?;
}
std::fs::rename(&staging, &target)
};
if let Err(e) = copy() {
let _ = std::fs::remove_dir_all(&staging);
return Err(e.into());
}
}
self.load_dir(&target, PluginSource::Installed, None);
self.emit(None, PluginEvent::CommandsChanged);
self.emit_theme_if_owned(&id);
self.info_of(&id)
}
pub fn uninstall(&self, id: &str) -> IdeResult<()> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
if plugin.source != PluginSource::Installed {
return Err(IdeError::invalid("dev plugins are removed by deleting them from .openidea/plugins"));
}
self.unload(id);
std::fs::remove_dir_all(&plugin.dir)?;
let theme_cleared = {
let mut state = self.state.lock();
state.disabled.remove(id);
let owned = state.theme.as_ref().is_some_and(|t| t.plugin == id);
if owned {
state.theme = None;
}
owned
};
self.save_state();
self.emit(None, PluginEvent::CommandsChanged);
if theme_cleared {
self.emit(None, PluginEvent::ThemeChanged { theme: None });
}
Ok(())
}
/// Runs a plugin command. Blocks for up to the configured time limit.
pub fn run_command(
&self,
id: &str,
command: &str,
arg: Option<&str>,
invocation: Invocation,
) -> IdeResult<Option<String>> {
let plugin = self.get(id)?;
let client = invocation.client.clone();
let mut runtime = plugin.runtime.lock();
let Some(rt) = runtime.as_mut() else {
let meta = plugin.meta.lock();
return Err(IdeError::unavailable(match meta.status {
PluginStatus::Disabled => format!("plugin {id:?} is disabled"),
PluginStatus::Static => format!("plugin {id:?} has no code to run commands"),
_ => format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed")),
}));
};
match rt.run_command(command, arg, invocation) {
Ok((value, fx)) => {
drop(runtime);
self.apply_effects(&plugin, fx);
Ok(value)
}
Err(CallError::Guest(e)) => Err(IdeError::internal(e)),
Err(CallError::Trap(e)) => {
*runtime = None;
drop(runtime);
self.fail(&plugin, &e, client.as_deref());
Err(IdeError::internal(format!("plugin {id:?} {e}")))
}
}
}
/// The UI reports which file its focused editor shows (for `active-file`).
pub fn set_active_file(&self, client: &str, path: Option<String>) {
let mut files = self.active_files.lock();
match path {
Some(p) => files.insert(client.to_owned(), p),
None => files.remove(client),
};
}
/// Selects a plugin theme (`None` restores the built-in theme).
pub fn set_theme(&self, selection: Option<(&str, &str)>) -> IdeResult<Option<ActiveTheme>> {
if let Some((plugin, theme)) = selection {
let p = self.get(plugin)?;
if !p.registrations.lock().themes.iter().any(|t| t.id == theme) {
return Err(IdeError::not_found(format!("plugin {plugin:?} has no theme {theme:?}")));
}
}
self.state.lock().theme =
selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into(), frame: None });
self.save_state();
let active = self.active_theme();
self.emit(None, PluginEvent::ThemeChanged { theme: active.clone() });
Ok(active)
}
/// Records the selected theme's resolved window frame colour (`#rgb`,
/// `#rrggbb` or `#rrggbbaa`); ignored without a selected theme.
pub fn set_theme_frame(&self, frame: Option<&str>) -> IdeResult<()> {
if let Some(f) = frame
&& !is_hex_color(f)
{
return Err(IdeError::invalid(format!("not a hex colour: {f:?}")));
}
let changed = match self.state.lock().theme.as_mut() {
Some(t) if t.frame.as_deref() != frame => {
t.frame = frame.map(str::to_owned);
true
}
_ => false,
};
if changed {
self.save_state();
}
Ok(())
}
/// Frame colour for a new window: the recorded one of the selected theme,
/// unless its plugin is disabled. Available before plugins are loaded.
pub fn startup_frame(&self) -> Option<String> {
let state = self.state.lock();
let t = state.theme.as_ref()?;
if state.disabled.contains(&t.plugin) {
return None;
}
t.frame.clone()
}
/// The selected plugin theme, when its plugin is enabled and running.
pub fn active_theme(&self) -> Option<ActiveTheme> {
let sel = self.state.lock().theme.clone()?;
let plugin = self.plugins.read().get(&sel.plugin).cloned()?;
if !plugin.is_live() {
return None;
}
let theme = plugin.registrations.lock().themes.iter().find(|t| t.id == sel.theme).cloned()?;
Some(ActiveTheme { plugin_id: sel.plugin, theme })
}
/// Languages contributed by live plugins (for runtime language registration).
pub fn languages(&self) -> Vec<LanguageConfig> {
self.plugins
.read()
.values()
.filter(|p| p.is_live())
.flat_map(|p| p.registrations.lock().languages.clone())
.collect()
}
/// Deactivates every plugin (also done on drop).
pub fn shutdown(&self) {
for plugin in self.plugins.read().values() {
if let Some(mut rt) = plugin.runtime.lock().take() {
rt.deactivate();
}
}
}
// ---- internals ----
fn get(&self, id: &str) -> IdeResult<Arc<LoadedPlugin>> {
let plugin =
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?;
if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) {
return Err(IdeError::unavailable("Project is in Restricted Mode"));
}
Ok(plugin)
}
fn is_loaded_from(&self, dir: &Path) -> bool {
self.plugins.read().values().any(|p| p.dir == dir)
}
/// Loads one package and starts it unless disabled. Returns the plugin id.
fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option<PathBuf>) -> String {
let (manifest, static_regs, error) = match PluginManifest::load(dir) {
Ok(m) => match static_contributions(dir, &m) {
Ok(regs) => (m, regs, None),
Err(e) => (m, Registrations::default(), Some(e)),
},
Err(e) => (placeholder_manifest(dir), Registrations::default(), Some(e)),
};
let id = manifest.id.clone();
let plugin = Arc::new(LoadedPlugin {
manifest,
dir: dir.to_path_buf(),
source,
dev_root,
registrations: Arc::new(Mutex::new(static_regs.clone())),
static_regs,
meta: Mutex::new(Meta { status: PluginStatus::Disabled, error: None }),
runtime: Mutex::new(None),
});
{
let mut plugins = self.plugins.write();
if let Some(existing) = plugins.get(&id) {
tracing::warn!(
"plugin {id:?} at {} ignored: already loaded from {}",
dir.display(),
existing.dir.display()
);
return id;
}
plugins.insert(id.clone(), plugin.clone());
}
if let Some(e) = error {
tracing::warn!("plugin {}: {e}", dir.display());
plugin.set_failed(e);
} else if !self.state.lock().disabled.contains(&id) {
self.start(&plugin);
}
id
}
fn unload(&self, id: &str) {
let Some(plugin) = self.plugins.write().remove(id) else { return };
self.stop(&plugin);
}
fn start(&self, plugin: &Arc<LoadedPlugin>) {
*plugin.registrations.lock() = plugin.static_regs.clone();
let Some(wasm) = &plugin.manifest.wasm else {
*plugin.meta.lock() = Meta { status: PluginStatus::Static, error: None };
return;
};
let started = self.engine.compile(&plugin.dir.join(wasm)).and_then(|component| {
let active_files = self.active_files.clone();
let setup = RuntimeSetup {
plugin_id: plugin.manifest.id.clone(),
version: plugin.manifest.version.clone(),
permissions: plugin.manifest.permissions.clone(),
registrations: plugin.registrations.clone(),
sink: self.sink.clone(),
active_file: Arc::new(move |client| client.and_then(|c| active_files.lock().get(c).cloned())),
limits: self.limits,
};
let mut rt = Runtime::instantiate(&self.engine, &component, setup)?;
match rt.activate() {
Ok(fx) => Ok((rt, fx)),
Err(CallError::Guest(e)) => Err(format!("activation failed: {e}")),
Err(CallError::Trap(e)) => Err(format!("activation {e}")),
}
});
match started {
Ok((rt, _fx)) => {
*plugin.runtime.lock() = Some(rt);
*plugin.meta.lock() = Meta { status: PluginStatus::Active, error: None };
}
Err(e) => self.fail(plugin, &e, None),
}
}
fn stop(&self, plugin: &LoadedPlugin) {
if let Some(mut rt) = plugin.runtime.lock().take() {
rt.deactivate();
}
}
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
tracing::warn!(plugin = %plugin.manifest.id, "disabled after failure: {error}");
plugin.set_failed(error.to_owned());
self.emit(
client,
PluginEvent::Notification {
plugin_id: plugin.manifest.id.clone(),
level: NotificationLevel::Error,
title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest.name),
body: error.to_owned(),
},
);
self.emit(None, PluginEvent::CommandsChanged);
self.emit_theme_if_owned(&plugin.manifest.id);
}
fn apply_effects(&self, plugin: &LoadedPlugin, fx: CallEffects) {
if fx.commands_changed {
self.emit(None, PluginEvent::CommandsChanged);
}
let sel = self.state.lock().theme.clone();
if let Some(sel) = sel
&& sel.plugin == plugin.manifest.id
&& fx.themes_changed.contains(&sel.theme)
{
self.emit(None, PluginEvent::ThemeChanged { theme: self.active_theme() });
}
}
fn emit_theme_if_owned(&self, id: &str) {
let owned = self.state.lock().theme.as_ref().is_some_and(|t| t.plugin == id);
if owned {
self.emit(None, PluginEvent::ThemeChanged { theme: self.active_theme() });
}
}
fn emit(&self, client: Option<&str>, event: PluginEvent) {
self.sink.emit(client, event);
}
fn save_state(&self) {
let json = serde_json::to_string_pretty(&*self.state.lock()).unwrap_or_default();
if let Err(e) = std::fs::write(self.plugins_dir.join(STATE_FILE), json) {
tracing::warn!("saving plugin state: {e}");
}
}
fn info(&self, p: &LoadedPlugin) -> PluginInfo {
let m = &p.manifest;
let regs = p.registrations.lock().clone();
let meta = p.meta.lock();
PluginInfo {
id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
authors: m.authors.clone(),
homepage: m.homepage.clone(),
change_notes: m.change_notes.clone(),
source: p.source,
path: p.dir.clone().into(),
enabled: !self.state.lock().disabled.contains(&m.id),
status: meta.status,
error: meta.error.clone(),
has_code: m.wasm.is_some(),
permissions: m.permissions.clone(),
commands: regs.commands,
languages: regs
.languages
.iter()
.map(|l| PluginLanguage {
id: l.id.to_string(),
name: l.name.clone(),
extensions: l.extensions.clone(),
})
.collect(),
themes: regs
.themes
.iter()
.map(|t| PluginTheme { id: t.id.clone(), name: t.name.clone(), kind: t.kind })
.collect(),
}
}
}
impl Drop for PluginHost {
fn drop(&mut self) {
self.shutdown();
}
}
fn is_hex_color(s: &str) -> bool {
s.strip_prefix('#').is_some_and(|h| matches!(h.len(), 3 | 6 | 8) && h.bytes().all(|b| b.is_ascii_hexdigit()))
}
/// Plugin packages found in `dirs` (first occurrence of an id wins). Invalid
/// manifests are skipped; packages with missing files carry a `problem`.
pub fn scan_catalog(dirs: &[PathBuf]) -> Vec<CatalogPlugin> {
let mut out: Vec<CatalogPlugin> = Vec::new();
for dir in dirs.iter().flat_map(|d| package_dirs(d)) {
let Ok(m) = PluginManifest::load(&dir) else { continue };
if out.iter().any(|c| c.id == m.id) {
continue;
}
let problem = m
.files()
.into_iter()
.find(|f| !dir.join(f).is_file())
.map(|f| format!("{} is missing; build the plugin first", f.display()));
out.push(CatalogPlugin {
has_code: m.wasm.is_some(),
language_count: m.contributes.languages.len() as u32,
theme_count: m.contributes.themes.len() as u32,
commands: m.contributes.commands.clone(),
permissions: m.permissions.clone(),
id: m.id,
name: m.name,
version: m.version,
description: m.description,
authors: m.authors,
homepage: m.homepage,
change_notes: m.change_notes,
path: dir.into(),
problem,
});
}
out.sort_by_key(|a| a.name.to_lowercase());
out
}
/// Subdirectories of `dir` that contain a `plugin.toml`, sorted.
fn package_dirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
let mut dirs: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.join(MANIFEST_FILE).is_file()
&& !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))
})
.collect();
dirs.sort();
dirs
}
fn static_contributions(dir: &Path, m: &PluginManifest) -> Result<Registrations, String> {
let mut regs = Registrations::default();
for cmd in &m.contributes.commands {
regs.add_command(cmd.clone());
}
let read = |p: &Path| std::fs::read_to_string(dir.join(p)).map_err(|e| format!("{}: {e}", p.display()));
for f in &m.contributes.languages {
let cfg = runtime::parse_language(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
regs.add_language(cfg);
}
for f in &m.contributes.themes {
let mut theme = ThemeDef::parse(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
if let Some(scheme) = &f.editor_scheme {
theme.merge_editor_scheme(&read(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?;
}
regs.add_theme(theme);
}
Ok(regs)
}
/// Stand-in for a package whose manifest failed to parse, so it still shows up (as failed).
fn placeholder_manifest(dir: &Path) -> PluginManifest {
let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
PluginManifest {
id: format!("invalid-{}", name.to_lowercase().replace(|c: char| !c.is_ascii_alphanumeric(), "-")),
name,
version: "0.0.0".into(),
description: String::new(),
authors: Vec::new(),
homepage: None,
change_notes: None,
openidea_api: API_VERSION.into(),
wasm: None,
permissions: Vec::new(),
contributes: Contributions::default(),
}
}
pub use wasmtime::component::Component;
-279
Переглянути файл
@@ -1,279 +0,0 @@
//! `plugin.toml`: the plugin package manifest.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Component, Path, PathBuf};
/// Plugin API version implemented by this host (`openidea-api` in the manifest).
pub const API_VERSION: &str = "0.1";
pub const MANIFEST_FILE: &str = "plugin.toml";
/// Capabilities a plugin must declare to use the gated host functions.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum Permission {
/// `workspace-root`, `read-file` (inside the project root only).
ProjectRead,
/// `active-file`.
EditorRead,
/// `insert-text`, `replace-selection`.
EditorWrite,
}
impl Permission {
pub fn as_str(self) -> &'static str {
match self {
Permission::ProjectRead => "project-read",
Permission::EditorRead => "editor-read",
Permission::EditorWrite => "editor-write",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct PluginManifest {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub homepage: Option<String>,
/// Release notes of this version (Settings › Plugins › What's New).
#[serde(default)]
pub change_notes: Option<String>,
pub openidea_api: String,
/// Component file relative to the package directory; absent for
/// contribution-only plugins (themes, languages).
#[serde(default)]
pub wasm: Option<PathBuf>,
#[serde(default)]
pub permissions: Vec<Permission>,
#[serde(default)]
pub contributes: Contributions,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct Contributions {
#[serde(default)]
pub commands: Vec<CommandDecl>,
#[serde(default)]
pub languages: Vec<FileDecl>,
#[serde(default)]
pub themes: Vec<ThemeDecl>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommandDecl {
pub id: String,
pub title: String,
#[serde(default)]
pub category: Option<String>,
}
/// A contributed file, relative to the package directory.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileDecl {
pub path: PathBuf,
}
/// A contributed theme: the UI theme file plus an optional editor colour scheme
/// whose tokens are merged over it (IntelliJ's `.theme.json` + `.icls`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ThemeDecl {
pub path: PathBuf,
#[serde(default)]
pub editor_scheme: Option<PathBuf>,
}
impl PluginManifest {
pub fn parse(src: &str) -> Result<Self, String> {
let manifest: Self = toml::from_str(src).map_err(|e| e.to_string())?;
manifest.validate()?;
Ok(manifest)
}
pub fn load(dir: &Path) -> Result<Self, String> {
let path = dir.join(MANIFEST_FILE);
let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
}
pub fn validate(&self) -> Result<(), String> {
validate_plugin_id(&self.id)?;
if self.name.trim().is_empty() {
return Err("`name` must not be empty".into());
}
if !is_version(&self.version) {
return Err(format!("`version` must look like 1.2.3, got {:?}", self.version));
}
if !api_compatible(&self.openidea_api) {
return Err(format!(
"unsupported openidea-api {:?} (this IDE implements {API_VERSION})",
self.openidea_api
));
}
if let Some(wasm) = &self.wasm {
check_relative(wasm, "wasm")?;
}
let mut seen = std::collections::HashSet::new();
for c in &self.contributes.commands {
validate_command_id(&c.id)?;
if !seen.insert(c.id.as_str()) {
return Err(format!("duplicate command id {:?}", c.id));
}
if c.title.trim().is_empty() {
return Err(format!("command {:?} has an empty title", c.id));
}
}
let theme_files = self.contributes.themes.iter().flat_map(|t| std::iter::once(&t.path).chain(&t.editor_scheme));
for path in self.contributes.languages.iter().map(|f| &f.path).chain(theme_files) {
check_relative(path, "contributes path")?;
}
Ok(())
}
/// Every file of the package besides the manifest (what an install copies).
pub fn files(&self) -> Vec<&Path> {
let mut files: Vec<&Path> = self.wasm.iter().map(PathBuf::as_path).collect();
files.extend(self.contributes.languages.iter().map(|f| f.path.as_path()));
for t in &self.contributes.themes {
files.push(t.path.as_path());
files.extend(t.editor_scheme.as_deref());
}
files
}
pub fn has(&self, p: Permission) -> bool {
self.permissions.contains(&p)
}
}
/// `[a-z0-9][a-z0-9_-]*`, at most 64 chars (no dots: it is one segment of an action id).
pub fn validate_plugin_id(id: &str) -> Result<(), String> {
validate_ident(id, "plugin id", false)
}
/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars.
pub fn validate_command_id(id: &str) -> Result<(), String> {
validate_ident(id, "command id", true)
}
fn validate_ident(id: &str, what: &str, dots: bool) -> Result<(), String> {
let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
let ok_rest =
id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_' || (dots && c == '.'));
if ok_first && ok_rest && id.len() <= 64 {
Ok(())
} else {
let allowed = if dots { "a-z, 0-9, '.', '-', '_'" } else { "a-z, 0-9, '-', '_'" };
Err(format!("invalid {what} {id:?}: use {allowed}, starting with a letter or digit (max 64)"))
}
}
fn is_version(v: &str) -> bool {
let core = v.split(['-', '+']).next().unwrap_or_default();
let parts: Vec<&str> = core.split('.').collect();
parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}
/// `0.1` and `0.1.x` are compatible with API 0.1 (pre-1.0: minor is breaking).
fn api_compatible(v: &str) -> bool {
v == API_VERSION || v.strip_prefix(API_VERSION).is_some_and(|rest| rest.starts_with('.'))
}
/// Package paths must stay inside the package directory.
fn check_relative(path: &Path, what: &str) -> Result<(), String> {
let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)));
if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) }
}
#[cfg(test)]
mod tests {
use super::*;
const FULL: &str = r#"
id = "hello"
name = "Hello"
version = "0.1.0"
description = "Says hello"
authors = ["A"]
openidea-api = "0.1"
wasm = "dist/plugin.wasm"
permissions = ["project-read", "editor-write"]
[[contributes.commands]]
id = "say.hi"
title = "Say Hi"
category = "Hello"
[[contributes.themes]]
path = "themes/dark.json"
editor-scheme = "themes/dark.scheme.json"
"#;
#[test]
fn parses_full_manifest() {
let m = PluginManifest::parse(FULL).unwrap();
assert_eq!(m.id, "hello");
assert_eq!(m.permissions, vec![Permission::ProjectRead, Permission::EditorWrite]);
assert_eq!(m.contributes.commands[0].category.as_deref(), Some("Hello"));
assert_eq!(
m.files(),
vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json"), Path::new("themes/dark.scheme.json")]
);
assert!(m.has(Permission::EditorWrite) && !m.has(Permission::EditorRead));
}
#[test]
fn minimal_contribution_only_manifest() {
let m =
PluginManifest::parse("id = \"t\"\nname = \"T\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1.3\"").unwrap();
assert!(m.wasm.is_none() && m.permissions.is_empty());
}
fn err(src: &str) -> String {
PluginManifest::parse(src).unwrap_err()
}
#[test]
fn rejects_invalid_manifests() {
let base = |extra: &str, id: &str, api: &str| {
format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nopenidea-api = \"{api}\"\n{extra}")
};
assert!(err(&base("", "Bad.Id", "0.1")).contains("invalid plugin id"));
assert!(err(&base("", "ok", "0.2")).contains("unsupported openidea-api"));
assert!(err(&base("", "ok", "0.10")).contains("unsupported openidea-api"));
assert!(err(&base("permissions = [\"network\"]", "ok", "0.1")).contains("unknown variant"));
assert!(err(&base("wasm = \"../evil.wasm\"", "ok", "0.1")).contains("relative path"));
assert!(err(&base("wasm = \"/abs.wasm\"", "ok", "0.1")).contains("relative path"));
assert!(err(&base("bogus = 1", "ok", "0.1")).contains("unknown field"));
let dup =
"[[contributes.commands]]\nid = \"a\"\ntitle = \"A\"\n[[contributes.commands]]\nid = \"a\"\ntitle = \"B\"";
assert!(err(&base(dup, "ok", "0.1")).contains("duplicate command"));
let traversal = "[[contributes.languages]]\npath = \"a/../../x.toml\"";
assert!(err(&base(traversal, "ok", "0.1")).contains("relative path"));
let scheme_escape = "[[contributes.themes]]\npath = \"t.json\"\neditor-scheme = \"../s.json\"";
assert!(err(&base(scheme_escape, "ok", "0.1")).contains("relative path"));
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nopenidea-api = \"0.1\"").contains("version"));
assert!(err("id = \"ok\"\nname = \" \"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"").contains("name"));
}
#[test]
fn ident_rules() {
assert!(validate_command_id("word-count.v2_x").is_ok());
assert!(validate_command_id("-x").is_err());
assert!(validate_command_id("").is_err());
assert!(validate_command_id(&"a".repeat(65)).is_err());
assert!(validate_plugin_id("a.b").is_err());
assert!(is_version("1.2.3-beta.1+build"));
assert!(!is_version("1.2.x"));
}
}
-74
Переглянути файл
@@ -1,74 +0,0 @@
//! Plugin packages on disk: a folder, its `plugin.toml`, or a `.zip` of the folder.
use crate::MANIFEST_FILE;
use ide_core::{IdeError, IdeResult};
use std::fs::{self, File};
use std::io::{self, BufReader, Read};
use std::path::{Path, PathBuf};
/// Far above any real package; stops a zip bomb before it fills the disk.
const MAX_ENTRIES: usize = 10_000;
const MAX_UNPACKED_BYTES: u64 = 1024 * 1024 * 1024;
pub(crate) fn is_zip(path: &Path) -> bool {
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"))
}
fn invalid_zip(zip: &Path, reason: impl std::fmt::Display) -> IdeError {
IdeError::invalid(format!("{} is not a valid plugin archive: {reason}", zip.display()))
}
/// Unpacks `zip` into `dest` and returns the package folder inside it. Refuses the
/// whole archive when an entry would leave `dest` or is a symbolic link.
pub(crate) fn unpack_zip(zip: &Path, dest: &Path) -> IdeResult<PathBuf> {
let mut archive = zip::ZipArchive::new(BufReader::new(File::open(zip)?)).map_err(|e| invalid_zip(zip, e))?;
if archive.len() > MAX_ENTRIES {
return Err(invalid_zip(zip, format!("more than {MAX_ENTRIES} entries")));
}
let mut budget = MAX_UNPACKED_BYTES;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| invalid_zip(zip, e))?;
let name = entry.name().to_owned();
let Some(rel) = entry.enclosed_name() else {
return Err(invalid_zip(zip, format!("unsafe path {name:?}")));
};
if entry.is_symlink() {
return Err(invalid_zip(zip, format!("symbolic link {name:?}")));
}
let out = dest.join(rel);
if entry.is_dir() {
fs::create_dir_all(&out)?;
continue;
}
if let Some(parent) = out.parent() {
fs::create_dir_all(parent)?;
}
// One byte over the budget tells a full budget from a file that exactly fits.
let written =
io::copy(&mut (&mut entry).take(budget + 1), &mut File::create(&out)?).map_err(|e| invalid_zip(zip, e))?;
if written > budget {
return Err(invalid_zip(zip, "it unpacks to more than 1 GB"));
}
budget -= written;
}
package_root(dest).ok_or_else(|| {
IdeError::invalid(format!(
"{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder",
zip.display()
))
})
}
/// `dir` itself, or its only folder as when a zip holds the package folder.
/// Archiver metadata (`__MACOSX`, dot files) does not count as a second folder.
fn package_root(dir: &Path) -> Option<PathBuf> {
if dir.join(MANIFEST_FILE).is_file() {
return Some(dir.to_path_buf());
}
let mut folders = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| {
let name = p.file_name().unwrap_or_default().to_string_lossy();
p.is_dir() && !name.starts_with('.') && name != "__MACOSX"
});
let only = folders.next().filter(|_| folders.next().is_none())?;
only.join(MANIFEST_FILE).is_file().then_some(only)
}
File diff suppressed because it is too large. Load diff
+437 -138
Переглянути файл
@@ -1,11 +1,8 @@
//! UI-facing types and the event stream.
//! Types shared by the runtime and the host.
use crate::manifest::{CommandDecl, Permission};
use crate::theme::{ThemeDef, ThemeKind};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -16,156 +13,458 @@ pub enum NotificationLevel {
Error,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PluginSource {
/// Installed under `<app-data>/plugins/<id>/`.
Installed,
/// Loaded from `<project>/.openidea/plugins/<dir>/` while that project is open.
Dev,
/// The focused editor of the window a command came from.
#[derive(Clone, Debug, Default)]
pub struct EditorContext {
pub path: PathBuf,
pub language: String,
/// Selections when the command was invoked (cursors are empty ranges).
pub selections: Vec<ide_core::Range>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PluginStatus {
/// Code loaded and activated.
Active,
/// No code; static contributions only.
Static,
Disabled,
/// Failed to load, or trapped and was disabled for this session (see `error`).
Failed,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginLanguage {
pub id: String,
pub name: String,
pub extensions: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginTheme {
pub id: String,
pub name: String,
pub kind: ThemeKind,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
pub source: PluginSource,
/// Package directory.
pub path: ide_core::IpcPath,
pub enabled: bool,
pub status: PluginStatus,
pub error: Option<String>,
pub has_code: bool,
pub permissions: Vec<Permission>,
/// Commands contributed while enabled (manifest + registered at runtime).
pub commands: Vec<CommandDecl>,
pub languages: Vec<PluginLanguage>,
pub themes: Vec<PluginTheme>,
}
/// A plugin package available for installation from a local catalog
/// directory (the samples shipped with the IDE). Not installed by listing it.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CatalogPlugin {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
/// Package directory; install with `install_from_path(path)`.
pub path: ide_core::IpcPath,
pub has_code: bool,
pub permissions: Vec<Permission>,
pub commands: Vec<CommandDecl>,
pub language_count: u32,
pub theme_count: u32,
/// Why the package cannot be installed as is (e.g. its WASM is not built).
pub problem: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ActiveTheme {
pub plugin_id: String,
pub theme: ThemeDef,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginsSnapshot {
pub plugins: Vec<PluginInfo>,
pub active_theme: Option<ActiveTheme>,
}
/// Backend → UI notifications (Tauri event `ide://plugin`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PluginEvent {
/// Plugins, their status or their commands changed: re-fetch `plugin_list`.
CommandsChanged,
#[serde(rename_all = "camelCase")]
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
#[serde(rename_all = "camelCase")]
InsertText { plugin_id: String, text: String },
#[serde(rename_all = "camelCase")]
ReplaceSelection { plugin_id: String, text: String },
#[serde(rename_all = "camelCase")]
ThemeChanged { theme: Option<ActiveTheme> },
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
/// (`None` = broadcast, e.g. during activation).
pub trait PluginEventSink: Send + Sync {
fn emit(&self, client: Option<&str>, event: PluginEvent);
}
impl<F: Fn(Option<&str>, PluginEvent) + Send + Sync> PluginEventSink for F {
fn emit(&self, client: Option<&str>, event: PluginEvent) {
self(client, event)
}
}
pub type BufferText = Arc<dyn Fn(&Path) -> Option<String> + Send + Sync>;
/// Where a command runs: the invoking window and its project.
#[derive(Clone, Default)]
/// Where a command or event batch comes from: the window and its project.
#[derive(Clone, Debug, Default)]
pub struct Invocation {
pub client: Option<String>,
pub workspace_root: Option<PathBuf>,
/// Unsaved editor contents win over disk for `read-file`.
pub buffer_text: Option<BufferText>,
pub editor: Option<EditorContext>,
}
/// Per-call resource limits for plugin code.
/// A failed host call, as the plugin sees it (WIT `types.error`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostError {
/// Not granted; names the capability.
Denied(String),
/// The document changed since the version an edit was made for.
Stale,
NotFound(String),
Invalid(String),
Cancelled,
Failed(String),
}
impl std::fmt::Display for HostError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Denied(c) => write!(f, "permission denied: {c} is not granted"),
Self::Stale => f.write_str("the document changed"),
Self::NotFound(m) => write!(f, "not found: {m}"),
Self::Invalid(m) => write!(f, "invalid: {m}"),
Self::Cancelled => f.write_str("cancelled"),
Self::Failed(m) => f.write_str(m),
}
}
}
/// A plugin's status bar item.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct StatusItem {
pub text: String,
pub tooltip: Option<String>,
/// One of the plugin's commands, run on click.
pub command: Option<String>,
}
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum EventKind {
FileOpened,
FileSaved,
EditorFocused,
ActionPerformed,
VcsChanged,
Scheduled,
}
impl EventKind {
pub const ALL: [Self; 6] = [
Self::FileOpened,
Self::FileSaved,
Self::EditorFocused,
Self::ActionPerformed,
Self::VcsChanged,
Self::Scheduled,
];
pub fn name(self) -> &'static str {
match self {
Self::FileOpened => "file-opened",
Self::FileSaved => "file-saved",
Self::EditorFocused => "editor-focused",
Self::ActionPerformed => "action-performed",
Self::VcsChanged => "vcs-changed",
Self::Scheduled => "scheduled",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.name() == s)
}
/// The capability a subscription needs (`None`: `scheduled`, which the
/// manifest declares).
pub fn capability(self) -> Option<ide_extension::Capability> {
match self {
Self::ActionPerformed => Some(ide_extension::Capability::InputEvents),
Self::Scheduled => None,
_ => Some(ide_extension::Capability::Events),
}
}
}
/// How the user started an action.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ActionSource {
Keyboard,
Menu,
Toolbar,
ContextMenu,
Search,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ActionPerformed {
pub action: String,
pub title: String,
pub source: ActionSource,
pub shortcut: Option<String>,
}
/// A workbench event delivered to plugins.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum WorkbenchEvent {
FileOpened(String),
FileSaved(String),
EditorFocused(Option<String>),
ActionPerformed(ActionPerformed),
VcsChanged(String),
Scheduled,
}
impl WorkbenchEvent {
pub fn kind(&self) -> EventKind {
match self {
Self::FileOpened(_) => EventKind::FileOpened,
Self::FileSaved(_) => EventKind::FileSaved,
Self::EditorFocused(_) => EventKind::EditorFocused,
Self::ActionPerformed(_) => EventKind::ActionPerformed,
Self::VcsChanged(_) => EventKind::VcsChanged,
Self::Scheduled => EventKind::Scheduled,
}
}
}
/// State of the repository containing a project (WIT `vcs.repository-status`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RepositoryStatus {
pub root: String,
pub branch: Option<String>,
pub commit: Option<String>,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
pub operation: Option<String>,
pub changed_files: u32,
}
/// Resource limits and supervision policy for plugin code
/// (docs/plugin_plan.md 4.5).
#[derive(Clone, Copy, Debug)]
pub struct Limits {
/// Wasm fuel (≈ instructions) per host → plugin call.
pub fuel: u64,
/// Wall-clock limit per call (epoch interruption).
/// Wall-clock limit of commands, events, activation and deactivation.
pub timeout: Duration,
/// Wall-clock limit of a call that began a task (`tasks.begin`).
pub background: Duration,
/// Edit-path calls (syntax): the caller stops waiting after this and the
/// result is dropped; the plugin keeps its instance.
pub edit_soft: Duration,
/// Edit-path calls still running after this are interrupted (the instance
/// is rebuilt on next use).
pub edit_hard: Duration,
/// Linear memory cap per plugin.
pub memory_bytes: usize,
/// Wait before restarting after a crash; doubles with each consecutive crash.
pub restart_backoff: Duration,
/// Consecutive crashes after which the plugin is stopped for the session.
pub max_failures: u32,
/// Instances unused for this long are reclaimed (`None`: kept).
pub idle_timeout: Option<Duration>,
}
impl Default for Limits {
fn default() -> Self {
Self { fuel: 5_000_000_000, timeout: Duration::from_secs(5), memory_bytes: 256 << 20 }
Self {
fuel: 5_000_000_000,
timeout: Duration::from_secs(5),
background: Duration::from_secs(60),
edit_soft: Duration::from_millis(50),
edit_hard: Duration::from_millis(500),
memory_bytes: 256 << 20,
restart_backoff: Duration::from_secs(1),
max_failures: 3,
idle_timeout: Some(Duration::from_secs(300)),
}
}
}
impl Limits {
/// Delay before the restart that follows the `failures`-th consecutive crash.
pub fn backoff(&self, failures: u32) -> Duration {
let factor = 1u32 << failures.saturating_sub(1).min(6);
(self.restart_backoff * factor).min(Duration::from_secs(60).max(self.restart_backoff))
}
}
/// A highlighted range from a plugin's `syntax-provider` (UTF-8 byte offsets).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SyntaxToken {
pub start: u32,
pub end: u32,
pub kind: String,
}
/// A structure-view symbol from a plugin's `syntax-provider` (UTF-8 byte offsets).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SyntaxSymbol {
pub name: String,
pub detail: Option<String>,
pub kind: String,
pub start: u32,
pub end: u32,
pub name_start: u32,
pub name_end: u32,
/// Index of the enclosing symbol in the same list.
pub parent: Option<u32>,
}
/// A foldable line range from a plugin's `syntax-provider`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SyntaxFold {
pub start_line: u32,
pub end_line: u32,
pub kind: String,
}
/// An HTTP request from a plugin (WIT `net.request`); headers may hold
/// `{{secret:<name>}}` placeholders for the host to fill in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HttpRequest {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
pub timeout: Duration,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
/// What a one-off process printed (WIT `process.output`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProcessOutput {
pub status: Option<i32>,
pub stdout: String,
pub stderr: String,
}
/// Where `net:` requests may go: the URL's host when it is `https`, or
/// `http` to the loopback interface.
pub fn net_host(url: &str) -> Result<String, String> {
let (scheme, rest) = url.split_once("://").ok_or_else(|| format!("{url:?} is not an absolute URL"))?;
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let host = if let Some(v6) = authority.strip_prefix('[') {
v6.split(']').next().unwrap_or_default().to_owned()
} else {
authority.split(':').next().unwrap_or_default().to_ascii_lowercase()
};
if host.is_empty() {
return Err(format!("{url:?} has no host"));
}
let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
match scheme.to_ascii_lowercase().as_str() {
"https" => Ok(host),
"http" if loopback => Ok(host),
"http" => Err(format!("{url:?}: plain http is only allowed to localhost")),
other => Err(format!("{url:?}: unsupported scheme {other:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn net_hosts() {
assert_eq!(net_host("https://API.example.com:8443/v1?x").unwrap(), "api.example.com");
assert_eq!(net_host("http://localhost:11434/api").unwrap(), "localhost");
assert_eq!(net_host("https://user:[email protected]/").unwrap(), "h.io");
assert!(net_host("http://example.com/").is_err());
assert!(net_host("file:///etc/passwd").is_err());
assert!(net_host("example.com").is_err());
}
}
/// The document a provider request is about.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProviderDocument {
pub path: PathBuf,
pub language: String,
pub version: u64,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Location {
pub path: PathBuf,
pub range: ide_core::Range,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompletionItem {
pub label: String,
pub detail: Option<String>,
/// WIT `completion-kind` name (`method`, `keyword`, …).
pub kind: Option<String>,
pub insert_text: String,
pub replace: Option<ide_core::Range>,
pub sort_text: Option<String>,
pub documentation: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InlineSuggestion {
pub insert_text: String,
pub replace: Option<ide_core::Range>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CodeAction {
pub title: String,
pub kind: Option<String>,
pub preferred: bool,
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LineMarker {
pub line: u32,
pub icon: Option<String>,
pub tooltip: String,
pub target: Option<Location>,
}
// ---- declarative views (wit/views.wit) ---------------------------------------
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ViewButton {
pub id: String,
pub label: String,
pub icon: Option<String>,
pub enabled: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ViewTreeItem {
pub key: String,
pub parent: Option<String>,
pub label: String,
pub icon: Option<String>,
pub detail: Option<String>,
pub expandable: bool,
pub expanded: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ViewListItem {
pub key: String,
pub label: String,
pub icon: Option<String>,
pub detail: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum FieldValue {
Text(String),
Multiline(String),
Checkbox(bool),
Choice { options: Vec<String>, selected: String },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ViewField {
pub key: String,
pub label: String,
pub value: FieldValue,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ViewTable {
pub columns: Vec<String>,
pub total_rows: u32,
pub first_row: u32,
pub rows: Vec<Vec<String>>,
pub editable: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum ViewBody {
Message(String),
Tree(Vec<ViewTreeItem>),
List(Vec<ViewListItem>),
Form(Vec<ViewField>),
Table(ViewTable),
Html(String),
}
/// What a view or custom editor shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct View {
pub toolbar: Vec<ViewButton>,
pub body: ViewBody,
pub status: Option<String>,
}
/// The table rows the UI shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Viewport {
pub first_row: u32,
pub rows: u32,
}
/// A user action in a view.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum ViewEvent {
Button { id: String },
Activate { key: String },
Select { key: String },
Expand { key: String },
Collapse { key: String },
Field { key: String, value: String },
Cell { row: u32, column: u32, value: String },
InsertRow { before: u32 },
DeleteRows { rows: Vec<u32> },
Message { text: String },
}
+691
Переглянути файл
@@ -0,0 +1,691 @@
//! The host side of the `openidea:[email protected]` world: bindings, resource
//! types and the implementation of every import. Capabilities are checked
//! here; a missing one returns `error::denied` (the plugin can degrade).
use crate::runtime::{StoreData, clip};
use crate::sandbox;
use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host};
use ide_extension::Capability;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use wasmtime::component::Resource;
pub(crate) mod bindings {
wasmtime::component::bindgen!({
world: "plugin",
path: "../../wit",
imports: { default: trappable },
with: {
"openidea:plugin/workspace.project": super::ProjectRes,
"openidea:plugin/editor.text-editor": super::EditorRes,
"openidea:plugin/invocation.invocation": super::InvocationRes,
"openidea:plugin/tasks.task": super::TaskRes,
},
});
}
/// The optional `syntax-provider` export, bound on its own when present.
pub(crate) mod syntax_bindings {
wasmtime::component::bindgen!({
world: "plugin-syntax",
path: "../../wit",
imports: { default: trappable },
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/log": super::bindings::openidea::plugin::log,
"openidea:plugin/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,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
"openidea:plugin/net": super::bindings::openidea::plugin::net,
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
"openidea:plugin/process": super::bindings::openidea::plugin::process,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
},
});
}
/// The optional `providers` export.
pub(crate) mod providers_bindings {
wasmtime::component::bindgen!({
world: "plugin-providers",
path: "../../wit",
imports: { default: trappable },
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/log": super::bindings::openidea::plugin::log,
"openidea:plugin/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,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
"openidea:plugin/net": super::bindings::openidea::plugin::net,
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
"openidea:plugin/process": super::bindings::openidea::plugin::process,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
},
});
}
/// The optional `view-provider` export.
pub(crate) mod views_bindings {
wasmtime::component::bindgen!({
world: "plugin-views",
path: "../../wit",
imports: { default: trappable },
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/log": super::bindings::openidea::plugin::log,
"openidea:plugin/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,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
"openidea:plugin/net": super::bindings::openidea::plugin::net,
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
"openidea:plugin/process": super::bindings::openidea::plugin::process,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
},
});
}
use bindings::openidea::plugin::{
contributions, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
types as wit, ui, vcs, views, workspace,
};
/// A project handle: its root.
pub struct ProjectRes {
root: PathBuf,
}
/// The invoking window's focused editor.
pub struct EditorRes {
ctx: EditorContext,
}
/// The current call's invocation (read from [`StoreData`]).
pub struct InvocationRes;
pub struct TaskRes {
id: u64,
}
const MAX_EDITS: usize = 10_000;
const MAX_DIAGNOSTICS: usize = 5_000;
const MAX_REQUEST_BODY: usize = 8 << 20;
const MAX_FIND: u32 = 10_000;
const MAX_STATUS_TEXT: usize = 128;
type Fallible<T> = wasmtime::Result<Result<T, wit::Error>>;
pub(crate) fn to_wit(e: HostError) -> wit::Error {
match e {
HostError::Denied(c) => wit::Error::Denied(c),
HostError::Stale => wit::Error::Stale,
HostError::NotFound(m) => wit::Error::NotFound(m),
HostError::Invalid(m) => wit::Error::Invalid(m),
HostError::Cancelled => wit::Error::Cancelled,
HostError::Failed(m) => wit::Error::Failed(m),
}
}
/// A guest's `error` as text (`denied: net:example.com`, `stale`, …).
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,
}
}
fn pos(p: wit::Position) -> ide_core::Pos {
ide_core::Pos::new(p.line, p.character)
}
fn range_to_wit(r: &ide_core::Range) -> wit::Range {
wit::Range {
start: wit::Position { line: r.start.line, character: r.start.col },
end: wit::Position { line: r.end.line, character: r.end.col },
}
}
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())) }
}
fn client(&self) -> Option<&str> {
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)?)
}
pub(crate) fn new_invocation(&mut self) -> wasmtime::Result<Resource<InvocationRes>> {
self.push(InvocationRes)
}
pub(crate) fn drop_invocation(&mut self, res: Resource<InvocationRes>) {
let _ = self.table.delete(res);
}
}
impl wit::Host for StoreData {}
impl log::Host for StoreData {
fn log(&mut self, level: wit::LogLevel, message: String) -> wasmtime::Result<()> {
let id = &self.plugin_id;
let message = clip(message, crate::runtime::MAX_TEXT);
match level {
wit::LogLevel::Trace => tracing::trace!(plugin = %id, "{message}"),
wit::LogLevel::Debug => tracing::debug!(plugin = %id, "{message}"),
wit::LogLevel::Info => tracing::info!(plugin = %id, "{message}"),
wit::LogLevel::Warn => tracing::warn!(plugin = %id, "{message}"),
wit::LogLevel::Error => tracing::error!(plugin = %id, "{message}"),
}
Ok(())
}
}
impl 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,
level: wit::NotificationLevel,
title: String,
body: String,
) -> wasmtime::Result<()> {
let level = match level {
wit::NotificationLevel::Info => NotificationLevel::Info,
wit::NotificationLevel::Warning => NotificationLevel::Warning,
wit::NotificationLevel::Error => NotificationLevel::Error,
};
let max = crate::runtime::MAX_TITLE;
self.host.notify(self.client(), level, clip(title, max), clip(body, 16 << 10));
Ok(())
}
fn set_status_item(&mut self, id: String, item: ui::StatusItem) -> wasmtime::Result<()> {
let item = StatusItem {
text: clip(item.text, MAX_STATUS_TEXT),
tooltip: item.tooltip.map(|t| clip(t, 1024)),
command: item.command.map(|c| clip(c, 128)),
};
self.host.status_item(&clip(id, 64), Some(item));
Ok(())
}
fn remove_status_item(&mut self, id: String) -> wasmtime::Result<()> {
self.host.status_item(&clip(id, 64), None);
Ok(())
}
}
impl storage::Host for StoreData {
fn get(&mut self, key: String) -> Fallible<Option<String>> {
if let Err(e) = self.require(Capability::Storage) {
return Ok(Err(e));
}
Ok(self.host.storage_get(&key).map_err(to_wit))
}
fn set(&mut self, key: String, value: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::Storage) {
return Ok(Err(e));
}
Ok(self.host.storage_set(&key, Some(value)).map_err(to_wit))
}
fn delete(&mut self, key: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::Storage) {
return Ok(Err(e));
}
Ok(self.host.storage_set(&key, None).map_err(to_wit))
}
fn keys(&mut self, prefix: String) -> Fallible<Vec<String>> {
if let Err(e) = self.require(Capability::Storage) {
return Ok(Err(e));
}
Ok(self.host.storage_keys(&prefix).map_err(to_wit))
}
}
impl workspace::Host for StoreData {
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let mut out = Vec::new();
for root in self.host.projects() {
out.push(self.push(ProjectRes { root })?);
}
Ok(Ok(out))
}
}
impl workspace::HostProject for StoreData {
fn root(&mut self, this: Resource<ProjectRes>) -> Fallible<String> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned()))
}
fn read_file(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<String> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
if let Some(text) = self.host.buffer_text(&real) {
return Ok(Ok(text));
}
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
}
fn find_files(&mut self, this: Resource<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let root = self.table.get(&this)?.root.clone();
Ok(self.host.find_files(&root, &glob, limit.min(MAX_FIND) as usize).map_err(to_wit))
}
fn drop(&mut self, rep: Resource<ProjectRes>) -> wasmtime::Result<()> {
self.table.delete(rep)?;
Ok(())
}
}
impl editor::Host for StoreData {}
impl editor::HostTextEditor for StoreData {
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned())
}
fn language(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
Ok(self.table.get(&this)?.ctx.language.clone())
}
fn document(&mut self, this: Resource<EditorRes>) -> Fallible<editor::Document> {
if let Err(e) = self.require(Capability::EditorRead) {
return Ok(Err(e));
}
let path = self.table.get(&this)?.ctx.path.clone();
Ok(match self.host.document(&path) {
Some((version, text)) => Ok(editor::Document { version, text }),
None => Err(wit::Error::NotFound(format!("{} is not open", path.display()))),
})
}
fn selections(&mut self, this: Resource<EditorRes>) -> Fallible<Vec<wit::Range>> {
if let Err(e) = self.require(Capability::EditorRead) {
return Ok(Err(e));
}
Ok(Ok(self.table.get(&this)?.ctx.selections.iter().map(range_to_wit).collect()))
}
fn apply_edits(&mut self, this: Resource<EditorRes>, version: u64, edits: Vec<wit::TextEdit>) -> Fallible<u64> {
if let Err(e) = self.require(Capability::EditorWrite) {
return Ok(Err(e));
}
if edits.len() > MAX_EDITS {
return Ok(Err(wit::Error::Invalid(format!("more than {MAX_EDITS} edits"))));
}
let total: usize = edits.iter().map(|e| e.text.len()).sum();
if total > crate::runtime::MAX_TEXT * 8 {
return Ok(Err(wit::Error::Invalid("edits too large".into())));
}
let path = self.table.get(&this)?.ctx.path.clone();
let edits: Vec<ide_core::TextEdit> = edits
.into_iter()
.map(|e| ide_core::TextEdit {
range: ide_core::Range::new(pos(e.range.start), pos(e.range.end)),
new_text: e.text,
})
.collect();
let client = self.invocation.client.clone();
Ok(self.host.apply_edits(client.as_deref(), &path, version, edits).map_err(to_wit))
}
fn drop(&mut self, rep: Resource<EditorRes>) -> wasmtime::Result<()> {
self.table.delete(rep)?;
Ok(())
}
}
impl invocation::Host for StoreData {}
impl invocation::HostInvocation for StoreData {
fn project(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Option<Resource<ProjectRes>>> {
match self.invocation.workspace_root.clone() {
Some(root) => Ok(Some(self.push(ProjectRes { root })?)),
None => Ok(None),
}
}
fn editor(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Option<Resource<EditorRes>>> {
match self.invocation.editor.clone() {
Some(ctx) => Ok(Some(self.push(EditorRes { ctx })?)),
None => Ok(None),
}
}
fn drop(&mut self, rep: Resource<InvocationRes>) -> wasmtime::Result<()> {
// Owned by the host for the duration of the call.
let _ = rep;
Ok(())
}
}
impl vcs::Host for StoreData {
fn status(&mut self, project: Resource<ProjectRes>) -> Fallible<Option<vcs::RepositoryStatus>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
let root = self.table.get(&project)?.root.clone();
Ok(self
.host
.vcs_status(&root)
.map(|s| {
s.map(|s| vcs::RepositoryStatus {
root: s.root,
branch: s.branch,
commit: s.commit,
upstream: s.upstream,
ahead: s.ahead,
behind: s.behind,
operation: s.operation,
changed_files: s.changed_files,
})
})
.map_err(to_wit))
}
fn fetch(&mut self, project: Resource<ProjectRes>) -> Fallible<()> {
if let Err(e) = self.require(Capability::VcsWrite) {
return Ok(Err(e));
}
let root = self.table.get(&project)?.root.clone();
let token = self.cancel.token();
Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit))
}
}
fn kind_from_wit(k: events::EventKind) -> EventKind {
match k {
events::EventKind::FileOpened => EventKind::FileOpened,
events::EventKind::FileSaved => EventKind::FileSaved,
events::EventKind::EditorFocused => EventKind::EditorFocused,
events::EventKind::ActionPerformed => EventKind::ActionPerformed,
events::EventKind::VcsChanged => EventKind::VcsChanged,
events::EventKind::Scheduled => EventKind::Scheduled,
}
}
impl events::Host for StoreData {
fn subscribe(&mut self, kind: events::EventKind) -> Fallible<()> {
let kind = kind_from_wit(kind);
if let Some(cap) = kind.capability()
&& let Err(e) = self.require(cap)
{
return Ok(Err(e));
}
self.host.subscribe(kind, true);
Ok(Ok(()))
}
fn unsubscribe(&mut self, kind: events::EventKind) -> wasmtime::Result<()> {
self.host.subscribe(kind_from_wit(kind), false);
Ok(())
}
}
impl tasks::Host for StoreData {
fn begin(&mut self, title: String, cancellable: bool) -> wasmtime::Result<Resource<TaskRes>> {
let token = self.cancel.token();
let id = self.host.task_begin(self.client(), clip(title, crate::runtime::MAX_TITLE), cancellable, token);
self.open_tasks.push(id);
// The call may now run for the background budget.
let extended = self.call_started + self.limits.background;
if extended > self.deadline {
self.deadline = extended;
self.timeout = self.limits.background;
}
self.push(TaskRes { id })
}
}
impl tasks::HostTask for StoreData {
fn report(
&mut self,
this: Resource<TaskRes>,
done: u32,
total: Option<u32>,
message: Option<String>,
) -> wasmtime::Result<()> {
let id = self.table.get(&this)?.id;
let now = Instant::now();
// At most ~10 updates per second reach the UI.
if self.last_report.is_none_or(|t| now.duration_since(t).as_millis() >= 100) || total.is_some_and(|t| done >= t)
{
self.last_report = Some(now);
self.host.task_report(id, done, total, message.map(|m| clip(m, 256)));
}
Ok(())
}
fn is_cancelled(&mut self, this: Resource<TaskRes>) -> wasmtime::Result<bool> {
let id = self.table.get(&this)?.id;
Ok(self.cancel.token().is_cancelled() || self.host.task_cancelled(id))
}
fn drop(&mut self, rep: Resource<TaskRes>) -> wasmtime::Result<()> {
let id = self.table.delete(rep)?.id;
self.open_tasks.retain(|t| *t != id);
self.host.task_end(id);
Ok(())
}
}
impl StoreData {
/// What is left of the running call's wall-clock budget (host operations
/// can't be interrupted by the epoch callback, so they get this as timeout).
fn remaining(&self) -> Duration {
self.deadline.saturating_duration_since(Instant::now())
}
}
impl diagnostics::Host for StoreData {
fn publish(&mut self, path: String, items: Vec<diagnostics::Diagnostic>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let source = self.plugin_id.clone();
let items = items
.into_iter()
.take(MAX_DIAGNOSTICS)
.map(|d| ide_core::Diagnostic {
range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)),
severity: match d.severity {
diagnostics::Severity::Error => ide_core::Severity::Error,
diagnostics::Severity::Warning => ide_core::Severity::Warning,
diagnostics::Severity::Info => ide_core::Severity::Info,
diagnostics::Severity::Hint => ide_core::Severity::Hint,
},
message: clip(d.message, 4096),
source: Some(source.clone()),
code: d.code.map(|c| clip(c, 128)),
unnecessary: false,
deprecated: false,
server: Some(source.clone()),
})
.collect();
Ok(self.host.publish_diagnostics(Path::new(&path), items).map_err(to_wit))
}
}
impl net::Host for StoreData {
fn fetch(&mut self, req: net::Request) -> Fallible<net::Response> {
let host = match net_host(&req.url) {
Ok(h) => h,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
if let Err(e) = self.require(Capability::Net(host)) {
return Ok(Err(e));
}
if req.headers.iter().any(|h| h.value.contains("{{secret:"))
&& let Err(e) = self.require(Capability::Secrets)
{
return Ok(Err(e));
}
if req.body.as_ref().is_some_and(|b| b.len() > MAX_REQUEST_BODY) {
return Ok(Err(wit::Error::Invalid("request body larger than 8 MiB".into())));
}
let wanted = Duration::from_millis(u64::from(req.timeout_ms.unwrap_or(30_000)));
let request = HttpRequest {
method: req.method,
url: req.url,
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
body: req.body,
timeout: wanted.min(self.remaining()),
};
let token = self.cancel.token();
Ok(self
.host
.fetch(request, &token)
.map(|r| net::Response {
status: r.status,
headers: r.headers.into_iter().map(|(name, value)| net::Header { name, value }).collect(),
body: r.body,
})
.map_err(to_wit))
}
}
impl secrets::Host for StoreData {
fn is_set(&mut self, name: String) -> wasmtime::Result<bool> {
Ok(self.granted.iter().any(|g| g.covers(&Capability::Secrets)) && self.host.secret_is_set(&name))
}
}
impl process::Host for StoreData {
fn run(
&mut self,
program: String,
args: Vec<String>,
cwd: Option<Resource<ProjectRes>>,
stdin: Option<String>,
) -> Fallible<process::Output> {
if let Err(e) = self.require(Capability::Process(program.clone())) {
return Ok(Err(e));
}
let cwd = match &cwd {
Some(p) => Some(self.table.get(p)?.root.clone()),
None => None,
};
let token = self.cancel.token();
let timeout = self.remaining();
Ok(self
.host
.run_process(&program, args, cwd.as_deref(), stdin, &token, timeout)
.map(|o| process::Output { status: o.status, stdout: o.stdout, stderr: o.stderr })
.map_err(to_wit))
}
}
impl views::Host for StoreData {
fn refresh(&mut self, view: String) -> wasmtime::Result<()> {
self.host.refresh_view(&clip(view, 64));
Ok(())
}
fn post_message(&mut self, view: String, message: String) -> Fallible<()> {
if message.len() > MAX_VIEW_MESSAGE {
return Ok(Err(wit::Error::Invalid(format!("messages are limited to {MAX_VIEW_MESSAGE} bytes"))));
}
Ok(self.host.post_view_message(&clip(view, 64), message).map_err(to_wit))
}
fn open_terminal(&mut self, title: String, program: String, args: Vec<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::Process(program.clone())) {
return Ok(Err(e));
}
if args.len() > 256 || args.iter().any(|a| a.len() > 4096) {
return Ok(Err(wit::Error::Invalid("too many or too long arguments".into())));
}
let cwd = self.invocation.workspace_root.clone();
let client = self.invocation.client.clone();
let title = clip(title, crate::runtime::MAX_TITLE);
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
}
}
impl settings::Host for StoreData {
fn get(&mut self, key: String) -> wasmtime::Result<Option<String>> {
let root = self.invocation.workspace_root.clone();
Ok(self.host.setting(root.as_deref(), &clip(key, 128)))
}
}
impl views_bindings::openidea::plugin::view_types::Host for StoreData {}
/// Largest message between a plugin and its view's frame.
const MAX_VIEW_MESSAGE: usize = 1 << 20;
Бінарний файл не відображається.
+7 -4
Переглянути файл
@@ -1,7 +1,10 @@
id = "misbehave"
name = "Misbehave"
version = "0.0.1"
description = "Test fixture: traps, spins, allocates, probes permissions."
openidea-api = "0.1"
version = "0.0.2"
description = "Test fixture: traps, spins, allocates, probes capabilities."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
permissions = ["project-read"]
activation = ["on-command:*"]
[capabilities]
required = ["project.read"]
+62 -14
Переглянути файл
@@ -1,25 +1,37 @@
//! Test fixture for the ide-plugin sandbox. Never shipped.
wit_bindgen::generate!({
world: "plugin",
world: "plugin-syntax",
path: "../../../../../wit",
});
use openidea::plugin::host;
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};
struct Misbehave;
impl Guest for Misbehave {
fn activate(_ctx: Context) -> Result<(), String> {
fn err(e: types::Error) -> String {
format!("{e:?}")
}
impl lifecycle::Guest for Misbehave {
fn activate(ctx: types::Context) -> Result<(), String> {
// 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"] {
host::register_command(id, id, None);
contributions::register_command(id, id, None).map_err(err)?;
}
Ok(())
}
fn deactivate() {}
}
fn run_command(id: String, arg: Option<String>) -> Result<Option<String>, String> {
impl commands::Guest for Misbehave {
fn run(id: String, ctx: &Invocation, arg: Option<String>) -> Result<Option<String>, String> {
match id.as_str() {
"echo" => Ok(arg),
"panic" => panic!("boom"),
@@ -36,18 +48,54 @@ impl Guest for Misbehave {
std::hint::black_box(&chunks);
}
}
"read" => host::read_file(&arg.unwrap_or_default()).map(Some),
"root" => Ok(host::workspace_root()),
// Not permitted by the manifest: traps.
"insert" => {
host::insert_text("x");
Ok(None)
"read" => {
let project = ctx.project().ok_or("no project is open")?;
project.read_file(&arg.unwrap_or_default()).map(Some).map_err(err)
}
"language" => host::register_language(&arg.unwrap_or_default()).map(|_| None),
"theme" => host::register_theme(&arg.unwrap_or_default()).map(|_| None),
"root" => ctx.project().map(|p| p.root().map_err(err)).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)
}
"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}")),
}
}
}
impl event_handler::Guest for Misbehave {
fn handle(_events: Vec<openidea::plugin::events::Event>, _ctx: &Invocation) {}
}
/// 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.
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");
}
vec![Token { start: 0, end: text.len() as u32, kind: "keyword".into() }]
}
fn outline(_language: String, _text: String) -> Vec<Symbol> {
Vec::new()
}
fn folding(_language: String, _text: String) -> Vec<Fold> {
Vec::new()
}
}
export!(Misbehave);
+2
Переглянути файл
@@ -0,0 +1,2 @@
target/
!dist/
+326
Переглянути файл
@@ -0,0 +1,326 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-p0-probe"
version = "0.0.1"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+25
Переглянути файл
@@ -0,0 +1,25 @@
[package]
name = "openidea-p0-probe"
version = "0.0.1"
edition = "2024"
publish = false
# Standalone: not a member of the IDE workspace (different target).
[workspace]
[lib]
crate-type = ["cdylib"]
[features]
# Builds the `probe-full` world (with the optional exports); default is `probe-min`.
full = []
[dependencies]
wit-bindgen = "0.63"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
+11
Переглянути файл
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Builds dist/min.wasm (probe-min) and dist/full.wasm (probe-full).
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
export CARGO_TARGET_DIR="$here/target"
mkdir -p "$here/dist"
cargo build --manifest-path "$here/Cargo.toml" --release --target wasm32-wasip2
cp "$CARGO_TARGET_DIR/wasm32-wasip2/release/openidea_p0_probe.wasm" "$here/dist/min.wasm"
cargo build --manifest-path "$here/Cargo.toml" --release --target wasm32-wasip2 --features full
cp "$CARGO_TARGET_DIR/wasm32-wasip2/release/openidea_p0_probe.wasm" "$here/dist/full.wasm"
ls -l "$here/dist"
Бінарний файл не відображається.
Бінарний файл не відображається.
+68
Переглянути файл
@@ -0,0 +1,68 @@
//! P0 probe guest. Built twice (see `build.sh`): `probe-min` and, with
//! `--features full`, `probe-full` with the optional exports.
#[cfg(feature = "full")]
wit_bindgen::generate!({ world: "probe-full", path: "wit", generate_all });
#[cfg(not(feature = "full"))]
wit_bindgen::generate!({ world: "probe-min", path: "wit", generate_all });
use exports::openidea::probe::base;
use openidea::probe::host_api;
use std::sync::atomic::{AtomicU32, Ordering};
static CALLS: AtomicU32 = AtomicU32::new(0);
struct Probe;
impl base::Guest for Probe {
fn run(cmd: String, arg: String) -> Result<String, String> {
let n = CALLS.fetch_add(1, Ordering::Relaxed) + 1;
match cmd.as_str() {
"echo" => Ok(arg),
// Instance state survives between calls (and is lost with the instance).
"count" => Ok(n.to_string()),
"spin" => {
let mut x: u64 = 0;
loop {
x = std::hint::black_box(x.wrapping_add(1));
}
}
"sleep" => {
host_api::sleep_ms(arg.parse().map_err(|_| "ms")?);
Ok("slept".into())
}
// Streams items pushed by the host until it cancels.
"task" => {
let task = host_api::current_task();
let mut got = Vec::new();
loop {
task.next_ready().block();
if task.is_cancelled() {
return Ok(format!("cancelled after {}", got.join(",")));
}
if let Some(item) = task.take() {
got.push(item);
task.report(got.len() as u32, None);
}
}
}
other => Err(format!("unknown: {other}")),
}
}
}
#[cfg(feature = "full")]
impl exports::openidea::probe::code_actions::Guest for Probe {
fn actions(path: String) -> Vec<String> {
vec![format!("fix:{path}")]
}
}
#[cfg(feature = "full")]
impl exports::openidea::probe::completion::Guest for Probe {
fn complete(prefix: String) -> Vec<String> {
vec![format!("{prefix}1"), format!("{prefix}2")]
}
}
export!(Probe);
+11
Переглянути файл
@@ -0,0 +1,11 @@
// Subset of wasi:[email protected] (the host links its full 0.2.x implementation).
package wasi:[email protected];
interface poll {
resource pollable {
ready: func() -> bool;
block: func();
}
poll: func(in: list<borrow<pollable>>) -> list<u32>;
}
+59
Переглянути файл
@@ -0,0 +1,59 @@
/// P0 probe (docs/plugin_p0.md): 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];
interface host-api {
use wasi:io/[email protected].{pollable};
/// A long-running job the host started for the guest.
resource task {
/// Progress report (`total` unknown while `none`).
report: func(done: u32, total: option<u32>);
/// Set by the host when the user cancels.
is-cancelled: func() -> bool;
/// Ready when the host pushed the next item, or on cancellation.
next-ready: func() -> pollable;
/// The pushed item, if any (non-blocking).
take: func() -> option<string>;
}
/// The task of the current call.
current-task: func() -> task;
/// An async host import: sleeps without blocking the host thread.
sleep-ms: func(ms: u32);
}
interface base {
run: func(cmd: string, arg: string) -> result<string, string>;
}
/// Optional exports: a plugin implements only the providers it needs.
interface code-actions {
actions: func(path: string) -> list<string>;
}
interface completion {
complete: func(prefix: string) -> list<string>;
}
world probe-min {
import host-api;
export base;
}
world probe-full {
import host-api;
export base;
export code-actions;
export completion;
}
/// The host binds this world; guests implement `probe-min` or `probe-full`.
world probe-host {
import host-api;
export base;
export code-actions;
export completion;
}
-603
Переглянути файл
@@ -1,603 +0,0 @@
//! End-to-end tests against real components.
//!
//! The components are checked in (`plugins/hello/dist/plugin.wasm`,
//! `tests/fixtures/misbehave/dist/plugin.wasm`) so tests need no wasm
//! toolchain. Rebuild them with `plugins/build.sh hello` and
//! `plugins/build.sh crates/ide-plugin/tests/fixtures/misbehave` after
//! changing their source or `wit/plugin.wit`.
use ide_plugin::*;
use parking_lot::Mutex;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
type Events = Arc<Mutex<Vec<(Option<String>, PluginEvent)>>>;
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn sample(name: &str) -> PathBuf {
repo().join("plugins").join(name)
}
fn theme_template() -> PathBuf {
repo().join("templates/theme-plugin")
}
/// A theme package with a light and a dark theme, plus licence files, in `dir`.
fn write_theme_pair(dir: &Path) {
std::fs::create_dir_all(dir.join("t")).unwrap();
std::fs::write(
dir.join("plugin.toml"),
"id = \"theme-pair\"\nname = \"Pair\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\
[[contributes.themes]]\npath = \"t/day.json\"\n[[contributes.themes]]\npath = \"t/night.json\"\n",
)
.unwrap();
std::fs::write(
dir.join("t/day.json"),
r##"{"id":"day","name":"Day","kind":"light","tokens":{"--bg-frame":"#eeeeee"}}"##,
)
.unwrap();
std::fs::write(
dir.join("t/night.json"),
r##"{"id":"night","name":"Night","kind":"dark","tokens":{"--bg-frame":"#111111"}}"##,
)
.unwrap();
std::fs::write(dir.join("LICENSE"), "MIT License\n").unwrap();
std::fs::write(dir.join("NOTICE"), "Palette notice\n").unwrap();
}
fn misbehave_pkg() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/misbehave")
}
struct Fixture {
host: PluginHost,
events: Events,
_tmp: tempfile::TempDir,
plugins_dir: PathBuf,
project: PathBuf,
}
fn fixture(limits: Limits) -> Fixture {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let project = tmp.path().join("project");
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
let project = project.canonicalize().unwrap();
let events: Events = Arc::default();
let sink = {
let events = events.clone();
move |client: Option<&str>, e: PluginEvent| events.lock().push((client.map(str::to_owned), e))
};
let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), limits).unwrap();
Fixture { host, events, _tmp: tmp, plugins_dir, project }
}
impl Fixture {
fn inv(&self) -> Invocation {
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), buffer_text: None }
}
fn run(&self, plugin: &str, cmd: &str, arg: Option<&str>) -> Result<Option<String>, String> {
self.host.run_command(plugin, cmd, arg, self.inv()).map_err(|e| e.message)
}
fn status(&self, id: &str) -> PluginStatus {
self.host.info_of(id).unwrap().status
}
fn take_events(&self) -> Vec<(Option<String>, PluginEvent)> {
std::mem::take(&mut *self.events.lock())
}
}
#[test]
fn hello_plugin_commands() {
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&sample("hello")).unwrap();
assert_eq!(info.status, PluginStatus::Active, "{:?}", info.error);
assert!(f.plugins_dir.join("hello/plugin.toml").is_file());
assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file());
let titles: Vec<_> = info.commands.iter().map(|c| c.title.as_str()).collect();
assert_eq!(titles, ["Hello: Insert Date", "Hello: Word Count of Active File"]);
f.take_events();
// Insert Date: returns YYYY-MM-DD and asks the invoking window to insert it.
let date = f.run("hello", "insert-date", None).unwrap().unwrap();
assert!(date.len() == 10 && date.as_bytes()[4] == b'-' && date.starts_with("20"), "{date}");
let events = f.take_events();
assert!(events.iter().any(|(client, e)| client.as_deref() == Some("main")
&& *e == PluginEvent::InsertText { plugin_id: "hello".into(), text: date.clone() }));
// Word Count needs an active file.
assert!(f.run("hello", "word-count", None).unwrap_err().contains("no file is open"));
f.host.set_active_file("main", Some(f.project.join("src/notes.txt").to_string_lossy().into_owned()));
let summary = f.run("hello", "word-count", None).unwrap().unwrap();
assert!(summary.starts_with("5 words, 2 lines"), "{summary}");
assert!(f.take_events().iter().any(|(_, e)| matches!(e,
PluginEvent::Notification { title, level: NotificationLevel::Info, .. } if title == "Word count: notes.txt")));
// Unsaved buffer contents win over disk.
let mut inv = f.inv();
inv.buffer_text = Some(Arc::new(|_: &Path| Some("just three words".to_owned())));
let summary = f.host.run_command("hello", "word-count", None, inv).unwrap().unwrap();
assert!(summary.starts_with("3 words"), "{summary}");
// Guest errors don't disable the plugin.
assert!(f.run("hello", "nope", None).unwrap_err().contains("unknown command"));
assert_eq!(f.status("hello"), PluginStatus::Active);
}
#[test]
fn read_file_is_confined_to_the_project() {
let f = fixture(Limits::default());
f.host.install_from_dir(&misbehave_pkg()).unwrap();
assert_eq!(f.run("misbehave", "read", Some("src/notes.txt")).unwrap().unwrap(), "one two three\nfour five\n");
let abs = f.project.join("src/notes.txt");
assert!(f.run("misbehave", "read", Some(abs.to_str().unwrap())).is_ok());
let err = f.run("misbehave", "read", Some("../secret.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
// Without a project there is nothing to read.
let inv = Invocation { client: None, workspace_root: None, buffer_text: None };
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
assert!(err.contains("no project is open"), "{err}");
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy());
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
#[test]
fn undeclared_permission_traps_and_disables() {
let f = fixture(Limits::default());
f.host.install_from_dir(&misbehave_pkg()).unwrap();
let err = f.run("misbehave", "insert", None).unwrap_err();
assert!(err.contains("permission denied") && err.contains("editor-write"), "{err}");
assert_eq!(f.status("misbehave"), PluginStatus::Failed);
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::InsertText { .. })));
}
#[test]
fn panic_is_isolated_and_plugin_can_be_restarted() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("hello")).unwrap();
f.host.install_from_dir(&misbehave_pkg()).unwrap();
f.take_events();
let err = f.run("misbehave", "panic", None).unwrap_err();
assert!(err.contains("crashed"), "{err}");
assert!(err.contains("boom"), "stderr panic message is surfaced: {err}");
let info = f.host.info_of("misbehave").unwrap();
assert_eq!(info.status, PluginStatus::Failed);
assert!(info.enabled, "failure is per session, not persisted");
let events = f.take_events();
assert!(events.iter().any(|(client, e)| client.as_deref() == Some("main")
&& matches!(e, PluginEvent::Notification { level: NotificationLevel::Error, title, .. } if title.contains("was disabled"))));
assert!(events.iter().any(|(_, e)| *e == PluginEvent::CommandsChanged));
assert!(f.run("misbehave", "echo", Some("x")).unwrap_err().contains("not running"));
// Other plugins are unaffected.
assert!(f.run("hello", "insert-date", None).is_ok());
// Toggling restarts it with a fresh instance.
f.host.set_enabled("misbehave", true).unwrap();
assert_eq!(f.status("misbehave"), PluginStatus::Active);
assert_eq!(f.run("misbehave", "echo", Some("back")).unwrap().as_deref(), Some("back"));
}
#[test]
fn fuel_exhaustion_traps() {
let f = fixture(Limits { fuel: 50_000_000, timeout: Duration::from_secs(30), ..Limits::default() });
f.host.install_from_dir(&misbehave_pkg()).unwrap();
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("CPU budget"), "{err}");
assert_eq!(f.status("misbehave"), PluginStatus::Failed);
}
#[test]
fn wall_clock_timeout_traps() {
let f = fixture(Limits { fuel: u64::MAX, timeout: Duration::from_millis(200), ..Limits::default() });
f.host.install_from_dir(&misbehave_pkg()).unwrap();
let started = std::time::Instant::now();
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("timed out"), "{err}");
assert!(started.elapsed() < Duration::from_secs(5));
}
#[test]
fn memory_limit_traps() {
let f = fixture(Limits { memory_bytes: 32 << 20, ..Limits::default() });
f.host.install_from_dir(&misbehave_pkg()).unwrap();
let err = f.run("misbehave", "alloc", None).unwrap_err();
assert!(err.contains("memory"), "{err}");
assert_eq!(f.status("misbehave"), PluginStatus::Failed);
}
#[test]
fn runtime_language_and_theme_registration() {
let f = fixture(Limits::default());
f.host.install_from_dir(&misbehave_pkg()).unwrap();
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"]);
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()
);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!(info.languages[0].extensions, ["zz"]);
assert_eq!(info.themes[0].kind, 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());
let theme = f.host.install_from_dir(&theme_template()).unwrap();
assert_eq!(theme.status, PluginStatus::Static);
assert!(!theme.has_code);
let ids: Vec<&str> = theme.themes.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, ["harbor-night"]);
let lang = f.host.install_from_dir(&sample("language-toy")).unwrap();
assert_eq!(lang.status, PluginStatus::Static);
assert_eq!(lang.languages[0].id, "toy");
assert_eq!(f.host.languages()[0].block_comment, Some(("(*".into(), "*)".into())));
assert!(f.run("language-toy", "x", None).unwrap_err().contains("no code"));
f.take_events();
let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#14171c");
assert_eq!(active.theme.tokens["--syn-string"], "#9ccc7a");
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ThemeChanged { theme: Some(_) })));
assert_eq!(f.host.list(None).active_theme, Some(active));
// Disabling the plugin withdraws its theme and language.
f.host.set_enabled("theme-harbor-night", false).unwrap();
assert!(f.host.active_theme().is_none());
assert!(f.take_events().iter().any(|(_, e)| *e == PluginEvent::ThemeChanged { theme: None }));
f.host.set_enabled("language-toy", false).unwrap();
assert!(f.host.languages().is_empty());
assert_eq!(f.status("language-toy"), PluginStatus::Disabled);
}
#[test]
fn theme_template_installs_with_its_editor_scheme() {
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&theme_template()).unwrap();
assert_eq!(info.status, PluginStatus::Static);
assert!(!info.has_code);
assert_eq!(info.themes[0].id, "harbor-night");
assert_eq!(info.themes[0].kind, ThemeKind::Dark);
// The scheme file is copied with the package and merged over the UI tokens.
assert!(f.plugins_dir.join("theme-harbor-night/themes/harbor-night.scheme.json").is_file());
let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap();
let tokens = &active.theme.tokens;
assert!(
tokens.contains_key("--bg-app") && tokens.contains_key("--syn-keyword") && tokens.contains_key("--ansi-red")
);
assert!(tokens.len() > 150);
}
#[test]
fn theme_packages_install_with_their_licence() {
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
write_theme_pair(dir.path());
let info = f.host.install_from_dir(dir.path()).unwrap();
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let installed = f.plugins_dir.join("theme-pair");
assert!(std::fs::read_to_string(installed.join("LICENSE")).unwrap().contains("MIT License"));
assert!(installed.join("NOTICE").is_file());
for (theme, kind) in [("day", ThemeKind::Light), ("night", ThemeKind::Dark)] {
let active = f.host.set_theme(Some(("theme-pair", theme))).unwrap().unwrap();
assert_eq!(active.theme.kind, kind);
}
}
#[test]
fn theme_frame_follows_the_selection() {
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
write_theme_pair(dir.path());
f.host.install_from_dir(dir.path()).unwrap();
// Nothing to attach it to without a selected theme.
f.host.set_theme_frame(Some("#111111")).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_theme(Some(("theme-pair", "night"))).unwrap();
assert!(f.host.set_theme_frame(Some("hsl(220, 13%, 15%)")).is_err());
f.host.set_theme_frame(Some("#111111")).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#111111"));
f.host.set_enabled("theme-pair", false).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_enabled("theme-pair", true).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#111111"));
// A new selection drops the old theme's colour until the UI reports the new one.
f.host.set_theme(Some(("theme-pair", "day"))).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_theme_frame(Some("#eeeeee")).unwrap();
f.host.set_theme(None).unwrap();
assert_eq!(f.host.startup_frame(), None);
}
#[test]
fn theme_with_broken_editor_scheme_fails_to_load() {
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("t")).unwrap();
std::fs::write(
dir.path().join("plugin.toml"),
"id = \"bad-scheme\"\nname = \"B\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\
[[contributes.themes]]\npath = \"t/a.json\"\neditor-scheme = \"t/a.scheme.json\"\n",
)
.unwrap();
std::fs::write(dir.path().join("t/a.json"), r##"{"id":"a","name":"A","kind":"light","tokens":{}}"##).unwrap();
std::fs::write(dir.path().join("t/a.scheme.json"), "{\n \"tokens\": { \"--caret\": \"url(x)\" }\n}").unwrap();
let info = f.host.install_from_dir(dir.path()).unwrap();
assert_eq!(info.status, PluginStatus::Failed);
assert!(info.error.unwrap_or_default().contains("a.scheme.json"));
}
#[test]
fn state_persists_across_restarts() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("hello")).unwrap();
f.host.install_from_dir(&theme_template()).unwrap();
f.host.set_enabled("hello", false).unwrap();
f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap();
f.host.set_theme_frame(Some("#1f252e")).unwrap();
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
// Known before the plugins are loaded: new windows open in the theme's frame colour.
assert_eq!(again.startup_frame().as_deref(), Some("#1f252e"));
assert!(!again.wait_initial_load(Duration::from_millis(1)));
again.load_all();
assert!(again.wait_initial_load(Duration::ZERO));
let list = again.list(None);
let hello = list.plugins.iter().find(|p| p.id == "hello").unwrap();
assert!(!hello.enabled);
assert_eq!(hello.status, PluginStatus::Disabled);
assert_eq!(list.active_theme.unwrap().theme.id, "harbor-night");
assert!(
again
.run_command("hello", "insert-date", None, Invocation::default())
.unwrap_err()
.message
.contains("disabled")
);
}
#[test]
fn dev_plugins_follow_the_project() {
let f = fixture(Limits::default());
f.host.set_project_policy(Arc::new(|_| true));
let dev = f.project.join(DEV_PLUGINS_DIR).join("toy");
copy_dir(&sample("language-toy"), &dev);
f.host.sync_project(&f.project);
let mine = f.host.list(Some(&f.project));
assert_eq!(mine.plugins.len(), 1);
assert_eq!(mine.plugins[0].source, PluginSource::Dev);
assert!(f.host.list(None).plugins.is_empty(), "dev plugins only show for their project");
assert!(f.host.uninstall("language-toy").is_err());
assert!(f.host.install_from_dir(&sample("language-toy")).is_err(), "id clash with a dev plugin");
// Edit + reload picks up changes.
let manifest = dev.join("plugin.toml");
let src = std::fs::read_to_string(&manifest).unwrap().replace("Toy Language", "Toy Language (dev)");
std::fs::write(&manifest, src).unwrap();
assert_eq!(f.host.reload("language-toy").unwrap().name, "Toy Language (dev)");
std::fs::remove_dir_all(&dev).unwrap();
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
}
#[test]
fn install_validation_and_uninstall() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
// Missing component file.
let pkg = tmp.path().join("broken");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
"id = \"b\"\nname = \"B\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\nwasm = \"x.wasm\"",
)
.unwrap();
assert!(f.host.install_from_dir(&pkg).unwrap_err().message.contains("missing"));
// Not a component.
std::fs::write(pkg.join("x.wasm"), b"\0asm garbage").unwrap();
assert!(f.host.install_from_dir(&pkg).unwrap_err().message.contains("not a valid plugin component"));
// Bad manifest.
std::fs::write(pkg.join("plugin.toml"), "id = \"B!\"").unwrap();
assert!(f.host.install_from_dir(&pkg).is_err());
assert!(f.host.list(None).plugins.is_empty(), "failed installs leave nothing behind");
// A broken package already in the plugins dir shows up as failed.
std::fs::create_dir_all(f.plugins_dir.join("junk")).unwrap();
std::fs::write(f.plugins_dir.join("junk/plugin.toml"), "garbage = ").unwrap();
f.host.load_all();
let junk = &f.host.list(None).plugins[0];
assert_eq!(junk.status, PluginStatus::Failed);
assert!(junk.error.is_some());
// Reinstall replaces; uninstall removes the directory.
f.host.install_from_dir(&sample("hello")).unwrap();
f.host.install_from_dir(&sample("hello")).unwrap();
assert_eq!(f.status("hello"), PluginStatus::Active);
f.host.uninstall("hello").unwrap();
assert!(!f.plugins_dir.join("hello").exists());
assert!(f.host.info_of("hello").is_err());
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() }
}
}
#[test]
fn catalog_lists_sample_packages() {
let tmp = tempfile::tempdir().unwrap();
let broken = tmp.path().join("broken");
std::fs::create_dir_all(&broken).unwrap();
std::fs::write(
broken.join("plugin.toml"),
"id = \"x\"\nname = \"X\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("junk")).unwrap();
std::fs::write(tmp.path().join("junk/plugin.toml"), "garbage = ").unwrap();
let catalog = scan_catalog(&[repo().join("plugins"), repo().join("templates"), tmp.path().to_path_buf()]);
let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect();
for id in ["hello", "theme-harbor-night", "language-toy", "x"] {
assert!(ids.contains(&id), "{id} missing from {ids:?}");
}
let hello = catalog.iter().find(|c| c.id == "hello").unwrap();
assert!(hello.has_code && hello.problem.is_none() && hello.change_notes.is_some());
assert_eq!(hello.commands.len(), 2);
let theme = catalog.iter().find(|c| c.id == "theme-harbor-night").unwrap();
assert_eq!((theme.theme_count, theme.has_code), (1, false));
let x = catalog.iter().find(|c| c.id == "x").unwrap();
assert!(x.problem.as_deref().unwrap().contains("plugin.wasm"));
}
#[test]
fn restricted_dev_plugin_has_no_activation_registration() {
let f = fixture(Limits::default());
let dev = f.project.join(DEV_PLUGINS_DIR).join("hello");
copy_dir(&sample("hello"), &dev);
let manifest = dev.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap();
std::fs::write(&manifest, text.split("[[contributes.commands]]").next().unwrap()).unwrap();
assert!(PluginManifest::load(&dev).unwrap().contributes.commands.is_empty());
let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false));
let policy = trusted.clone();
f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire)));
f.host.sync_project(&f.project);
let untrusted = f.host.list(Some(&f.project));
assert!(
!untrusted.plugins.iter().flat_map(|plugin| &plugin.commands).any(|command| command.id == "word-count"),
"untrusted development plugin registered its activation witness"
);
assert!(untrusted.plugins.is_empty());
assert!(f.host.info_of("hello").is_err());
// Positive control: activation itself registers these commands, not the manifest.
trusted.store(true, std::sync::atomic::Ordering::Release);
f.host.sync_project(&f.project);
let info = f.host.info_of("hello").unwrap();
assert_eq!(info.status, PluginStatus::Active);
assert!(info.commands.iter().any(|c| c.id == "word-count"));
trusted.store(false, std::sync::atomic::Ordering::Release);
assert!(f.host.reload("hello").is_err());
assert!(f.host.set_enabled("hello", true).is_err());
assert!(f.run("hello", "word-count", None).is_err());
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
}
/// Zips the files of `dir` under `prefix` (recursively).
fn add_dir(zip: &mut zip::ZipWriter<std::fs::File>, dir: &Path, prefix: &str) {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let name = format!("{prefix}{}", entry.file_name().to_string_lossy());
if entry.path().is_dir() {
add_dir(zip, &entry.path(), &format!("{name}/"));
} else {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(&std::fs::read(entry.path()).unwrap()).unwrap();
}
}
}
fn zip_of(dir: &Path, prefix: &str, out: &Path) -> PathBuf {
let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap());
add_dir(&mut zip, dir, prefix);
zip.finish().unwrap();
out.to_path_buf()
}
#[test]
fn installs_zips_with_the_package_at_the_root_or_in_one_folder() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
for (name, prefix) in [("root.ZIP", ""), ("folder.zip", "hello-1.0/")] {
let zip = zip_of(&sample("hello"), prefix, &tmp.path().join(name));
let info = f.host.install_from_path(&zip).unwrap();
assert_eq!((info.id.as_str(), info.status), ("hello", PluginStatus::Active), "{:?}", info.error);
assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file());
}
// Still runs from the installed copy, not the unpacked archive.
assert!(f.run("hello", "insert-date", None).unwrap().is_some());
}
#[test]
fn installs_from_a_manifest_or_a_folder() {
let f = fixture(Limits::default());
assert_eq!(f.host.install_from_path(&sample("hello").join("plugin.toml")).unwrap().id, "hello");
assert_eq!(f.host.install_from_path(&sample("hello")).unwrap().id, "hello");
}
#[test]
fn ignores_archiver_metadata_next_to_the_package_folder() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("mac.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
add_dir(&mut zip, &sample("hello"), "hello/");
zip.start_file("__MACOSX/hello/._plugin.toml", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(b"metadata").unwrap();
zip.finish().unwrap();
assert_eq!(f.host.install_from_path(&out).unwrap().id, "hello");
}
#[test]
fn refuses_zips_that_are_not_safe_plugin_packages() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let options = zip::write::SimpleFileOptions::default;
let no_manifest = tmp.path().join("idea-plugin.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&no_manifest).unwrap());
zip.start_file("idea-plugin/lib/plugin.jar", options()).unwrap();
zip.finish().unwrap();
let error = f.host.install_from_path(&no_manifest).unwrap_err();
assert!(error.message.contains("no plugin.toml"), "{}", error.message);
let escape = tmp.path().join("escape.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&escape).unwrap());
zip.start_file("../../evil.txt", options()).unwrap();
zip.write_all(b"evil").unwrap();
zip.finish().unwrap();
let error = f.host.install_from_path(&escape).unwrap_err();
assert!(error.message.contains("unsafe path"), "{}", error.message);
let link = tmp.path().join("link.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&link).unwrap());
zip.add_symlink("plugin.toml", "/etc/passwd", options()).unwrap();
zip.finish().unwrap();
let error = f.host.install_from_path(&link).unwrap_err();
assert!(error.message.contains("symbolic link"), "{}", error.message);
let garbage = tmp.path().join("garbage.zip");
std::fs::write(&garbage, "not a zip").unwrap();
let error = f.host.install_from_path(&garbage).unwrap_err();
assert!(error.message.contains("not a valid plugin archive"), "{}", error.message);
assert!(!f.plugins_dir.join("hello").exists());
}
+352
Переглянути файл
@@ -0,0 +1,352 @@
//! P0 experiments (docs/plugin_p0.md), 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
//! `tests/fixtures/p0-probe/build.sh`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use wasmtime::component::{Component, HasSelf, Linker, Resource, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::p2::{DynPollable, Pollable, subscribe};
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
mod bindings {
wasmtime::component::bindgen!({
world: "probe-host",
path: "tests/fixtures/p0-probe/wit",
imports: {
"openidea:probe/host-api.sleep-ms": async | trappable,
default: trappable,
},
exports: { default: async },
with: {
"wasi:io/poll": wasmtime_wasi::p2::bindings::io::poll,
"openidea:probe/host-api.task": super::Task,
},
});
}
use bindings::openidea::probe::host_api;
/// Host side of the `task` resource: a queue the host pushes into.
pub struct Task {
queue: Arc<parking_lot::Mutex<Vec<String>>>,
cancelled: Arc<AtomicBool>,
notify: Arc<tokio::sync::Notify>,
progress: Arc<AtomicU64>,
}
/// The pollable of `next-ready`: ready once an item is queued or the task is cancelled.
struct NextReady {
queue: Arc<parking_lot::Mutex<Vec<String>>>,
cancelled: Arc<AtomicBool>,
notify: Arc<tokio::sync::Notify>,
}
#[wasmtime_wasi::async_trait]
impl Pollable for NextReady {
async fn ready(&mut self) {
loop {
let notified = self.notify.notified();
if self.cancelled.load(Ordering::Acquire) || !self.queue.lock().is_empty() {
return;
}
notified.await;
}
}
}
#[derive(Clone, Default)]
struct TaskHandle {
queue: Arc<parking_lot::Mutex<Vec<String>>>,
cancelled: Arc<AtomicBool>,
notify: Arc<tokio::sync::Notify>,
progress: Arc<AtomicU64>,
}
impl TaskHandle {
fn push(&self, item: &str) {
self.queue.lock().push(item.to_owned());
self.notify.notify_waiters();
}
fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
self.notify.notify_waiters();
}
}
struct Data {
wasi: WasiCtx,
table: ResourceTable,
task: TaskHandle,
}
impl WasiView for Data {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.wasi, table: &mut self.table }
}
}
impl host_api::HostTask for Data {
fn report(&mut self, task: Resource<Task>, done: u32, _total: Option<u32>) -> wasmtime::Result<()> {
self.table.get(&task)?.progress.store(u64::from(done), Ordering::Release);
Ok(())
}
fn is_cancelled(&mut self, task: Resource<Task>) -> wasmtime::Result<bool> {
Ok(self.table.get(&task)?.cancelled.load(Ordering::Acquire))
}
fn next_ready(&mut self, task: Resource<Task>) -> wasmtime::Result<Resource<DynPollable>> {
let t = self.table.get(&task)?;
let ready = NextReady { queue: t.queue.clone(), cancelled: t.cancelled.clone(), notify: t.notify.clone() };
let r = self.table.push(ready)?;
subscribe(&mut self.table, r)
}
fn take(&mut self, task: Resource<Task>) -> wasmtime::Result<Option<String>> {
let mut q = self.table.get(&task)?.queue.lock();
Ok(if q.is_empty() { None } else { Some(q.remove(0)) })
}
fn drop(&mut self, task: Resource<Task>) -> wasmtime::Result<()> {
self.table.delete(task)?;
Ok(())
}
}
impl host_api::Host for Data {
fn current_task(&mut self) -> wasmtime::Result<Resource<Task>> {
let h = self.task.clone();
Ok(self.table.push(Task { queue: h.queue, cancelled: h.cancelled, notify: h.notify, progress: h.progress })?)
}
async fn sleep_ms(&mut self, ms: u32) -> wasmtime::Result<()> {
tokio::time::sleep(Duration::from_millis(u64::from(ms))).await;
Ok(())
}
}
fn dist(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p0-probe/dist").join(name)
}
struct Env {
engine: Engine,
linker: Linker<Data>,
stop: Arc<AtomicBool>,
}
impl Drop for Env {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
}
}
fn env() -> Env {
let mut config = Config::new();
config.wasm_component_model(true).epoch_interruption(true).consume_fuel(true);
let engine = Engine::new(&config).unwrap();
let mut linker = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap();
host_api::add_to_linker::<_, HasSelf<_>>(&mut linker, |d| d).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let (weak, flag) = (engine.weak(), stop.clone());
std::thread::spawn(move || {
while !flag.load(Ordering::Acquire)
&& let Some(e) = weak.upgrade()
{
e.increment_epoch();
drop(e);
std::thread::sleep(Duration::from_millis(5));
}
});
Env { engine, linker, stop }
}
fn store(env: &Env, task: TaskHandle) -> Store<Data> {
let data = Data { wasi: WasiCtx::builder().build(), table: ResourceTable::new(), task };
let mut store = Store::new(&env.engine, data);
store.set_fuel(u64::MAX).unwrap();
// Yield to the executor every tick instead of trapping: long calls share the thread.
store.epoch_deadline_async_yield_and_update(1);
store
}
const BASE: &str = "openidea:probe/[email protected]";
const CODE_ACTIONS: &str = "openidea:probe/[email protected]";
/// P0-1: a component that lacks optional exports still binds; presence is
/// detectable per interface, and present interfaces are callable.
#[tokio::test]
async fn optional_exports_bind_per_interface() {
let env = env();
let min = Component::from_file(&env.engine, dist("min.wasm")).unwrap();
let full = Component::from_file(&env.engine, dist("full.wasm")).unwrap();
// Presence is visible from the compiled component, before instantiation.
assert!(min.get_export_index(None, BASE).is_some());
assert!(min.get_export_index(None, CODE_ACTIONS).is_none());
assert!(full.get_export_index(None, CODE_ACTIONS).is_some());
// The whole-world binding requires every export ...
let pre_min = env.linker.instantiate_pre(&min).unwrap();
assert!(bindings::ProbeHostPre::new(pre_min.clone()).is_err());
// ... but per-interface indices bind what is there.
use bindings::exports::openidea::probe::{base, code_actions, completion};
let base_idx = base::GuestIndices::new(&pre_min).unwrap();
assert!(code_actions::GuestIndices::new(&pre_min).is_err());
let mut s = store(&env, TaskHandle::default());
let inst = pre_min.instantiate_async(&mut s).await.unwrap();
let b = base_idx.load(&mut s, &inst).unwrap();
assert_eq!(b.call_run(&mut s, "echo", "hi").await.unwrap().unwrap(), "hi");
let pre_full = env.linker.instantiate_pre(&full).unwrap();
let ca_idx = code_actions::GuestIndices::new(&pre_full).unwrap();
let co_idx = completion::GuestIndices::new(&pre_full).unwrap();
let mut s = store(&env, TaskHandle::default());
let inst = pre_full.instantiate_async(&mut s).await.unwrap();
let ca = ca_idx.load(&mut s, &inst).unwrap();
assert_eq!(ca.call_actions(&mut s, "a.rs").await.unwrap(), ["fix:a.rs"]);
let co = co_idx.load(&mut s, &inst).unwrap();
assert_eq!(co.call_complete(&mut s, "x").await.unwrap(), ["x1", "x2"]);
}
/// P0-2a: with `epoch_deadline_async_yield_and_update`, a spinning guest yields,
/// so other tasks on the same (single) thread keep running.
#[tokio::test(flavor = "current_thread")]
async fn spinning_guest_yields_to_the_executor() {
let env = env();
let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap();
let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap();
let mut s = store(&env, TaskHandle::default());
let inst = pre.instantiate_async(&mut s).await.unwrap();
let base = idx.load(&mut s, &inst).unwrap();
let ticks = Arc::new(AtomicU64::new(0));
let ticker = {
let ticks = ticks.clone();
tokio::spawn(async move {
loop {
ticks.fetch_add(1, Ordering::Relaxed);
tokio::task::yield_now().await;
}
})
};
let spin = base.call_run(&mut s, "spin", "");
assert!(tokio::time::timeout(Duration::from_millis(200), spin).await.is_err(), "spin never returns");
ticker.abort();
assert!(ticks.load(Ordering::Relaxed) > 10, "the executor kept running other tasks while the guest spun");
}
/// P0-2b: dropping the call future cancels the call — and poisons the instance:
/// the next call fails with `CannotEnterComponent`. A fresh instance from the
/// same `InstancePre` is cheap (no compilation) and starts with fresh state.
#[tokio::test]
async fn cancelled_instance_cannot_be_reused_but_reinstantiates_fast() {
let env = env();
let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap();
let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap();
let mut s = store(&env, TaskHandle::default());
let inst = pre.instantiate_async(&mut s).await.unwrap();
let base = idx.load(&mut s, &inst).unwrap();
assert_eq!(base.call_run(&mut s, "count", "").await.unwrap().unwrap(), "1");
// Cancel while the guest computes.
let _ = tokio::time::timeout(Duration::from_millis(50), base.call_run(&mut s, "spin", "")).await;
let err = base.call_run(&mut s, "echo", "x").await.unwrap_err();
assert_eq!(err.downcast_ref::<wasmtime::Trap>(), Some(&wasmtime::Trap::CannotEnterComponent), "{err:?}");
// Cancel while the guest waits in an async host import: same result.
let mut s = store(&env, TaskHandle::default());
let inst = pre.instantiate_async(&mut s).await.unwrap();
let base = idx.load(&mut s, &inst).unwrap();
let _ = tokio::time::timeout(Duration::from_millis(20), base.call_run(&mut s, "sleep", "5000")).await;
assert!(base.call_run(&mut s, "echo", "x").await.is_err());
// Re-instantiation: a new store + instance from the pre-instantiated component.
let started = Instant::now();
let mut runs = 0;
while runs < 20 {
let mut s = store(&env, TaskHandle::default());
let inst = pre.instantiate_async(&mut s).await.unwrap();
let base = idx.load(&mut s, &inst).unwrap();
assert_eq!(base.call_run(&mut s, "count", "").await.unwrap().unwrap(), "1", "state starts fresh");
runs += 1;
}
let per = started.elapsed() / runs;
eprintln!("re-instantiate + first call: {per:?} per instance");
assert!(per < Duration::from_millis(30), "{per:?}");
}
/// P0-2c: a guest that returns an error (not a trap) leaves the instance usable,
/// and an async host import does not block the thread.
#[tokio::test(flavor = "current_thread")]
async fn async_host_import_does_not_block() {
let env = env();
let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap();
let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap();
let mut s = store(&env, TaskHandle::default());
let inst = pre.instantiate_async(&mut s).await.unwrap();
let base = idx.load(&mut s, &inst).unwrap();
assert!(base.call_run(&mut s, "nope", "").await.unwrap().is_err());
let other = tokio::spawn(async { tokio::time::sleep(Duration::from_millis(10)).await });
let started = Instant::now();
assert_eq!(base.call_run(&mut s, "sleep", "100").await.unwrap().unwrap(), "slept");
assert!(other.is_finished(), "the other task ran during the host sleep");
assert!(started.elapsed() >= Duration::from_millis(100));
assert_eq!(base.call_run(&mut s, "echo", "still").await.unwrap().unwrap(), "still");
}
/// P0-3: streaming + cancellation on WASI p2 alone: the guest blocks on a host
/// `pollable` (which yields to the executor), consumes pushed items, reports
/// progress, and returns cleanly when the host flags cancellation — the instance
/// stays usable (cooperative cancellation, unlike dropping the future).
#[tokio::test]
async fn pollable_task_streams_and_cancels_cooperatively() {
let env = env();
let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap();
let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap();
let task = TaskHandle::default();
let mut s = store(&env, task.clone());
let inst = pre.instantiate_async(&mut s).await.unwrap();
let base = idx.load(&mut s, &inst).unwrap();
let driver = {
let task = task.clone();
tokio::spawn(async move {
for item in ["a", "b", "c"] {
tokio::time::sleep(Duration::from_millis(10)).await;
task.push(item);
}
// Wait until the guest consumed everything, then cancel.
while task.progress.load(Ordering::Acquire) < 3 {
tokio::time::sleep(Duration::from_millis(2)).await;
}
task.cancel();
})
};
let out = tokio::time::timeout(Duration::from_secs(5), base.call_run(&mut s, "task", "")).await.unwrap();
driver.await.unwrap();
assert_eq!(out.unwrap().unwrap(), "cancelled after a,b,c");
assert_eq!(task.progress.load(Ordering::Acquire), 3);
assert_eq!(base.call_run(&mut s, "echo", "reusable").await.unwrap().unwrap(), "reusable");
}
/// Serialized compilation output loads without recompiling (P4 cache).
#[test]
fn serialized_component_round_trips() {
let env = env();
let bytes = std::fs::read(dist("full.wasm")).unwrap();
let t = Instant::now();
let compiled = Component::new(&env.engine, &bytes).unwrap();
let compile = t.elapsed();
let blob = compiled.serialize().unwrap();
let t = Instant::now();
// SAFETY: the blob was produced by this engine just above.
let loaded = unsafe { Component::deserialize(&env.engine, &blob) }.unwrap();
let load = t.elapsed();
eprintln!("compile {compile:?}, deserialize {load:?}");
assert!(loaded.get_export_index(None, CODE_ACTIONS).is_some());
assert!(load < compile);
}
+1
Переглянути файл
@@ -40,6 +40,7 @@ pub(crate) fn detect_with_profiles(
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_godot(root, &mut out);
crate::external::detect(root, &mut out);
let mut seen = HashSet::new();
out.retain(|c| seen.insert(c.id.clone()));
+93
Переглянути файл
@@ -0,0 +1,93 @@
//! Run configurations detected outside this crate: plugins' `runDetectors`
//! (docs/plugin_plan.md P3). 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.
use crate::RunConfig;
use parking_lot::RwLock;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Id prefix of externally detected configurations (`plugin:<plugin>:<id>`).
pub const EXTERNAL_PREFIX: &str = "plugin:";
pub trait ExternalDetection: Send + Sync {
/// Configurations for the project at `root`; ids start with [`EXTERNAL_PREFIX`].
fn detect(&self, root: &Path) -> Vec<RunConfig>;
/// The program to start for `config` (one this provider detected), or why
/// it may not start.
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String>;
}
static PROVIDER: RwLock<Option<Arc<dyn ExternalDetection>>> = RwLock::new(None);
/// Installs (or removes) the provider [`crate::detect`] and runs consult.
pub fn set_external_detection(provider: Option<Arc<dyn ExternalDetection>>) {
*PROVIDER.write() = provider;
}
pub(crate) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
let provider = PROVIDER.read().clone();
if let Some(p) = provider {
out.extend(p.detect(root).into_iter().filter(|c| c.id.starts_with(EXTERNAL_PREFIX)));
}
}
/// `config` with the program its provider approved, for an externally
/// detected configuration; `None` for every other one.
pub(crate) fn approved(config: &RunConfig) -> Option<Result<RunConfig, String>> {
if !config.detected || !config.id.starts_with(EXTERNAL_PREFIX) {
return None;
}
let provider = PROVIDER.read().clone();
Some(match provider {
Some(p) => p
.launch(config)
.map(|program| RunConfig { command: program.to_string_lossy().into_owned(), ..config.clone() }),
None => Err("the plugin that detected this configuration is not loaded".into()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ProcessManager, StartOptions};
/// Answers only for projects with its own marker (tests share the provider).
struct Fake;
const MARKER: &str = "fake-external-detection.marker";
impl ExternalDetection for Fake {
fn detect(&self, root: &Path) -> Vec<RunConfig> {
if !root.join(MARKER).exists() {
return Vec::new();
}
let mut c: RunConfig = serde_json::from_value(serde_json::json!({
"id": "plugin:acme:x", "name": "x", "kind": "plugin", "command": "x", "args": ["a"],
"cwd": null, "env": {}, "detected": true, "isTest": false, "debugAdapter": null
}))
.unwrap();
c.detected = true;
let other = RunConfig { id: "not-prefixed".into(), ..c.clone() };
vec![c, other]
}
fn launch(&self, _: &RunConfig) -> Result<PathBuf, String> {
Err("not allowed".into())
}
}
#[test]
fn external_configs_join_detection_and_launch_only_when_approved() {
set_external_detection(Some(Arc::new(Fake)));
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(MARKER), "").unwrap();
let configs = crate::detect(tmp.path());
assert_eq!(configs.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["plugin:acme:x"]);
let err = ProcessManager::new()
.start(tmp.path(), &configs[0], &StartOptions::default(), Arc::new(ide_core::NullSink), Box::new(|_| {}))
.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied);
assert!(err.message.contains("not allowed"));
}
}
+3
Переглянути файл
@@ -4,6 +4,7 @@ mod config;
mod detect;
pub mod dotnet;
pub mod envfile;
mod external;
pub mod go;
pub mod gradle_jvm;
pub mod import;
@@ -192,6 +193,8 @@ pub struct RunInfo {
pub pty: bool,
}
pub use external::{EXTERNAL_PREFIX, ExternalDetection, set_external_detection};
/// Detects configs from package.json scripts, Cargo.toml targets, build.gradle(.kts),
/// pom.xml, go.mod, pyproject/pytest, Makefile.
pub fn detect(root: &Path) -> Vec<RunConfig> {
+6
Переглянути файл
@@ -329,6 +329,12 @@ impl ProcessManager {
if config.command.trim().is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
let approved = match crate::external::approved(config) {
Some(Ok(c)) => Some(c),
Some(Err(e)) => return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e)),
None => None,
};
let config = approved.as_ref().unwrap_or(config);
let plan = crate::go::plan(root, config)?;
let followup = plan.as_ref().and_then(|p| p.run.as_ref()).map(|run| {
let cwd = resolve_cwd(root, run.cwd.as_deref());
+3
Переглянути файл
@@ -544,6 +544,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
max_java: None,
launch_error: None,
tsserver_project: false,
pinned: None,
}
}
@@ -553,6 +554,8 @@ pub fn server_launch_overrides(cfg: &mut LanguageServerConfig, s: &Settings) {
let Some(ls) = s.language_servers.get(&cfg.name) else { return };
if let Some(command) = non_empty(ls.command.trim()) {
cfg.command = command.to_owned();
// The user's own program, not the one approved for a plugin.
cfg.pinned = None;
}
if let Some(args) = &ls.args {
cfg.args = args.clone();
+1
Переглянути файл
@@ -36,6 +36,7 @@ fn server(name: &str) -> LanguageServerConfig {
max_java: None,
launch_error: None,
tsserver_project: false,
pinned: None,
}
}
+21
Переглянути файл
@@ -563,6 +563,27 @@ impl Documents {
})
}
/// [`apply_edits`](Self::apply_edits) only if the document is still at
/// `version` (checked under the document's lock). `Ok(None)` when it
/// changed since; nothing is applied then.
pub fn apply_edits_at(&self, doc: DocId, version: u32, edits: &[TextEdit]) -> IdeResult<Option<u32>> {
self.with_doc(doc, |st| {
if st.doc.version() != version {
return Ok(None);
}
if st.read_only {
return Err(read_only_error(&st.path));
}
if edits.is_empty() {
return Ok(Some(version));
}
let (set, applied, bytes) = st.doc.apply_edits_detailed(edits)?;
self.patched(doc, st, version, set, &applied, &bytes);
self.set_dirty(doc, st, true);
Ok(Some(st.doc.version()))
})
}
fn patched(
&self,
doc: DocId,
+159
Переглянути файл
@@ -0,0 +1,159 @@
# 插件系统 P0 验证结论
对应 `docs/plugin_plan.md` 第 5 节 P0。实验代码保留为回归测试:
`crates/ide-plugin/tests/p0_probe.rs`,探针组件在 `crates/ide-plugin/tests/fixtures/p0-probe/`
(`build.sh` 生成 `dist/min.wasm` 与 `dist/full.wasm`)。环境:wasmtime 49.0.5、wasmtime-wasi 49(p2)、
wit-bindgen 0.63、`wasm32-wasip2`,Apple Silicon,debug 构建(release 数字括注)。
## 1. 可选导出能否按接口绑定 —— 可以
测试:`optional_exports_bind_per_interface`。
- `Component::get_export_index(None, "openidea:probe/[email protected]")` 在**编译后、实例化前**就能判断
组件有没有导出某个接口。安装时据此算出插件实现了哪些 Provider,写进贡献索引,启动时不需要实例化。
- `bindgen!` 生成的整 world 绑定(`ProbeHostPre::new`)要求所有导出都在,缺一个就失败;
但每个导出接口各有一个 `GuestIndices::new(&instance_pre)`,缺失时返回 `Err`,存在时正常 `load` 和调用。
- 结论:**不需要退回“基础 world + 多个组合 world”**。宿主用一个包含全部可选导出的 world 生成绑定,
只用到每个接口的 `GuestIndices`,不用整 world 的 `*Pre`。插件作者侧:SDK 为每个 Provider 提供
feature 或宏,只导出实现了的接口(wit-bindgen 按 world 生成,SDK 内置几个常用组合 world 即可,
宿主不关心组合方式)。
对设计的影响:无,4.3 节的“可选导出”成立。
## 2. async 调用、取消与实例复用
测试:`spinning_guest_yields_to_the_executor`、`cancelled_instance_cannot_be_reused_but_reinstantiates_fast`、
`async_host_import_does_not_block`。
- wasmtime 49 里 `Config::async_support` 已废弃且无效果,async 能力由 `async` cargo feature 和调用方式决定。
- `store.epoch_deadline_async_yield_and_update(1)` + `call_async`:死循环的 guest 每个 epoch tick 让出一次,
**单线程**执行器上的其他任务照常推进。长调用不再独占线程。
- async 宿主导入(`imports: { "...sleep-ms": async }`)等待期间不阻塞线程。
- guest 返回 `Err`(不是 trap)后实例照常可用。
- **取消(丢弃 future)后实例不能复用**:下一次调用返回 `Trap::CannotEnterComponent`。
无论取消发生在 guest 计算中还是 async 宿主导入的等待中,结果相同。原因:wasmtime 在 fiber 被
dispose 时把错误传回 `catch_traps`,组件 store 被标记为 `trapped`(`may_enter() == false`)。
这是 wasmtime 的有意行为,不能绕开。
- **重建很便宜**:从已有的 `InstancePre` 新建 store 并实例化,加上第一次调用,约 **50µs**(release 约 40µs)。
代价在于丢失实例内存里的状态,而不是时间。
结论与对 4.5 节的修订:
1. 取消 = 丢弃 future + 丢弃 store;下次调用前从 `InstancePre` 重建。不需要冷编译。
2. 两段式超时仍然保留,但理由从“避免冷启动”改为“保留实例状态”:
- **软超时**(编辑路径 50ms):调用方不再等待、结果丢弃,调用在 actor 内继续执行完,实例和状态保留;
同一插件的后续调用排队,新请求到来时旧请求如仍在排队则直接丢弃(不进入 guest)。
- **硬超时**(编辑路径 500ms,命令 5s):丢弃 future,重建实例,计入熔断计数。
3. 用户主动取消(例如 AI Completion 的旧请求)优先走**协作式取消**(见第 3 节),实例不受影响;
协作式取消在宽限期内没有返回时,再升级为硬取消。
4. 插件需要跨调用保留的状态一律走 `storage`;实例内存只当缓存。SDK 文档写明这一点。
5. fuel 保留作为死循环兜底;与 epoch 让出同时启用没有冲突。
## 3. WASI p2 上的流式与任务模型 —— 可行,不依赖 p3
测试:`pollable_task_streams_and_cancels_cooperatively`。
- 宿主定义 `task` 资源,方法 `next-ready() -> pollable` 返回 `wasi:io/poll.pollable`(宿主用
`wasmtime_wasi::p2::subscribe` 包装自定义 `Pollable`)。guest 调 `pollable.block()` 时,wasmtime 在
async 宿主里等待该 future,执行器线程不被占用。
- guest 循环“等待 → 取数据 → `report` 进度 → 检查 `is-cancelled`”,宿主推送数据并在最后设置取消标志;
guest 正常返回,**实例保持可用**。
- 流式输出(例如 AI Completion 逐块返回、Kubernetes 日志)用同一模式的反方向:宿主提供
`output-stream` 风格的资源(`write(chunk)` 是 async 宿主导入),或者 guest 通过 `task.emit(chunk)` 推送;
两者都只需要 p2 资源 + pollable。
- 结论:4.3 节 `tasks` 接口定为:
```wit
resource task {
report: func(done: u32, total: option<u32>);
is-cancelled: func() -> bool;
/// 取消或有新输入时就绪,供 guest 在等待时让出。
cancelled: func() -> pollable;
}
```
`net` 的流式响应体直接复用 `wasi:io/streams.input-stream`,不自造类型。
## 4. `syntax`、`index` 接口
依据:`ide-syntax`(`StructureService`:`node_at`、`syntax_errors`、`locals`、`identifiers_named`、
`structural_ranges`,全部基于 `ide-language` 的 `SyntaxCache` 增量树,0 基行、UTF-16 列)和 `ide-index`
(`ProjectIndex`:`find_files`、`find_symbols`、`symbols_named`,后台 worker + redb 持久化)。
Solidity 与 MyBatis Navigator 两个原型需要的能力如下,接口按它们的最小需求定稿。
### 4.1 `syntax`
两个方向,分开设计:
- **读宿主的树(import)**:适用于宿主有 grammar 的语言(MyBatis Navigator 读 Java、XML)。
```wit
interface syntax {
resource tree {
/// 0 基行、UTF-16 列;返回包含该位置的最内层命名节点。
node-at: func(pos: position) -> option<node>;
/// tree-sitter 查询语法;只返回被捕获的节点,最多 `limit` 个。
query: func(source: string, range: option<range>, limit: u32) -> result<list<capture>, error>;
errors: func(limit: u32) -> list<range>;
}
record node { kind: string, range: range, text: string, parent-kind: option<string> }
record capture { name: string, node: node }
/// 打开的文档用增量缓存树;未打开的文件现场解析(受 `project.read` 约束)。
parse: func(path: string) -> result<tree, error>;
}
```
不暴露节点句柄的游走 API(`parent`/`children` 逐个跨边界调用代价高);查询一次返回结果列表。
- **插件提供语法树(export,P3)**:宿主没有 grammar 的语言(Solidity)。宿主不加载插件提供的原生
grammar;插件在 Wasm 里自带解析器,导出 `syntax-provider`,按需返回**扁平结果**而不是树:
```wit
interface syntax-provider {
highlights: func(text: string) -> list<token>; // range + 语义类别(映射到 --syn-*)
outline: func(text: string) -> list<outline-item>;
folding: func(text: string) -> list<range>;
}
```
宿主把结果转换成现有的 `OutlineItem`、`FoldRange`、`SyntaxHighlights`,编辑器侧无感知。
这条路径在编辑路径上,适用 4.5 节的编辑路径预算。
原因:tree-sitter 的 Wasm grammar 需要 `tree-sitter` 的 wasm 特性和额外的 wasmtime 实例,
且 grammar 不能表达大纲和折叠;扁平结果把实现方式留给插件。
### 4.2 `index`
- **查宿主的符号索引(import)**:
```wit
interface index {
find-symbols: func(query: string, types-only: bool, limit: u32) -> list<symbol>;
symbols-named: func(name: string, kind: option<symbol-kind>, limit: u32) -> list<symbol>;
find-files: func(query: string, limit: u32) -> list<string>;
/// 插件自定义索引的查询,见下。
lookup: func(index: string, key: string, limit: u32) -> list<entry>;
}
record entry { key: string, value: string, path: string, range: range }
```
- **插件自定义索引(export + 声明)**:MyBatis Navigator 要“Mapper 接口方法 ↔ XML 语句 id”的跨文件映射,
宿主的符号索引不认识 XML 语句。做法:
```toml
[[contributes.indexes]]
id = "statements"
files = ["**/*Mapper.xml", "**/*Mapper.java"]
version = 1 # 改变索引格式时递增,宿主据此重建
```
插件导出 `indexer.index-file(path, text) -> list<entry>`;宿主在**后台**(复用 `ide-index` 的 worker
调度和 redb 存储,按插件 id + 索引 id 分表)对匹配文件调用,文件变化时增量更新,插件禁用或卸载时删表。
插件运行时只能 `lookup` 自己的索引。索引任务是后台任务,适用后台预算并上报进度。
对设计的影响:4.3 节的 `syntax`、`index` 按上面两节定稿;新增 `contributes.indexes` 扩展点(P6)。
## 5. 文档修正
`plugins/README.md` 第 4 节原文暗示插件语言里的 `language-servers`、`debugger` 会生效。实际上插件语言
只经 `languages` 命令进入前端的语言列表(文件检测、注释、缩进、借用的 CodeMirror 模式),不进入后端
`LanguageRegistry`,因此 LSP、DAP、后端语言检测都不生效。README 已改为如实说明,P3 完成后再恢复。
## 6. 对计划的修订汇总
| 位置 | 原设计 | 修订 |
| -------------------- | ------------------------ | ------------------------------------------------------------ |
| 4.3 可选导出 | 待验证,备选组合 world | 按接口 `GuestIndices` 绑定,安装时用 `get_export_index` 记录 |
| 4.5 超时 | 两段式用于避免冷启动 | 重建约 50µs;两段式用于保留实例状态;用户取消优先协作式 |
| 4.3 `tasks` | `report`、`is-cancelled` | 增加 `cancelled() -> pollable` |
| 4.3 `syntax`/`index` | 待定 | 见第 4 节 |
以上修订已同步到 `docs/plugin_plan.md`。
+601
Переглянути файл
@@ -0,0 +1,601 @@
# 插件系统重构计划
状态: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 纳入性能预算观测 |
+6 -2
Переглянути файл
@@ -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`.",
"$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).",
"regressionTolerance": 0.2,
"regressionFloor": 1,
"ui": {
@@ -83,6 +83,10 @@
"search_5k/literal": 100000,
"search_5k/regex": 100000,
"search_5k/dense_limit": 20000
"search_5k/dense_limit": 20000,
"plugins/load_50": 20000,
"plugins/cold_activation": 30000,
"plugins/syntax_highlights_2k_lines": 50000
}
}
+1 -1
Переглянути файл
@@ -16,7 +16,7 @@ import { evaluate, loadBudgets, printReport, type ScenarioResult } from "./lib/r
const ROOT = resolve(import.meta.dirname, "..");
const TARGET = resolve(ROOT, process.env.CARGO_TARGET_DIR ?? "target");
const OUT = join(ROOT, "target", "perf");
const PACKAGES = ["ide-text", "ide-language", "ide-index", "ide-search"];
const PACKAGES = ["ide-text", "ide-language", "ide-index", "ide-search", "ide-host"];
const { values: args } = parseArgs({
options: {
+509 -112
Переглянути файл
@@ -1,87 +1,201 @@
# Writing OpenIDEA plugins
OpenIDEA plugins are sandboxed **WebAssembly components** (wasmtime, component
model, WASI preview 2), in the style of Zed extensions. A plugin can register
commands (they become IDE actions you can search for and bind to keys), read
project files, insert text into the editor, show notifications and contribute
themes and languages. Plugins that only contribute themes or languages need no
code at all.
model, WASI preview 2). A plugin can register commands (they become IDE actions
you can search for and bind to keys), read project files, edit the focused
editor, react to workbench events, run background tasks, show notifications
and status bar items, keep data in its own storage, read and fetch git
repositories, provide completion, quick fixes, navigation and gutter markers,
call web services and run approved programs, show tool windows and custom
editors, and contribute menus, shortcuts, settings, themes, file icon themes
and languages. Plugins that only contribute data (themes, icons, languages,
menus, settings) need no code at all.
```
plugins/
├─ build.sh # builds Rust plugins into components
├─ hello/ # Rust plugin: two commands
└─ language-toy/ # manifest-only: a language definition
├─ hello/ # Rust plugin built with the SDK: commands, a tree tool window
├─ language-toy/ # data only: a language definition
└─ official/
├─ material-icons/ # data only: a file icon theme
├─ solidity/ # language + syntax provider + run configurations
├─ string-tools/ # 26 text commands, versioned multi-cursor edits
├─ key-promoter/ # action events, storage
├─ git-toolbox/ # on-schedule fetch, tasks, VCS, status bar item
├─ checkstyle/ # one-shot process, diagnostics, quick fixes
├─ ai-completion/ # inline completion over the network, secrets
├─ mybatis/ # navigation, gutter markers, completion across files
├─ kubernetes/ # tree tool window, terminals, settings
└─ csv-editor/ # custom editor sharing the text document
sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
```
The API is defined in [`wit/plugin.wit`](../wit/plugin.wit) (`openidea:plugin/[email protected]`).
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.0.0`, world
`plugin`); the design and roadmap are in
[`docs/plugin_plan.md`](../docs/plugin_plan.md).
## 1. The package
## 1. Quick start
The `openidea-plugin` tool scaffolds, builds, checks and links plugins:
```sh
cargo install --path crates/ide-host --bin openidea-plugin # or: cargo run -p ide-host --bin openidea-plugin -- …
openidea-plugin new my-plugin # Rust command plugin (SDK)
openidea-plugin new my-theme --template theme # also: icon-theme, language
cd my-plugin
cargo test # logic, against the SDK's mock host
openidea-plugin build # → dist/plugin.wasm, then `check`
openidea-plugin link # load this folder in the IDE
```
`build` runs `cargo build --release --target wasm32-wasip2` (installing the
target through rustup if needed), copies the component to the manifest's
`wasm` path and runs `check`. `check` validates the package exactly as
_Install Plugin from Disk…_ would — manifest, every contribution, the SVG
sanitiser, the component — and that the component imports nothing but the
plugin API and WASI. It prints what the user will be asked to grant.
`link` adds the folder to `links.json` in the IDE's plugins directory
(`$OPENIDEA_PLUGINS_DIR`, default `<app-data>/ai.l0ng.openidea/plugins`). Linked
plugins are read from the folder itself, so the edit loop is `build` then
Settings › Plugins › _Reload_. `unlink` and `links` undo and list.
## 2. The package
A plugin is a directory with a `plugin.toml`:
```toml
id = "hello" # [a-z0-9][a-z0-9_-]*, unique
name = "Hello"
version = "0.1.0" # semver
description = "Inserts today's date and counts words."
id = "acme.word-tools" # [a-z0-9][a-z0-9_-]*, optionally with dotted namespaces
name = "Word Tools"
version = "1.2.0" # semver
description = "Counts and transforms words."
authors = ["You <you@example.com>"]
homepage = "https://example.com" # optional
openidea-api = "0.1" # API version this plugin targets
wasm = "dist/plugin.wasm" # omit for contribution-only plugins
permissions = ["project-read", "editor-read", "editor-write"]
homepage = "https://example.com" # optional
license = "MIT" # optional
change-notes = "1.2.0 — …" # shown under What's New
engines.openidea = "^1.0" # the plugin API this package needs
wasm = "dist/plugin.wasm" # omit for data-only plugins
activation = ["on-language:markdown"] # optional, see below
[capabilities]
required = ["project.read", "editor.write"]
optional = ["net:api.example.com"]
# Optional static contributions (active even before / without code):
[[contributes.commands]]
id = "insert-date" # [a-z0-9][a-z0-9._-]*
title = "Hello: Insert Date"
category = "Hello" # group in Search Everywhere / Keymap
[[contributes.themes]]
path = "themes/my-theme.json"
[[contributes.languages]]
path = "languages/my-lang.toml"
id = "count" # [a-z0-9][a-z0-9._-]*
title = "Word Tools: Count Words"
category = "Word Tools" # group in Search Everywhere / Keymap
when = "editorFocus" # optional: same expressions as built-in actions
```
All paths are relative to the package directory and may not leave it.
All paths are relative to the package directory and may not leave it. A
manifest is validated as a whole: if one contribution is invalid, nothing is
installed or loaded. Contribution types the IDE does not know (a newer API) are
skipped with a warning.
### Permissions
Manifests in the 0.1 format (`openidea-api = "0.1"`, `permissions = [...]`)
still load (their permissions become required capabilities; contributions
work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
| Permission | Unlocks |
| -------------- | ------------------------------------------------- |
| `project-read` | `workspace-root`, `read-file` (project root only) |
| `editor-read` | `active-file` |
| `editor-write` | `insert-text`, `replace-selection` |
### Contribution points
`log`, `register-command`, `show-notification`, `register-language` and
`register-theme` are always available. Calling a gated function without its
permission **traps**: the plugin is stopped and the user is told which
permission is missing.
| Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------- | ----------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`) | yes |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`) | yes |
## 2. Writing code (Rust)
Declared contributions are live as soon as the plugin is enabled — commands
show in Find Action, menus and the status bar, shortcuts work, themes and icon
themes can be applied, settings pages are there — **before** any plugin code
is loaded. Menus, shortcuts and status bar items refer to the plugin's
declared commands. A plugin shortcut never takes a keystroke the IDE (or the
user) already uses; the user can rebind it in the keymap editor.
`wasm32-wasip2` produces a component directly, so all you need is rustup:
### Activation
```sh
rustup target add wasm32-wasip2
```
Code is instantiated lazily: when one of the plugin's commands runs, or when
an event in `activation` fires:
`Cargo.toml`:
| Event | Fires when |
| --------------------- | ------------------------------------------------------ |
| `on-command:<id>` | the command runs (implied for every command) |
| `on-language:<id>` | a file of that language is opened (`*`: any) |
| `on-file:<glob>` | a project file matching the glob is opened (`**`) |
| `on-view:<id>` | one of the plugin's views is shown |
| `on-schedule:<secs>` | every `secs` seconds (at least 60): `Event::Scheduled` |
| `on-event:<kind>` | a workbench event of that kind (see _Events_) |
| `on-startup-finished` | shortly after the workbench is ready (use sparingly) |
Until then the plugin is listed as _Inactive_ and costs nothing at start-up.
An instance unused for 5 minutes is reclaimed (its `deactivate` runs) and the
plugin is _Inactive_ again until its next event; `on-schedule` activates it
when due but does not keep it alive. Keep nothing in memory you can't rebuild
in `activate`.
### Capabilities
Code can only do what its capabilities allow, and the user approves them when
installing (or when a new version asks for more). Low-risk capabilities are
listed; high-risk ones are called out as equivalent to running code on the
machine. Users can revoke optional capabilities in Settings › Plugins.
| Capability | Risk | Unlocks today |
| ------------------- | ------ | ----------------------------------------------------------------------------------- |
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
| `editor.read` | low | `TextEditor::document`, `selections` |
| `editor.write` | medium | `TextEditor::apply_edits` |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes) |
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) |
| `storage` | low | `host::storage` (1 MiB per plugin) |
| `events` | low | file opened/saved, editor focused, repository changed events |
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
`process:` capabilities are also derived from what the contributions start: a
language's `language-servers` and `debugger` commands and a run detector's
`command`. Those are optional — refusing one keeps the rest of the plugin
working. On grant the program is resolved to an absolute path and its content
hash is recorded; it is checked again right before every start, and a replaced
file is refused until the user approves it again. In a remote project the
server resolves and pins the program on its own machine (see _Remote
projects_). `fs.read:<path>` can be declared and granted; it unlocks nothing
yet.
Logging, notifications, status bar items, tasks and run-time contributions
are always available. Calling a gated function without its capability returns
`Error::Denied("<capability>")` — the plugin keeps running and can degrade
(Git Toolbox still shows the branch without `vcs.write`).
## 3. Writing code (Rust SDK)
`openidea-plugin new` writes this for you. `Cargo.toml`:
```toml
[package]
name = "my-plugin"
name = "openidea-plugin-word-tools"
version = "0.1.0"
edition = "2024"
[workspace] # keep it out of any parent workspace
[workspace] # keep it out of any parent workspace
[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]
[dependencies]
wit-bindgen = "0.63"
openidea-plugin = { path = "…/sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
@@ -93,24 +207,23 @@ panic = "abort"
`src/lib.rs`:
```rust
wit_bindgen::generate!({ world: "plugin", path: "path/to/openidea/wit" });
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
use openidea::plugin::host::{self, LogLevel, NotificationLevel};
#[derive(Default)]
struct WordTools;
struct MyPlugin;
impl Guest for MyPlugin {
fn activate(_ctx: Context) -> Result<(), String> {
host::register_command("shout", "My Plugin: Shout Selection", Some("My Plugin"));
Ok(())
}
fn deactivate() {}
fn run_command(id: String, arg: Option<String>) -> Result<Option<String>, String> {
match id.as_str() {
impl Plugin for WordTools {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
let editor = ctx.editor().ok_or("no file is open")?;
match id {
"count" => {
let words = editor.document()?.text.split_whitespace().count();
host::notify(NotificationLevel::Info, "Word count", &format!("{words} words"));
Ok(None)
}
// Every selection (a cursor: empty) in one undoable change.
"shout" => {
host::replace_selection(&arg.unwrap_or_default().to_uppercase());
editor.transform_selections(|s| s.to_uppercase())?;
Ok(None)
}
_ => Err(format!("unknown command {id}")),
@@ -118,37 +231,196 @@ impl Guest for MyPlugin {
}
}
export!(MyPlugin);
register_plugin!(WordTools);
```
Build (copy `build.sh` or run it with your plugin's path):
On `wasm32-wasip2` the SDK calls the IDE; on your machine it calls the SDK's
mock host, so the logic is unit-tested with `cargo test`:
```sh
plugins/build.sh hello # → plugins/hello/dist/plugin.wasm
plugins/build.sh /path/to/my-plugin
```rust
#[test]
fn counts_and_shouts() {
let host = openidea_plugin::mock::MockHost::install();
host.open_editor("/project/a.md", "markdown", "one two three", &[(0, 4, 0, 7)]);
WordTools.run_command("count", &host.invocation(), None).unwrap();
assert_eq!(host.notifications()[0].body, "3 words");
WordTools.run_command("shout", &host.invocation(), None).unwrap();
assert_eq!(host.editor_text(), "one TWO three");
}
```
Any language with component-model tooling works (e.g. `componentize-py`,
`jco componentize`, TinyGo): the only contract is the `plugin` world.
The mock enforces capabilities like the IDE (`MockHost::install().with_capabilities(&[...])`),
keeps a versioned editor document (`type_in_editor` makes edits stale), and
records notifications, status items, storage, subscriptions, tasks, fetches,
commands, languages, themes and log lines.
### Host functions
Any language with component-model tooling works (`componentize-py`,
`jco componentize`, TinyGo): the only contract is the `plugin` world in
[`wit/`](../wit/) (`plugin-syntax` adds the syntax provider). Build it yourself,
then run `openidea-plugin check`.
| Function | Notes |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `log(level, msg)` | Goes to the IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
| `register-command(id, title, category?)` | Becomes action `plugin.<plugin-id>.<id>`; call again to rename. Max 256 commands. |
| `show-notification(level, title, body)` | Balloon in the invoking window. |
| `workspace-root()` | Root of the project the command runs in. |
| `active-file()` | Absolute path of the focused editor's file. |
| `read-file(path)` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB. Unsaved editor contents win over disk. |
| `insert-text(text)` | Inserts at every cursor of the active editor (cursor ends after the text). |
| `replace-selection(text)` | Replaces every selection and selects the new text. |
| `register-language(toml)` | Same format as `languages/<id>.toml`. |
| `register-theme(json)` | See _Themes_ below. |
### 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. |
`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.
Returning `Err(msg)` shows _"<title> failed: msg"_; the plugin keeps running.
### Events
| Kind | Capability | Delivered |
| ------------------ | -------------- | ------------------------------------------------------------------------- |
| `file-opened` | `events` | a file opens in an editor |
| `file-saved` | `events` | after the file is written |
| `editor-focused` | `events` | the focused editor changes (path, or none) |
| `vcs-changed` | `events` | a repository's state changed (commit, checkout, fetch…; its root) |
| `action-performed` | `events.input` | an IDE action ran: id, title, source (keyboard, menu, toolbar…), shortcut |
| `scheduled` | — | the `on-schedule` period elapsed |
Events are batched: a running plugin gets them within a fraction of a second,
an idle one collects them for 2 s before it is woken, so frequent events
(Key Promoter's actions) don't keep it alive.
### Providers
Declare what the plugin provides in the editor; only plugins whose
declaration matches the file's language are called (and loaded):
```toml
[[contributes.providers]]
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions"
languages = ["java", "xml"] # editor language ids; "*" for any
```
Then `register_plugin!(MyPlugin, providers)` and implement
`openidea_plugin::providers::Providers` for it: `completion`,
`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
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.
### Network and secrets
`host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host
the plugin was granted with `net:<host>`; redirects to another host are
refused and responses are limited to 8 MiB. Requests are made from the
machine the project is on.
Secrets declared under `[[contributes.secrets]]` are entered by the user in
Settings › Plugins and kept in the system keychain. The plugin never reads
them: it writes `{{secret:<name>}}` in a request header and the host fills it
in on the way out (`secrets` capability). `host::secret_is_set(name)` tells
whether the user has entered one.
### Views and custom editors
A view is data: a toolbar, a body — `Tree`, `List`, `Form`, `Table`,
`Message` or `Html` — and a status line. Declare it, then
`register_plugin!(MyPlugin, views)` (or `(MyPlugin, providers, views)`) and
implement `openidea_plugin::views::Views`:
```rust
impl Views for MyPlugin {
fn render(&mut self, view: &str, viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error> {
Ok(View::new(Body::Tree(vec![TreeItem::new("a", "A").expandable(self.open)]))
.button(Button::new("refresh", "Refresh").icon("refresh")))
}
fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> {
if let ViewEvent::Expand(_) = event {
self.open = true;
host::refresh_view(view);
}
Ok(())
}
}
```
The IDE draws the view with its own components (theme, keyboard, speed
search) and sends what the user does: buttons, select / activate / expand /
collapse of items, form fields, table cells, inserted and deleted rows, and
`html` messages. A tree is a flat list whose items name their `parent`; an
`expandable` item gets its children when it is expanded. A table gets only
the rows it shows (`viewport`): report `total_rows` and return those.
Showing a view activates the plugin (`on-view:<id>`). `views` become tool
windows; a `customEditors` file (`priority = "default"`) opens in the
plugin's editor, or through _Open With_ for `option`. For a custom editor
`ctx.editor()` is its document: read it with `document()` and change it with
`apply_edits(version, …)` — the text editor of the same file follows,
Undo / Redo are one history with it, and Save, the dirty mark and the close
prompts are the document's. _Open as Text_ opens the text editor.
`Html` is the escape hatch for content the declarative bodies can't show. It
runs in a sandboxed frame — an opaque origin under a CSP that allows only its
inline scripts and styles and `data:` images, no network, no IDE API — and
talks to the plugin only through `openidea.postMessage(text)` /
`openidea.onMessage(fn)` (`ViewEvent::Message`, `host::post_view_message`).
### Settings
```toml
[contributes.settings]
schema = "settings.schema.json"
```
```json
{
"properties": {
"context": { "type": "string", "title": "Context", "default": "" },
"tailLines": { "type": "integer", "title": "Log lines", "default": 200 },
"shell": { "type": "string", "enum": ["sh", "bash"], "default": "sh" }
}
}
```
Types are `boolean`, `string` (optionally `enum`, `multiline`), `number` and
`integer`, each with a `default`. The IDE generates the page under
Settings › Plugins (searchable; projects can override values) and the plugin
reads values with `host::setting*`; a stored value of the wrong type falls
back to the default.
### Remote projects
Plugin code runs where the project is. When a window opens a remote project,
the desktop sends its enabled code plugins and their grants to
`openidea-server`, which runs them next to the files: `read_file`,
`find_files`, `run_process`, `fetch` and diagnostics all act on the remote
machine, and `process:` programs are resolved on its `PATH`. Changes on the
desktop (install, disable, grants) follow within a second. Secrets stay in the
desktop's keychain; the server asks for one when a request header needs it.
Status items, notifications, tasks, views, custom editors and terminals of
remote plugins show in that window; a custom editor edits the remote
document.
Project plugins (`.openidea/plugins`) don't run in remote projects yet, and a
plugin's storage on the server starts empty.
### Sandbox and limits
@@ -157,15 +429,23 @@ environment, no network, no inherited stdio — only clocks and randomness from
WASI, plus the host functions above. Every call into the plugin is bounded:
- **CPU:** a fuel budget (≈ 5·10⁹ wasm instructions) per call;
- **time:** 5 s wall clock per call (epoch interruption);
- **time:** 5 s wall clock for commands, activation and deactivation; editor
providers (syntax, completion, code actions, navigation, line markers) get
50 ms — a later result is dropped, but the instance keeps running — and are
interrupted at 500 ms;
- **memory:** 256 MiB of linear memory.
A panic, trap, permission violation or exceeded limit stops the plugin and
marks it _Failed_ for the session (with the reason in Settings › Plugins);
toggling it off and on, or _Reload_, starts a fresh instance. The IDE itself is
never affected.
Each active plugin runs on its own thread; calls are queued, never run
concurrently in one instance. A panic, trap, permission violation or exceeded
limit drops the instance: the user is told once, and the plugin starts afresh
the next time it is needed, after a pause that doubles with each crash in a
row (1 s, 2 s, …). Three crashes in a row mark it _Failed_ for the session
(with the reason in Settings › Plugins); toggling it off and on, or _Reload_,
starts it again. A call that returns normally resets the count. Settings ›
Plugins shows activations, call latency (p95), crashes, late editor results
and reclaims. The IDE itself is never affected.
## 3. Themes
## 4. Themes
```json
{
@@ -183,42 +463,159 @@ values (no `url()`, `;`, braces); `//` and `/* */` comments are allowed. A
manifest theme entry may add `editor-scheme = "themes/x.scheme.json"` (editor
colours, merged over the theme). Pick a theme in Settings › Plugins › _Apply_.
Start from [`templates/theme-plugin`](../templates/theme-plugin); the full guide,
token reference and IntelliJ / VS Code porting tables are in
Start with `openidea-plugin new <dir> --template theme` (or
[`templates/theme-plugin`](../templates/theme-plugin)); the full guide, token
reference and IntelliJ / VS Code porting tables are in
[`docs/plugins/themes.md`](../docs/plugins/themes.md).
## 4. Languages
## 5. File icon themes
```toml
[[contributes.iconThemes]]
id = "material"
label = "Material Icons"
path = "icons/theme.json"
```
`theme.json` maps names to SVGs (paths relative to the package):
```json
{
"iconDefinitions": { "rust": "icons/rust.svg", "folder-src": "icons/folder-src.svg" },
"file": "file", "folder": "folder", "folderExpanded": "folder-open",
"fileExtensions": { "rs": "rust", "d.ts": "typescript-def" },
"fileNames": { "cargo.toml": "cargo" },
"folderNames": { "src": "folder-src" }
}
```
Files match by exact name, then the longest extension (`a.test.ts` tries
`test.ts`, then `ts`), then `file`. Folders match by name, then
`folder`/`folderExpanded`; folders with a role (source root, excluded, module)
keep their built-in icons. Names are case-insensitive. Up to 5000 icons of at
most 64 KiB each. Every SVG is sanitised on install: allow-listed elements and
attributes only, no scripts, event handlers, external references, `url()` or
entities. Apply a theme in Settings › Plugins; see
[`official/material-icons`](official/material-icons) for a complete one.
## 6. Languages
`languages/<id>.toml` files use the IDE's own language format; see any file in
[`languages/`](../languages) (`id`, `name`, `extensions`, `filenames`,
`first-line`, `line-comment`, `block-comment`, `indent`, `ui-mode`,
`framework-ui-modes`, `language-servers`, `debugger`). `language-toy` is a
minimal example. `[[framework-ui-modes]]` entries (`ui-mode`, `root-markers`)
[`languages/`](../languages). `language-toy` is a minimal example,
[`official/solidity`](official/solidity) a complete one.
Plugin languages join the IDE's language registry like built-in ones: file
detection (`extensions`, `filenames`, `first-line`), comments, indentation,
`ui-mode`, `framework-ui-modes`, and the `language-servers` and `debugger`
they declare (each needs its `process:` grant). A plugin cannot replace a
built-in or user definition: their ids, extensions and file names win (users
can still map files to a plugin language in Settings › File Types). In a
remote project the definitions are sent to the server, which starts the
servers there.
`[[framework-ui-modes]]` entries (`ui-mode`, `root-markers`)
replace `ui-mode` for the files of a framework's projects: the first whose
root marker is in a directory above the file, up to the project folder, applies
(see [`languages/html.toml`](../languages/html.toml), Angular templates).
Markers are matched like a language server's `root-markers`.
## 5. Installing and developing
### 6.1 Syntax for languages without a grammar
- **Install:** Settings › Plugins › _Install Plugin from Disk…_ (or the action
_Install Plugin from Disk…_) and pick the package folder. The manifest and the
files it references are copied to `<app-data>/plugins/<id>/`.
- **Dev plugins:** put (or symlink) the package in `<project>/.openidea/plugins/<name>/`.
It loads when the project is opened, is listed with a _dev_ tag, and _Reload_
(or the _Reload Plugins_ action) picks up a rebuilt `plugin.wasm` without
restarting the IDE.
The IDE parses its built-in languages with tree-sitter. For a plugin language
it has no grammar for, the plugin's component can export `syntax-provider`
(world `plugin-syntax`): flat highlight tokens, structure-view symbols and
folds for a whole document, in UTF-8 byte offsets. With the SDK:
```rust
use openidea_plugin::syntax::{Fold, Highlight, Symbol, SyntaxProvider, Token};
impl SyntaxProvider for MyPlugin {
fn highlights(&mut self, language: &str, text: &str) -> Vec<Token> { … }
fn outline(&mut self, language: &str, text: &str) -> Vec<Symbol> { … } // optional
fn folding(&mut self, language: &str, text: &str) -> Vec<Fold> { … } // optional
}
register_plugin!(MyPlugin, syntax);
```
It runs on the editing path (once per document version, cached), so keep it
fast — a hand-written lexer is typical (`syntax::bracket_folds` helps with
folding). Add `activation = ["on-language:<id>"]` so the component loads with
the first file. Results are validated (clamped to the text, overlaps dropped).
### 6.2 Run configurations
```toml
[[contributes.runDetectors]]
id = "forge-test"
name = "forge test"
files = ["foundry.toml"] # offered in projects with any of these at the root
command = "forge" # one program; needs process:forge
args = ["test"]
test = true # results go to the test tree
```
The configuration appears among the detected ones while `process:<command>` is
granted, and starts the approved program.
## 7. Installing and developing
- **Install:** Settings › Plugins › _Install Plugin from Disk…_ (a package
folder) or _Install Plugin from Package File…_ (a `.oiplugin`). The install
page lists every capability and whether the package is signed by a publisher
the marketplace trusts; nothing is copied until you accept.
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
with an `active` pointer that is switched only once the new version is in
place. An update whose code fails to start the first time is rolled back to
the version it replaced automatically (you're told why); _Roll Back to …_
in the plugin's details does it by hand. Data (`storage`) is per plugin, not
per version.
- **Linked plugins:** `openidea-plugin link <dir>` (§1). Listed with a _linked_
tag; capabilities are granted per folder.
- **Project plugins:** put (or symlink) the package in
`<project>/.openidea/plugins/<name>/`. They load only in trusted projects and
only after you approve their capabilities.
- Commands appear in Search Everywhere › Actions under their category, and can
be bound in Settings › Keymap like any other action.
## 6. Testing
## 8. Distribution
The host's integration tests (`crates/ide-plugin/tests/host.rs`) load the
checked-in `hello` and test-fixture components; rebuild them after changing the
WIT file:
A **package** is a zip named `<id>-<version>.oiplugin`: the manifest, the
files it references, the component, licence files and, when signed,
`SIGNATURE.json` (Ed25519 over the plugin id, version and the digest of its
files).
```sh
plugins/build.sh hello
openidea-plugin keygen acme.key # once; keep the file secret
openidea-plugin pack plugins/acme-tools --key acme.key --publisher acme
openidea-plugin index market/ --publisher acme --name "ACME" --key acme.key
```
A **marketplace** is static: an `index.json` and the packages next to it, on
any web server or folder. It lists publishers and their public keys (the trust
root: publisher `acme` owns plugin ids `acme.*`), the plugins with their
digests, and revocations. Settings › Plugins › Available reads it from the URL
you set; installing downloads the package, checks it against the index entry
and the publisher's key, then shows the install page.
- A package whose signature doesn't hold (changed after signing, a key that
isn't the publisher's, another publisher's id prefix, a revoked key) is
refused. Unsigned packages install from disk with a warning.
- **Revocation:** the index can revoke plugin versions (or all versions) and
publisher keys. The IDE reads the index daily; revoked plugins are disabled
with the reason and the user is notified — also after a restart, before
their code could run. A publisher rotates keys by adding the new one; older
keys stay valid until revoked.
## 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:
```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
cargo test -p ide-plugin
cargo test -p ide-host --test plugins
```
+8 -1
Переглянути файл
@@ -95,12 +95,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin-hello"
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-hello"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
+2 -2
Переглянути файл
@@ -8,10 +8,10 @@ publish = false
[workspace]
[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]
[dependencies]
wit-bindgen = "0.63"
openidea-plugin = { path = "../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
Бінарний файл не відображається.
+34 -5
Переглянути файл
@@ -1,17 +1,23 @@
id = "hello"
name = "Hello"
version = "0.1.0"
description = "Sample plugin: inserts today's date and counts words in the active file."
version = "0.3.0"
description = "Sample plugin: inserts today's date, counts words in the active file and shows a tool window with a tree."
authors = ["OpenIDEA contributors"]
change-notes = """
0.3.0 — a Greetings tool window (tree view), an editor context menu entry, a shortcut and a status bar item.
0.2.0 — plugin API 0.2: edits are versioned, one undo step for every cursor.
0.1.0 — first release: Insert Date and Word Count of Active File commands.
"""
openidea-api = "0.1"
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
permissions = ["project-read", "editor-read", "editor-write"]
# Loaded the first time one of its commands runs.
activation = ["on-command:*", "on-view:greetings"]
[capabilities]
required = ["editor.read", "editor.write"]
# Static declarations make the commands visible before the plugin activates;
# the plugin may also register more at runtime via `register-command`.
# the plugin may also register more at runtime with `register_command`.
[[contributes.commands]]
id = "insert-date"
title = "Hello: Insert Date"
@@ -21,3 +27,26 @@ category = "Hello"
id = "word-count"
title = "Hello: Word Count of Active File"
category = "Hello"
# Declarative UI: shown without loading the plugin's code.
[[contributes.menus]]
menu = "editor.context"
command = "word-count"
[[contributes.keymaps]]
command = "insert-date"
key = "Ctrl+Alt+D"
mac = "Meta+Alt+D"
[[contributes.statusBar]]
id = "hello"
text = "Hello"
tooltip = "Word count of the active file"
command = "word-count"
# A tool window whose tree the plugin renders (view-provider).
[[contributes.views]]
id = "greetings"
title = "Greetings"
icon = "plugin"
location = "right"
+133 -23
Переглянути файл
@@ -1,41 +1,50 @@
//! Sample OpenIDEA plugin. Build with `../build.sh hello` (see plugins/README.md).
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
//! Build with `openidea-plugin build plugins/hello` (see plugins/README.md).
wit_bindgen::generate!({
world: "plugin",
path: "../../wit",
});
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin};
use std::collections::BTreeSet;
use openidea::plugin::host::{self, LogLevel, NotificationLevel};
#[derive(Default)]
struct Hello {
/// Expanded languages of the Greetings tree.
expanded: BTreeSet<String>,
}
struct Hello;
/// The Greetings tree: languages, then their greetings (loaded when expanded).
const GREETINGS: &[(&str, &[&str])] = &[
("English", &["Hello", "Good morning", "Hi"]),
("Deutsch", &["Hallo", "Guten Morgen", "Servus"]),
("日本語", &["こんにちは", "おはよう"]),
];
impl Guest for Hello {
fn activate(ctx: Context) -> Result<(), String> {
impl Plugin for Hello {
fn activate(&mut self, ctx: &Context) -> Result<(), String> {
host::log(LogLevel::Info, &format!("hello {} activated", ctx.plugin_version));
host::register_command("insert-date", "Hello: Insert Date", Some("Hello"));
host::register_command("word-count", "Hello: Word Count of Active File", Some("Hello"));
Ok(())
}
fn deactivate() {
fn deactivate(&mut self) {
host::log(LogLevel::Info, "hello deactivated");
}
fn run_command(id: String, _arg: Option<String>) -> Result<Option<String>, String> {
match id.as_str() {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
let editor = ctx.editor().ok_or("no file is open in the editor")?;
match id {
"insert-date" => {
// Replaces every selection (inserts at every cursor), as one undo step.
let date = today();
host::insert_text(&date);
editor.transform_selections(|_| date.clone())?;
Ok(Some(date))
}
"word-count" => {
let path = host::active_file().ok_or("no file is open in the editor")?;
let text = host::read_file(&path)?;
let text = editor.document()?.text;
let path = editor.path();
let words = text.split_whitespace().count();
let lines = text.lines().count();
let name = path.rsplit(['/', '\\']).next().unwrap_or(&path);
let body = format!("{words} words, {lines} lines, {} characters", text.chars().count());
host::show_notification(NotificationLevel::Info, &format!("Word count: {name}"), &body);
host::notify(NotificationLevel::Info, &format!("Word count: {name}"), &body);
Ok(Some(body))
}
other => Err(format!("unknown command: {other}")),
@@ -43,12 +52,56 @@ impl Guest for Hello {
}
}
impl Views for Hello {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
if view != "greetings" {
return Err(Error::NotFound(view.into()));
}
let mut items = Vec::new();
for (language, words) in GREETINGS {
let open = self.expanded.contains(*language);
items.push(
TreeItem::new(language, language).icon("folder").detail(&words.len().to_string()).expandable(open),
);
if open {
for w in *words {
items.push(TreeItem::new(&format!("{language}/{w}"), w).parent(language).icon("text"));
}
}
}
Ok(View::new(Body::Tree(items))
.button(Button::new("collapse", "Collapse All").icon("collapse-all").enabled(!self.expanded.is_empty()))
.status(&format!("{} languages", GREETINGS.len())))
}
fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
match event {
ViewEvent::Expand(key) => {
self.expanded.insert(key);
}
ViewEvent::Collapse(key) => {
self.expanded.remove(&key);
}
ViewEvent::Button(b) if b == "collapse" => self.expanded.clear(),
ViewEvent::Activate(key) => {
if let Some((language, word)) = key.split_once('/') {
host::notify(NotificationLevel::Info, word, &format!("A greeting in {language}"));
}
return Ok(());
}
_ => return Ok(()),
}
host::refresh_view(view);
Ok(())
}
}
register_plugin!(Hello, views);
/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock).
fn today() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let secs =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
format!("{y:04}-{m:02}-{d:02}")
}
@@ -67,4 +120,61 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) {
(y, m, d)
}
export!(Hello);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
#[test]
fn civil_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
}
#[test]
fn word_count_reads_the_focused_editor() {
let host = MockHost::install();
host.open_editor("/project/notes.txt", "text", "one two\nthree\n", &[]);
let out = Hello::default().run_command("word-count", &host.invocation(), None).unwrap();
assert_eq!(out.as_deref(), Some("3 words, 2 lines, 14 characters"));
assert_eq!(host.notifications()[0].title, "Word count: notes.txt");
}
#[test]
fn insert_date_replaces_every_selection_at_once() {
let host = MockHost::install();
host.open_editor("/project/a.txt", "text", "a\nXX b", &[(0, 1, 0, 1), (1, 0, 1, 2)]);
let date = Hello::default().run_command("insert-date", &host.invocation(), None).unwrap().unwrap();
assert_eq!(host.editor_text(), format!("a{date}\n{date} b"));
assert_eq!(host.editor_version(), 2, "one change");
}
#[test]
fn the_greetings_tree_loads_children_when_expanded() {
let host = MockHost::install();
let mut p = Hello::default();
let tree = |p: &mut Hello| match p.render("greetings", None, &host.invocation()).unwrap().body {
Body::Tree(items) => items,
other => panic!("{other:?}"),
};
assert_eq!(tree(&mut p).len(), 3);
p.handle("greetings", ViewEvent::Expand("Deutsch".into()), &host.invocation()).unwrap();
let items = tree(&mut p);
assert_eq!(items.len(), 6);
assert_eq!((items[2].label.as_str(), items[2].parent.as_deref()), ("Hallo", Some("Deutsch")));
assert_eq!(host.refreshed_views(), ["greetings"]);
p.handle("greetings", ViewEvent::Activate("Deutsch/Servus".into()), &host.invocation()).unwrap();
assert_eq!(host.notifications()[0].title, "Servus");
p.handle("greetings", ViewEvent::Button("collapse".into()), &host.invocation()).unwrap();
assert_eq!(tree(&mut p).len(), 3);
}
#[test]
fn without_editor_write_the_command_fails_cleanly() {
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!(host.editor_text(), "");
}
}
+1
Переглянути файл
@@ -0,0 +1 @@
/target
+334
Переглянути файл
@@ -0,0 +1,334 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-ai-completion"
version = "0.1.0"
dependencies = [
"openidea-plugin",
"serde_json",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+22
Переглянути файл
@@ -0,0 +1,22 @@
[package]
name = "openidea-plugin-openidea-ai-completion"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+44
Переглянути файл
@@ -0,0 +1,44 @@
id = "openidea.ai-completion"
name = "AI Completion"
version = "0.1.0"
description = "Inline code suggestions (ghost text) from a local model (Ollama) or OpenAI, as you type."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: local (Ollama) and OpenAI backends; the key stays in the system keychain."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
required = ["storage"]
# Grant the backends you use; the key is only ever sent to api.openai.com.
optional = ["net:localhost", "net:api.openai.com", "secrets"]
[[contributes.providers]]
kinds = ["inline-completion"]
languages = ["*"]
[[contributes.secrets]]
name = "api-key"
label = "OpenAI API key"
description = "Used only with the OpenAI backend."
[[contributes.commands]]
id = "use-local"
title = "AI Completion: Use Local Model (Ollama)"
category = "AI Completion"
[[contributes.commands]]
id = "use-openai"
title = "AI Completion: Use OpenAI"
category = "AI Completion"
[[contributes.commands]]
id = "toggle"
title = "AI Completion: Enable / Disable"
category = "AI Completion"
[[contributes.commands]]
id = "set-endpoint"
title = "AI Completion: Set Endpoint URL"
category = "AI Completion"
+214
Переглянути файл
@@ -0,0 +1,214 @@
//! AI Completion for OpenIDEA: inline suggestions from an OpenAI-compatible
//! `/v1/completions` endpoint (fill-in-the-middle with `prompt` + `suffix`).
//! The IDE cancels a request as soon as the user types on (the fetch returns
//! `Cancelled`); the API key is filled in by the IDE (`{{secret:api-key}}`)
//! and never seen here.
use openidea_plugin::host::{self, storage};
use openidea_plugin::providers::{Document, InlineSuggestion, Providers};
use openidea_plugin::text::offset;
use openidea_plugin::{Error, HttpRequest, Invocation, LogLevel, NotificationLevel, Plugin, Position, register_plugin};
use serde_json::{Value, json};
const LOCAL: &str = "http://localhost:11434";
const OPENAI: &str = "https://api.openai.com";
const LOCAL_MODEL: &str = "qwen2.5-coder:1.5b";
const OPENAI_MODEL: &str = "gpt-3.5-turbo-instruct";
/// Context sent around the caret.
const PREFIX_CHARS: usize = 3000;
const SUFFIX_CHARS: usize = 1000;
#[derive(Default)]
pub struct AiCompletion {
/// Why the last request failed, so it is logged once rather than per keystroke.
last_error: Option<String>,
}
struct Config {
endpoint: String,
model: String,
openai: bool,
enabled: bool,
}
fn config() -> Config {
let get = |k: &str| storage::get(k).ok().flatten();
let endpoint = get("endpoint").unwrap_or_else(|| LOCAL.into());
let openai = endpoint.starts_with(OPENAI);
let model = get("model").unwrap_or_else(|| (if openai { OPENAI_MODEL } else { LOCAL_MODEL }).into());
Config { endpoint, model, openai, enabled: get("enabled").as_deref() != Some("false") }
}
/// The last `max` characters of `s` (on a char boundary).
fn tail(s: &str, max: usize) -> &str {
match s.char_indices().rev().nth(max.saturating_sub(1)) {
Some((i, _)) if s.chars().count() > max => &s[i..],
_ => s,
}
}
fn head(s: &str, max: usize) -> &str {
s.char_indices().nth(max).map_or(s, |(i, _)| &s[..i])
}
/// The request body for the text around `at`, or `None` when there is
/// nothing to complete (in the middle of a word).
pub fn request_body(doc: &Document, at: Position, model: &str) -> Option<String> {
let at = offset(&doc.text, at)?;
let (before, after) = doc.text.split_at(at);
// Only at the end of a line or before closing punctuation.
let rest_of_line = after.split('\n').next().unwrap_or("");
if !rest_of_line.trim().chars().all(|c| ")]}>;,'\"`".contains(c)) {
return None;
}
let body = json!({
"model": model,
"prompt": tail(before, PREFIX_CHARS),
"suffix": head(after, SUFFIX_CHARS),
"max_tokens": 64,
"temperature": 0,
"stop": ["\n\n"],
});
Some(body.to_string())
}
/// The suggestion in a `/v1/completions` response.
pub fn suggestion(response: &str) -> Option<String> {
let v: Value = serde_json::from_str(response).ok()?;
let text = v["choices"][0]["text"].as_str()?.trim_end().to_owned();
(!text.trim().is_empty()).then_some(text)
}
impl Providers for AiCompletion {
fn inline_completion(&mut self, doc: &Document, at: Position) -> Option<InlineSuggestion> {
let c = config();
if !c.enabled {
return None;
}
let body = request_body(doc, at, &c.model)?;
let mut req = HttpRequest::post(&format!("{}/v1/completions", c.endpoint.trim_end_matches('/')), body)
.header("Content-Type", "application/json");
if c.openai {
if !host::secret_is_set("api-key") {
return None;
}
req = req.header("Authorization", "Bearer {{secret:api-key}}");
}
req.timeout_ms = Some(10_000);
match host::fetch(&req) {
Ok(r) if r.status == 200 => {
self.last_error = None;
suggestion(&r.text()).map(|insert_text| InlineSuggestion { insert_text, replace: None })
}
// Superseded by the next keystroke: nothing to report.
Err(Error::Cancelled) => None,
other => {
let msg = match other {
Ok(r) => format!("HTTP {}: {}", r.status, head(&r.text(), 200)),
Err(e) => e.to_string(),
};
if self.last_error.as_ref() != Some(&msg) {
host::log(LogLevel::Warn, &format!("{}: {msg}", c.endpoint));
self.last_error = Some(msg);
}
None
}
}
}
}
impl Plugin for AiCompletion {
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, String> {
let msg = match id {
"use-local" => {
storage::set("endpoint", LOCAL)?;
storage::delete("model")?;
format!("Using the local model {LOCAL_MODEL} (Ollama at {LOCAL}).")
}
"use-openai" => {
storage::set("endpoint", OPENAI)?;
storage::delete("model")?;
if host::secret_is_set("api-key") {
format!("Using OpenAI ({OPENAI_MODEL}).")
} else {
"Using OpenAI: store your API key in Settings › Plugins › AI Completion.".into()
}
}
"set-endpoint" => {
let url = arg.map(str::trim).filter(|u| u.starts_with("http://") || u.starts_with("https://"));
let url = url.ok_or("give the endpoint URL, e.g. http://localhost:11434")?;
storage::set("endpoint", url)?;
format!("Using {url}.")
}
"toggle" => {
let enabled = !config().enabled;
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}")),
};
host::notify(NotificationLevel::Info, "AI Completion", &msg);
Ok(Some(msg))
}
}
register_plugin!(AiCompletion, providers);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::HttpResponse;
use openidea_plugin::mock::MockHost;
fn doc(text: &str) -> Document {
Document { path: "/p/a.rs".into(), language: "rust".into(), version: 1, text: text.into() }
}
#[test]
fn asks_with_prefix_and_suffix_and_returns_the_text() {
let host = MockHost::install().with_capabilities(&["storage", "net:localhost"]);
host.on_fetch(|r| {
let body: Value = serde_json::from_slice(r.body.as_ref().unwrap()).unwrap();
assert_eq!(body["prompt"], "fn add(a: i32, b: i32) -> i32 {\n ");
assert_eq!(body["suffix"], "\n}\n");
Ok(HttpResponse { status: 200, headers: vec![], body: br#"{"choices":[{"text":"a + b\n"}]}"#.to_vec() })
});
let s = AiCompletion::default()
.inline_completion(&doc("fn add(a: i32, b: i32) -> i32 {\n \n}\n"), Position::new(1, 4))
.unwrap();
assert_eq!(s.insert_text, "a + b");
assert_eq!(host.requests()[0].url, "http://localhost:11434/v1/completions");
}
#[test]
fn openai_uses_the_secret_and_only_when_set() {
let host = MockHost::install().with_capabilities(&["storage", "net:api.openai.com", "secrets"]);
host.on_fetch(|_| {
Ok(HttpResponse { status: 200, headers: vec![], body: br#"{"choices":[{"text":"x"}]}"#.to_vec() })
});
let mut p = AiCompletion::default();
p.run_command("use-openai", &host.invocation(), None).unwrap();
assert!(p.inline_completion(&doc("let x = "), Position::new(0, 8)).is_none(), "no key yet");
host.set_secret("api-key", "sk-test");
assert!(p.inline_completion(&doc("let x = "), Position::new(0, 8)).is_some());
let sent = &host.requests()[0];
assert!(sent.headers.contains(&("Authorization".into(), "Bearer sk-test".into())));
}
#[test]
fn quiet_when_cancelled_denied_or_mid_word() {
let host = MockHost::install().with_capabilities(&["storage"]);
let mut p = AiCompletion::default();
assert!(p.inline_completion(&doc("let x = "), Position::new(0, 8)).is_none(), "net denied");
assert!(request_body(&doc("let abc = d;"), Position::new(0, 5), "m").is_none(), "mid-word");
assert!(request_body(&doc("call(x)"), Position::new(0, 6), "m").is_some(), "before `)`");
let host2 = MockHost::install().with_capabilities(&["storage", "net:localhost"]);
host2.on_fetch(|_| Err(Error::Cancelled));
assert!(p.inline_completion(&doc("x"), Position::new(0, 1)).is_none());
assert!(host2.logs().is_empty(), "a cancelled request is not an error");
drop(host);
p.run_command("toggle", &host2.invocation(), None).unwrap();
assert!(p.inline_completion(&doc("x"), Position::new(0, 1)).is_none());
assert_eq!(host2.requests().len(), 1, "disabled: no request");
}
}
+1
Переглянути файл
@@ -0,0 +1 @@
/target
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff. Show more