Files
openidea/sdk/rust/openidea-plugin/tests/api_frozen.rs
T
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

181 lines
8.3 KiB
Rust

//! Released plugin APIs never change (plugins/README.md, "Evolving the plugin API"):
//! wasmtime links a component only if every function type matches exactly, so
//! a field added to a record or a case added to a variant breaks every
//! component built for the release.
//!
//! `tests/api/<version>/` is the WIT of each release, copied when it was
//! frozen. Every interface, type and function in it must still exist in
//! `wit/` with the same definition; additions are fine. When a version is
//! released, copy `wit/` to `tests/api/<that version>/`. Until the first
//! release there is no snapshot and the API may still change freely.
use std::collections::BTreeMap;
use std::path::Path;
use wit_parser::{FunctionKind, Handle, Resolve, Type, TypeDefKind, TypeId, TypeOwner};
/// Everything a release promises, by key (`log/log`, `types/error`), rendered canonically.
fn surface(dir: &Path) -> BTreeMap<String, String> {
let mut resolve = Resolve::default();
let (pkg, _) = resolve.push_dir(dir).unwrap_or_else(|e| panic!("{}: {e:?}", dir.display()));
let mut out = BTreeMap::new();
for (name, &id) in &resolve.packages[pkg].interfaces {
let iface = &resolve.interfaces[id];
for (ty_name, &ty) in &iface.types {
out.insert(format!("{name}/type {ty_name}"), definition(&resolve, ty));
}
for f in iface.functions.values() {
let params: Vec<String> = f.params.iter().map(|p| format!("{}: {}", p.name, ty(&resolve, &p.ty))).collect();
let result = f.result.as_ref().map(|r| format!(" -> {}", ty(&resolve, r))).unwrap_or_default();
let kind = match f.kind {
FunctionKind::Freestanding => "",
FunctionKind::AsyncFreestanding => "async ",
FunctionKind::Method(_) => "method ",
FunctionKind::Static(_) => "static ",
FunctionKind::Constructor(_) => "constructor ",
_ => "other ",
};
out.insert(format!("{name}/func {}", f.name), format!("{kind}({}){result}", params.join(", ")));
}
}
for (name, &id) in &resolve.packages[pkg].worlds {
let world = &resolve.worlds[id];
let keys = |m: &wit_parser::IndexMap<wit_parser::WorldKey, wit_parser::WorldItem>| {
m.keys().map(|k| resolve.name_world_key(k)).collect::<Vec<_>>().join(", ")
};
out.insert(
format!("world {name}"),
format!("imports [{}] exports [{}]", keys(&world.imports), keys(&world.exports)),
);
}
out
}
/// A named type's definition.
fn definition(r: &Resolve, id: TypeId) -> String {
let def = &r.types[id];
match &def.kind {
TypeDefKind::Record(x) => {
let f: Vec<_> = x.fields.iter().map(|f| format!("{}: {}", f.name, ty(r, &f.ty))).collect();
format!("record {{ {} }}", f.join(", "))
}
TypeDefKind::Variant(x) => {
let c: Vec<_> = x
.cases
.iter()
.map(|c| c.ty.as_ref().map_or(c.name.clone(), |t| format!("{}({})", c.name, ty(r, t))))
.collect();
format!("variant {{ {} }}", c.join(", "))
}
TypeDefKind::Enum(x) => {
format!("enum {{ {} }}", x.cases.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(", "))
}
TypeDefKind::Flags(x) => {
format!("flags {{ {} }}", x.flags.iter().map(|f| f.name.as_str()).collect::<Vec<_>>().join(", "))
}
TypeDefKind::Resource => "resource".to_owned(),
// A `use`d type: the same type as the one it names.
TypeDefKind::Type(t) => format!("= {}", ty(r, t)),
_ => anonymous(r, id),
}
}
/// A type in a position (parameter, field…): named types by name, others structurally.
fn ty(r: &Resolve, t: &Type) -> String {
match t {
Type::Id(id) => {
let def = &r.types[*id];
match (&def.name, def.owner) {
(Some(name), TypeOwner::Interface(i)) => {
format!("{}.{name}", r.interfaces[i].name.as_deref().unwrap_or("?"))
}
(Some(name), _) => name.clone(),
(None, _) => anonymous(r, *id),
}
}
other => format!("{other:?}").to_lowercase(),
}
}
fn anonymous(r: &Resolve, id: TypeId) -> String {
let opt = |t: &Option<Type>| t.as_ref().map_or("_".to_owned(), |t| ty(r, t));
match &r.types[id].kind {
TypeDefKind::Option(t) => format!("option<{}>", ty(r, t)),
TypeDefKind::List(t) => format!("list<{}>", ty(r, t)),
TypeDefKind::Result(x) => format!("result<{}, {}>", opt(&x.ok), opt(&x.err)),
TypeDefKind::Tuple(x) => format!("tuple<{}>", x.types.iter().map(|t| ty(r, t)).collect::<Vec<_>>().join(", ")),
TypeDefKind::Handle(Handle::Own(t)) => format!("own<{}>", ty(r, &Type::Id(*t))),
TypeDefKind::Handle(Handle::Borrow(t)) => format!("borrow<{}>", ty(r, &Type::Id(*t))),
TypeDefKind::Type(t) => ty(r, t),
other => format!("{other:?}"),
}
}
/// The frozen releases (`tests/api/<version>/`), none before the first release.
fn releases(root: &Path) -> Vec<std::path::PathBuf> {
match std::fs::read_dir(root.join("tests/api")) {
Ok(dir) => dir.map(|e| e.unwrap().path()).filter(|p| p.is_dir()).collect(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => panic!("tests/api: {e}"),
}
}
#[test]
fn the_surface_covers_the_whole_api() {
// Keeps the check below meaningful while there is no release to compare.
let current = surface(&Path::new(env!("CARGO_MANIFEST_DIR")).join("wit"));
assert!(current.len() > 50, "only {} items: did wit/ load?", current.len());
assert!(current.keys().any(|k| k.starts_with("world ")));
assert!(current.keys().any(|k| k.contains("/func ")));
assert!(current.keys().any(|k| k.contains("/type ")));
}
#[test]
fn released_apis_are_unchanged() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let current = surface(&root.join("wit"));
for dir in releases(root) {
let release = surface(&dir);
let version = dir.file_name().unwrap().to_string_lossy().into_owned();
let mut broken = Vec::new();
for (key, was) in &release {
match current.get(key) {
None => broken.push(format!("{key}: removed (was {was})")),
// A world may gain imports and exports; everything else stays as it was.
Some(now) if key.starts_with("world ") => {
let had = |s: &str, part: &str| -> Vec<String> {
let start = s.find(part).unwrap() + part.len();
let end = s[start..].find(']').unwrap() + start;
s[start..end].split(", ").filter(|x| !x.is_empty()).map(str::to_owned).collect()
};
for part in ["imports [", "exports ["] {
let now = had(now, part);
for item in had(was, part) {
if !now.contains(&item) {
broken.push(format!("{key}: no longer has {item}"));
}
}
}
}
Some(now) if now != was => broken.push(format!("{key}:\n was {was}\n now {now}")),
Some(_) => {}
}
}
assert!(broken.is_empty(), "plugin API {version} changed incompatibly:\n {}", broken.join("\n "));
assert!(release.len() > 50, "{version}: only {} items: did the snapshot load?", release.len());
}
}
#[test]
fn the_current_api_is_a_release_or_newer_than_all() {
// `wit/` is either a frozen release or the next one; never older.
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let src = std::fs::read_to_string(root.join("wit/plugin.wit")).unwrap();
let current = src.lines().find_map(|l| l.trim().strip_prefix("package openidea:plugin@")).unwrap();
let current: Vec<u64> = current.trim_end_matches(';').split('.').map(|p| p.parse().unwrap()).collect();
for dir in releases(root) {
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
let release: Vec<u64> = name.split('.').map(|p| p.parse().unwrap()).collect();
assert!(release <= current, "tests/api/{name} is newer than wit/ ({current:?})");
}
}