Files
1c5a92f952 feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)
A plugin system built on WebAssembly components (wasmtime, WASI p2), with a
typed WIT API (`openidea:plugin@1.0.0`) and a Rust SDK, shipped as a preview.

- Extension points declared in `plugin.toml` (commands, languages, themes,
  icon themes, menus, key bindings, status items, settings, views, custom
  editors, file icons and templates, run detectors, editor providers),
  validated before anything takes effect and projected onto the workbench's
  own registries: plugin themes and file icon themes are chosen in
  Settings › Appearance and Quick Switch, plugin shortcuts are edited in
  Settings › Keymap, plugin problems are grouped in Settings › Inspections.
- Capabilities with risk levels and consent at install, pinned `process:`
  programs, secrets in the OS keychain.
- Runtime: one thread per active plugin, fuel/time/memory budgets, soft and
  hard deadlines on the editing path, cancellation, crash backoff, idle
  reclaim, a precompiled-component cache; remote projects run code plugins on
  `openidea-server`.
- Web views and custom editors with a sandboxed page, a document bridge
  joined to the text editor's undo history, and split or preview layouts.
- Distribution: signed `.oiplugin` packages, a static marketplace index with
  revocation, versioned installs with rollback, install from a `.zip` or a
  folder. The release workflow signs and publishes the official plugins and
  their index, which released builds use as the default marketplace.
- Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion,
  Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor
  Night, Excalidraw, Graphviz, Regex Tester, License Header.

Closes #732

Co-authored-by: Deng Liu <5108773+dengliu@users.noreply.github.com>
Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com>
2026-11-04 02:54:31 +08:00

295 lines
12 KiB
Rust

//! 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: PathBuf,
}
/// 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 = pkg.read_text(&decl.path)?;
let cfg = parse_definition(&src).map_err(|e| format!("{}: {e}", decl.path.display()))?;
let caps = required_capabilities(&cfg);
let (store, plugin, key, label) = (
self.store.clone(),
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).file(decl.path);
for cap in caps {
prepared = prepared.capability(cap);
}
Ok(prepared)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_extension::{ExtensionRegistry, Manifest};
#[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).unwrap();
assert_eq!(store.all()[0].id.as_str(), "solidity");
reg.dispose_plugin("p");
assert!(store.all().is_empty());
let bad = |def: &str| parse_definition(def).unwrap_err();
assert!(bad("not toml [").contains("invalid language"));
assert!(bad("id = \"\"\nname = \"x\"").contains("must not be empty"));
assert!(bad("id = \"a b\"\nname = \"x\"").contains("invalid language id"));
}
#[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"));
}
}