Файли
l0ng-ai fd1f465aeb 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.
2026-11-04 02:41:46 +08:00

502 рядки
20 KiB
Rust
Неформатований Постійне посилання Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
//! `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));
}
}