дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
"##;
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
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 & 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 & 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());
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
}
|
||||
@@ -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;
|
||||
Бінарний файл не відображається.
@@ -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);
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
Бінарний файл не відображається.
@@ -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);
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -36,6 +36,7 @@ fn server(name: &str) -> LanguageServerConfig {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`。
|
||||
@@ -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 纳入性能预算观测 |
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
BIN
Бінарний файл не відображається.
@@ -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(), "");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Заблокувати користувача