diff --git a/Cargo.lock b/Cargo.lock index a39ae6994..ac0c58f70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index f306034e9..a1c42bf2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 95ba3f5fd..f0e942945 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -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>)] + pub plugins: BTreeMap>, } /// 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(), } } } diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 341e0663b..2f4f88fc1 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -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, + /// A plugin language's adapter: start exactly this approved program. + pub pinned: Option, } /// Built-in adapters used when no language definition names `adapter`. @@ -55,6 +57,7 @@ fn builtin(adapter: &str) -> Option { 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 { diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index e1fb0ecae..181386a5f 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -915,6 +915,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc 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 { + 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(&self, s: S) -> Result { + s.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Capability { + fn deserialize>(d: D) -> Result { + 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::().unwrap().to_string(), s); + } + assert_eq!("project-read".parse::().unwrap(), Capability::ProjectRead); + assert!("net:".parse::().is_err()); + assert!("net:a b".parse::().is_err()); + assert!("process:./evil".parse::().is_err()); + assert!("fs.read:rel".parse::().is_err()); + assert!("network".parse::().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()))); + } +} diff --git a/crates/ide-extension/src/index.rs b/crates/ide-extension/src/index.rs new file mode 100644 index 000000000..2bb05d450 --- /dev/null +++ b/crates/ide-extension/src/index.rs @@ -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 { + 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) { + let keep: BTreeSet = 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()); + } +} diff --git a/crates/ide-extension/src/ledger.rs b/crates/ide-extension/src/ledger.rs new file mode 100644 index 000000000..9bed117b3 --- /dev/null +++ b/crates/ide-extension/src/ledger.rs @@ -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:` 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, + /// Optional capabilities the user declined (not asked again until re-confirmed). + #[serde(default)] + pub declined: BTreeSet, + /// `process:` → the program confirmed for it. + #[serde(default)] + pub binaries: BTreeMap, + /// 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::().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, + pub optional: Vec, +} + +#[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, pending: Vec }, + /// Ask the user first. `new` lists what was never granted (all of it on first use). + NeedsConfirmation { new: Vec, 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, + migrated: bool, + grants: BTreeMap, +} + +impl Ledger { + /// Loads `/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 = 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 { + 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) { + 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 = + 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 = 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, + ) { + 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 { + 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 { + 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 { + 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)> { + let mut groups: BTreeMap, Vec> = 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 { + 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 = 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 { + 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 { + 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 { + 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::>(), [Risk::High, Risk::Medium, Risk::Low]); + } +} diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs new file mode 100644 index 000000000..6595166a0 --- /dev/null +++ b/crates/ide-extension/src/lib.rs @@ -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; diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs new file mode 100644 index 000000000..99820d5f5 --- /dev/null +++ b/crates/ide-extension/src/manifest.rs @@ -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.` 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, + pub homepage: Option, + pub license: Option, + /// Release notes of this version (Settings › Plugins › What's New). + pub change_notes: Option, + /// `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, + pub activation: Vec, + pub capabilities: Capabilities, + /// `contributes.` → its declarations, validated by the extension points. + pub contributes: toml::Table, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Capabilities { + pub required: Vec, + pub optional: Vec, +} + +impl Capabilities { + pub fn all(&self) -> impl Iterator { + 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:`; `*` = 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:`. 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 { + 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:`, 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, + #[serde(default)] + homepage: Option, + #[serde(default)] + license: Option, + #[serde(default)] + change_notes: Option, + #[serde(default)] + engines: Option, + /// 0.1 format. + #[serde(default)] + openidea_api: Option, + #[serde(default)] + wasm: Option, + #[serde(default)] + activation: Option>, + #[serde(default)] + capabilities: Option, + /// 0.1 format. + #[serde(default)] + permissions: Option>, + #[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, + #[serde(default)] + optional: Vec, +} + +impl Manifest { + pub fn parse(src: &str) -> Result { + 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| -> Result, String> { + let mut out: Vec = 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::, _>>()?, + // 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 { + 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 { + 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)); + } +} diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs new file mode 100644 index 000000000..ee8c8642e --- /dev/null +++ b/crates/ide-extension/src/package.rs @@ -0,0 +1,378 @@ +//! Plugin distribution (docs/plugin_plan.md 4.7): the package file, its +//! signature, and the static marketplace index. +//! +//! - **Package** (`-.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\n\n`, +//! `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 +//! `.`; 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 { + 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 { + 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 { + 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 = 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, + pub signature: Option, +} + +/// `name` as a relative path that stays inside the package. +fn safe_path(name: &str) -> Option { + 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 { + 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, + #[serde(default)] + pub plugins: Vec, + #[serde(default)] + pub revoked: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Publisher { + /// Owns plugin ids `.…`. + pub id: String, + pub name: String, + pub keys: Vec, +} + +#[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, + 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, +} + +/// 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, + pub reason: String, +} + +impl Index { + pub fn parse(text: &str) -> Result { + 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 { + 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 { + 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()); + } +} diff --git a/crates/ide-extension/src/point.rs b/crates/ide-extension/src/point.rs new file mode 100644 index 000000000..7a74ac0d1 --- /dev/null +++ b/crates/ide-extension/src/point.rs @@ -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, + /// 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>, +} + +/// 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 { + 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, 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::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>); + +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 Result + Send>; + +/// A validated declaration, ready to take effect. +pub struct Prepared { + /// Capabilities this declaration needs (a language with an LSP → `process:`). + pub capabilities: Vec, + /// Package files the declaration references (copied on install). + pub files: Vec, + /// 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, + /// 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, commit: impl FnOnce() -> Result + 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) -> Self { + self.key = Some(key.into()); + self + } + + pub fn file(mut self, path: impl Into) -> 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.` 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; +} + +/// Deserializes a declaration with serde, prefixing errors with the point id. +pub fn decode(point: &str, decl: toml::Value) -> Result { + decl.try_into().map_err(|e: toml::de::Error| format!("contributes.{point}: {}", e.message())) +} diff --git a/crates/ide-extension/src/registry.rs b/crates/ide-extension/src/registry.rs new file mode 100644 index 000000000..172b580d7 --- /dev/null +++ b/crates/ide-extension/src/registry.rs @@ -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, + 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, + /// Package files the declarations reference. + pub files: Vec, + /// Declarations for extension points this host does not know (skipped). + pub warnings: Vec, + 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>>, + /// plugin id → its contributions in commit order. + entries: Mutex>>, +} + +impl ExtensionRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Adds an extension point. Ids are unique. + pub fn register(&self, point: Arc) -> 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 { + self.points.read().get(point).map(|p| p.placement()) + } + + fn point(&self, id: &str) -> Option> { + self.points.read().get(id).cloned() + } + + /// Validates every `contributes.` 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 { + 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 = 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 { + 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 = { + let mut entries = self.entries.lock(); + let Some(list) = entries.get_mut(plugin) else { return false }; + let (runtime, keep): (Vec, Vec) = 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 { + 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 { + 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>>, + commits: Arc, + } + + 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 { + 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>>) { + 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"]); + } +} diff --git a/crates/ide-extension/src/ui.rs b/crates/ide-extension/src/ui.rs new file mode 100644 index 000000000..e12bfceec --- /dev/null +++ b/crates/ide-extension/src/ui.rs @@ -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>, + /// (plugin, asset id) → content. + assets: RwLock>>, + seq: AtomicU64, + revision: AtomicU64, + index: RwLock>>, +} + +impl UiStore { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Adds an item; the returned disposable removes it. + pub fn insert(self: &Arc, 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, 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> { + 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)> { + 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>) { + *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 { + 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 { + 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, + /// Large validated payloads fetched separately by the client. + pub assets: Vec<(String, String)>, +} + +impl UiDecl { + pub fn new(key: impl Into, label: impl Into, 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 + 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, + validate: Box, +} + +impl UiPoint { + pub fn new( + id: &'static str, + store: Arc, + validate: impl Fn(&Package, toml::Value) -> Result + 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 { + 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}"), "".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("")); + 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()); + } +} diff --git a/crates/ide-extension/src/when.rs b/crates/ide-extension/src/when.rs new file mode 100644 index 000000000..98cd3c931 --- /dev/null +++ b/crates/ide-extension/src/when.rs @@ -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>, 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}"); + } + } +} diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 6f2c1ce54..47095ccb2 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -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 diff --git a/crates/ide-host/benches/plugins.rs b/crates/ide-host/benches/plugins.rs new file mode 100644 index 000000000..491fc974a --- /dev/null +++ b/crates/ide-host/benches/plugins.rs @@ -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 { + 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); diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs new file mode 100644 index 000000000..d55fb96ed --- /dev/null +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -0,0 +1,633 @@ +//! `openidea-plugin`: scaffolds, builds, checks and links OpenIDEA plugins. +//! +//! ```text +//! openidea-plugin new [--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 # a publisher's Ed25519 key +//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # -.oiplugin +//! openidea-plugin index --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 [options] + +commands: + new [--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 create a publisher signing key (keep the file secret) + pack [dir] [--key F --publisher ID] [--out FILE] + write -.oiplugin, signed with the key + index --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 = 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, + options: Vec<(String, Option)>, +} + +impl Args { + fn parse(args: &[String], switches: &[&str], valued: &[&str]) -> Result { + 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 { + 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 { + Args::parse(args, &[], &[])?.dir() +} + +fn canonical(dir: &Path) -> Result { + 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 [--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::()).unwrap_or_default() + }) + .collect::>() + .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 { + 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 ", 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 { + 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 { + 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 = 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 = 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 { + 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, +} + +fn read_links(file: &Path) -> Result { + 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::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 ".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(f: impl FnOnce(&PluginHost) -> Result) -> Result { + 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 = 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 = 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 = 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(); + } + } +} diff --git a/crates/ide-host/src/bin/openidea-plugin/templates.rs b/crates/ide-host/src/bin/openidea-plugin/templates.rs new file mode 100644 index 000000000..2f9b504fe --- /dev/null +++ b/crates/ide-host/src/bin/openidea-plugin/templates.rs @@ -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::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, 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##" +"##; + +const ICON_FOLDER: &str = r##" +"##; + +const ICON_FOLDER_OPEN: &str = r##" +"##; + +const ICON_MARKDOWN: &str = r##" +"##; + +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/.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" +"##; diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs index ada210d86..6a1f25785 100644 --- a/crates/ide-host/src/bin/server.rs +++ b/crates/ide-host/src/bin/server.rs @@ -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"); diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 898b2884c..edaafb43b 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -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> { self.projects.read().values().cloned().collect() diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index f84ab3c73..984466712 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -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, @@ -38,9 +40,87 @@ pub async fn lsp_completion( ) -> IdeResult { 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, 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(f: impl FnOnce() -> T + Send + 'static) -> Option { + 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> { 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> { - 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> { + 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 = 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, diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs new file mode 100644 index 000000000..da79e228c --- /dev/null +++ b/crates/ide-host/src/plugins/api.rs @@ -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; + /// Unsaved text of the open document at `path`. + fn buffer_text(&self, path: &Path) -> Option; + /// 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; + /// 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; + /// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones). + fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec) + -> 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 { + 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; + /// 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 { + 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 { + 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 = 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:}}` in `value` with the plugin's secrets. +fn fill_secrets(value: &str, lookup: impl Fn(&str) -> Option) -> Result { + 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, + pub registry: Arc, + pub workspace: RwLock>>, + /// `/.data`: one directory per plugin id. + pub data_dir: PathBuf, + tasks: Mutex>, + next_task: AtomicU64, + /// (plugin, item id) → item, for windows opened later. + status: Mutex>, + /// plugin → kinds subscribed at run time (kept across idle reclaim). + pub subscriptions: Mutex>>, + pub secrets: RwLock>, + /// (plugin, `process:` capability) → the approved program. + pub pins: Arc>>, + /// Validated UI declarations (settings schemas, declared views). + pub ui: Arc, +} + +impl Shared { + pub fn new( + sink: Arc, + registry: Arc, + data_dir: PathBuf, + pins: Arc>>, + ui: Arc, + ) -> Arc { + 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> { + self.workspace.read().clone() + } + + pub fn project_of(&self, window: &str) -> Option { + 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 = { + let mut status = self.status.lock(); + let ids: Vec = 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 = 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, +} + +impl PluginApi { + fn id(&self) -> &str { + self.pkg.id() + } + + fn load_storage(&self) -> Result, 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) -> 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 { + 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) { + 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 { + 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 { + self.shared.workspace().map(|w| w.projects()).unwrap_or_default() + } + + fn buffer_text(&self, path: &Path) -> Option { + 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, + ) -> Result { + 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, HostError> { + Ok(self.load_storage()?.get(key).cloned()) + } + + fn storage_set(&self, key: &str, value: Option) -> 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, HostError> { + Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect()) + } + + fn vcs_status(&self, root: &Path) -> Result, 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, 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) -> 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 { + 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, + cwd: Option<&Path>, + stdin: Option, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result { + 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>| { + 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)); + let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box)); + 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, message: Option) { + 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, + 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 { + let schema: Vec = 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); + +impl HostWorkspace { + fn projects(&self) -> Vec> { + 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, 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 { + let mut roots: Vec = HostWorkspace::projects(self).iter().map(|p| p.root.clone()).collect(); + roots.sort(); + roots.dedup(); + roots + } + + fn buffer_text(&self, path: &Path) -> Option { + 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 { + 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 { + self.0.upgrade()?.project(window).ok().map(|p| p.root.clone()) + } + + fn publish_diagnostics( + &self, + source: &str, + path: &Path, + items: Vec, + ) -> 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 { + 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>>) -> 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 = 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>> = 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")) + ); + } +} diff --git a/crates/ide-host/src/plugins/icons.rs b/crates/ide-host/src/plugins/icons.rs new file mode 100644 index 000000000..8df6a94be --- /dev/null +++ b/crates/ide-host/src/plugins/icons.rs @@ -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, + #[serde(default)] + file: Option, + #[serde(default)] + folder: Option, + #[serde(default)] + folder_expanded: Option, + #[serde(default)] + file_extensions: BTreeMap, + #[serde(default)] + file_names: BTreeMap, + #[serde(default)] + folder_names: BTreeMap, +} + +/// 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/`). +pub fn svg_prefix(theme: &str) -> String { + format!("iconTheme/{theme}/svg/") +} + +pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result { + 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, what: &str| -> Result, 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 { + let mut out = String::with_capacity(src.len()); + let mut stack: Vec = 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 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 element".into()); + } + } + rest = &rest[lt..]; + if let Some(after) = rest.strip_prefix("").ok_or("unterminated comment")?; + rest = &after[end + 3..]; + continue; + } + if rest.starts_with("").ok_or("unterminated processing instruction")?; + rest = &rest[end + 2..]; + continue; + } + if rest.starts_with("').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 "))?; + if open != name { + return Err(format!(" does not close <{open}>")); + } + if skip_depth > 0 { + skip_depth -= 1; + } else { + out.push_str(&format!("")); + } + 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 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 element".into()); + } + Ok(out) +} + +/// Index of the `>` ending the tag at the start of `s`, skipping quoted values. +fn tag_end(s: &str) -> Option { + let mut quote: Option = 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, 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 { + 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 { + 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##" + + + rust + + + +"##; + let out = sanitize_svg(src).unwrap(); + assert!(out.starts_with("")); + } + + #[test] + fn rejects_active_content() { + let bad = [ + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##"]>"##, + r##"x"##, + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##"hello"##, + r##""##, + ]; + 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#""#) + .unwrap(); + std::fs::write(dir.join("icons/file.svg"), r#""#) + .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#""#).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()); + } +} diff --git a/crates/ide-host/src/plugins/install.rs b/crates/ide-host/src/plugins/install.rs new file mode 100644 index 000000000..1409b5f81 --- /dev/null +++ b/crates/ide-host/src/plugins/install.rs @@ -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: +//! `///` 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 `//` 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-"; +/// `/active`: the version in use. +pub const ACTIVE_FILE: &str = "active"; +/// `/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 { + 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` +/// (`/`): `/`, or `root` itself in the flat layout. +pub fn active_dir(root: &Path) -> Option { + 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 { + read_pointer(root, PREVIOUS_FILE).filter(|v| root.join(v).join(ide_extension::MANIFEST_FILE).is_file()) +} + +/// Moves a flat install (`/plugin.toml`) to `//`. +fn migrate_flat(root: &Path, version_of: impl Fn(&Path) -> Option) -> 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--…` 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 `/` 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, +) -> io::Result> { + 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 = [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--*` when +/// `` 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) { + // `--`: 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"]); + } +} diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs new file mode 100644 index 000000000..8e34c2b63 --- /dev/null +++ b/crates/ide-host/src/plugins/market.rs @@ -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, +} + +/// Reads `url`: `http(s)://…`, `file://…` or a local path. +pub fn read_url(url: &str, max: usize) -> Result, 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 { + 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 { + self.state.lock().market_url.clone() + } + + pub fn set_market_url(&self, url: Option) { + 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> { + 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 { + 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 { + 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 { + 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> { + self.package_files_in(dir) + } + + pub(super) fn package_files_in(&self, dir: &Path) -> IdeResult> { + 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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs new file mode 100644 index 000000000..4ca93b2fb --- /dev/null +++ b/crates/ide-host/src/plugins/mod.rs @@ -0,0 +1,2266 @@ +//! The plugin host (`docs/plugin_plan.md` 3): assembles the extension +//! registry (`ide-extension`), the grant ledger and the Wasm runtime +//! (`ide-plugin`). It runs where the project is — in the desktop app for local +//! projects, in `openidea-server` for remote ones; the desktop shell only adapts +//! it to Tauri commands. +//! +//! Lifecycle of a package: its manifest is parsed and every declaration +//! validated by its extension point (no side effects); the grant ledger decides +//! whether it may load (project and new plugins wait for the user's consent); +//! then its contributions are committed all or nothing. Code is instantiated +//! only when one of its activation events fires (a command runs, or start-up +//! finished for `on-startup-finished`). +//! +//! Sources: `//` (installed), `/.openidea/plugins/*/` +//! (project plugins, loaded by [`PluginHost::sync_project`] for trusted +//! projects) and folders linked with `openidea-plugin link` (`links.json`). + +mod api; +mod icons; +mod install; +mod market; +mod points; +mod providers; +pub mod remote; +pub mod remote_host; +mod run_detectors; +mod theme; +mod types; +pub mod ui_points; +pub mod views; + +pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore}; +pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix}; +pub use market::{INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin}; +pub use points::{COMMANDS, CommandDecl, MAX_COMMANDS, register_builtin_points}; +pub(crate) use providers::editor_providers as editor_providers_for; +pub use providers::{ProviderKind, set_editor_providers}; +pub use theme::{ThemeDef, ThemeKind}; +pub use types::*; + +use ide_core::{IdeError, IdeResult, LanguageId}; +use ide_extension::ledger::{self, BinaryGrant, Decision, Ledger, Request, Subject}; +use ide_extension::{ + ActivationEvent, Capability, CapabilityInfo, ContributionIndex, ExtensionRegistry, MANIFEST_FILE, Manifest, + Package, Scope, UiContribution, UiStore, +}; +use ide_language::plugin_point::PluginLanguages; +use ide_language::{LanguageConfig, ProgramPin}; +use ide_plugin::{ + Actor, ActorError, CANCELLED, CallError, HostApi, Invocation, Limits, PluginEngine, Runtime, RuntimeSetup, +}; +use parking_lot::{Condvar, Mutex, RwLock}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +const STATE_FILE: &str = "state.json"; +const LINKS_FILE: &str = "links.json"; +/// Project 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, + #[serde(default)] + theme: Option, + /// Marketplace index URL (or folder path). + #[serde(default, skip_serializing_if = "Option::is_none")] + market_url: Option, +} + +#[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, +} + +#[derive(Default, Serialize, Deserialize)] +struct Links { + #[serde(default)] + paths: Vec, +} + +struct Meta { + status: PluginStatus, + error: Option, + warnings: Vec, + /// Capabilities the ledger granted (valid while live). + granted: Vec, + /// Required capabilities still awaiting consent (`NeedsConsent`). + pending: Vec, + /// Capabilities the declarations need beyond the manifest (`process:` of a language server). + declared_extra: Vec, + stats: PluginStats, + /// Durations of the recent calls (ms), for the p95. + latencies: VecDeque, + /// Consecutive crashes; reset by a call that returns. + failures: u32, + /// No restart before this (backoff after a crash). + retry_at: Option, + /// The component exports `syntax-provider` (known once activated). + has_syntax: bool, + /// Next `on-schedule` activation. + next_schedule: Option, + /// The trusted publisher of a signed installed package. + publisher: Option, +} + +impl Meta { + fn new() -> Self { + Self { + status: PluginStatus::Disabled, + error: None, + warnings: Vec::new(), + granted: Vec::new(), + pending: Vec::new(), + declared_extra: Vec::new(), + stats: PluginStats::default(), + latencies: VecDeque::new(), + failures: 0, + retry_at: None, + has_syntax: false, + publisher: None, + next_schedule: None, + } + } + + fn record_call(&mut self, elapsed: Duration, ok: bool) { + let ms = elapsed.as_secs_f64() * 1000.0; + self.stats.calls += 1; + self.stats.max_call_ms = self.stats.max_call_ms.max(ms); + if !ok { + self.stats.errors += 1; + } + if self.latencies.len() == LATENCY_WINDOW { + self.latencies.pop_front(); + } + self.latencies.push_back(ms as f32); + } + + fn p95(&self) -> f64 { + if self.latencies.is_empty() { + return 0.0; + } + let mut sorted: Vec = self.latencies.iter().copied().collect(); + sorted.sort_by(f32::total_cmp); + f64::from(sorted[(sorted.len() * 95).div_ceil(100).saturating_sub(1)]) + } +} + +/// Calls kept for the latency percentile. +const LATENCY_WINDOW: usize = 128; + +/// Events waiting for one plugin (from one window). +struct EventQueue { + since: Instant, + events: Vec, +} + +/// Most events queued per plugin and window; older ones are dropped. +const MAX_QUEUED_EVENTS: usize = 500; +/// How long events for a plugin that is not running are collected before it +/// is activated to receive them. +const IDLE_BATCH: Duration = Duration::from_secs(2); + +struct LoadedPlugin { + pkg: Package, + /// The manifest failed to parse: `pkg` is a placeholder. + invalid: bool, + source: PluginSource, + /// Project that owns a project plugin. + dev_root: Option, + meta: Mutex, + /// The running instance, on its own thread. + runtime: Mutex>>, + /// Event batches handed to the actor and not finished yet. + batches: Arc>, +} + +#[derive(Default)] +struct Batches { + /// Posted, not finished. + pending: u32, + /// Kinds of the batch being handled right now. + running: Option>, + /// A finished batch changed contributions: the next flush announces it. + changed: bool, +} + +impl LoadedPlugin { + /// Statistics of one call; a call that returned (`healthy`) ends a crash streak. + fn record_call(&self, elapsed: Duration, ok: bool, healthy: bool) { + let mut meta = self.meta.lock(); + meta.record_call(elapsed, ok); + if healthy && meta.failures > 0 { + meta.failures = 0; + meta.retry_at = None; + meta.error = None; + } + } + + fn id(&self) -> &str { + self.pkg.id() + } + + fn manifest(&self) -> &Manifest { + &self.pkg.manifest + } + + fn dir(&self) -> &Path { + &self.pkg.dir + } + + fn status(&self) -> PluginStatus { + self.meta.lock().status + } + + fn subject(&self) -> Subject { + match self.source { + PluginSource::Installed => Subject::Installed(self.id().to_owned()), + PluginSource::Dev | PluginSource::Linked => Subject::Path(self.dir().to_path_buf()), + } + } +} + +/// The user's answer on an install or consent page. +#[derive(Clone, Debug, Default, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Consent { + /// Optional capabilities the user accepted (`net:crates.io`, …). Required + /// ones are implied by consenting at all. + pub optional: Vec, +} + +impl Consent { + /// Accept everything the plugin asks for (tests, trusted official plugins). + pub fn all() -> Self { + Self { optional: vec!["*".into()] } + } + + fn accepts(&self, cap: &Capability) -> bool { + self.optional.iter().any(|s| s == "*" || s.parse::().is_ok_and(|c| c == *cap)) + } +} + +/// What the install page shows before anything is copied. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct InstallPreview { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub authors: Vec, + pub has_code: bool, + pub capabilities: Vec, + pub contributions: Vec, + pub warnings: Vec, + /// Version currently installed under the same id. + pub replaces: Option, + /// Not signed by a publisher the marketplace index trusts: warn the user. + pub unsigned: bool, + /// The verified publisher. + pub publisher: Option, +} + +type ProjectPolicy = Arc bool + Send + Sync>; +/// (plugin, window) of a batch of workbench events. +type QueueKey = (String, Option); +type PathVar = Arc Option + Send + Sync>; + +pub struct PluginHost { + project_policy: RwLock, + engine: PluginEngine, + plugins_dir: PathBuf, + sink: Arc, + limits: Limits, + registry: Arc, + ui: Arc, + languages: Arc, + run_detectors: Arc, + /// `[[contributes.providers]]` of enabled plugins. + providers: Arc, + /// Validated UI declarations of installed packages (start-up reuses them). + index: Arc, + ledger: Mutex, + plugins: RwLock>>, + state: Mutex, + /// What every plugin's [`api::PluginApi`] shares: sink, registry, + /// workspace, status items, tasks, subscriptions. + shared: Arc, + /// Workbench events waiting for delivery: (plugin, window) → batch. + queues: Mutex>, + /// 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, Condvar), + /// `on-startup-finished` has fired. + startup_finished: AtomicBool, + /// The theme last announced with `ThemeChanged`. + announced_theme: Mutex>, + /// [`PluginLanguages::revision`] last announced with `LanguagesChanged`. + announced_languages: AtomicU64, + /// `PATH` used to resolve `process:` programs at grant time. + path_var: RwLock, + /// (plugin id, `process:` capability) → the approved program; read by the + /// language store's policy (plugin language servers and debug adapters). + pins: Arc>>, + /// The last marketplace index read: trust root for signatures, revocations. + market: RwLock>, + /// Updates not confirmed yet (the new version hasn't started once): what + /// to roll back to. + updates: Mutex>, +} + +struct PendingUpdate { + previous: String, + grant: Option, +} + +impl PluginHost { + /// Creates the host; call [`load_all`](Self::load_all) to load installed plugins. + pub fn new(plugins_dir: PathBuf, sink: Arc, limits: Limits) -> IdeResult { + std::fs::create_dir_all(&plugins_dir)?; + install::recover(&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(); + let registry = Arc::new(ExtensionRegistry::new()); + let ui = UiStore::new(); + let index = Arc::new(ContributionIndex::new( + plugins_dir.join(".cache").join("contributions"), + env!("CARGO_PKG_VERSION"), + )); + ui.set_index(Some(index.clone())); + let languages = PluginLanguages::new(); + let run_detectors: Arc = Arc::default(); + let providers: Arc = Arc::default(); + register_builtin_points(®istry, &ui, &languages, &run_detectors, &providers).map_err(IdeError::internal)?; + let pins: Arc>> = Arc::default(); + let policy_pins = pins.clone(); + languages.set_process_policy(Arc::new(move |plugin, cap| { + policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned() + })); + let shared = + api::Shared::new(sink.clone(), registry.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone()); + let market = RwLock::new(Self::load_market_index(&plugins_dir)); + let host = Self { + market, + updates: Mutex::default(), + project_policy: RwLock::new(Arc::new(|_| false)), + engine: PluginEngine::new().map_err(IdeError::internal)?, + ledger: Mutex::new(Ledger::load(&plugins_dir)), + plugins_dir, + sink, + limits, + registry, + ui, + languages, + run_detectors, + providers, + index, + plugins: RwLock::default(), + state: Mutex::new(state), + shared, + queues: Mutex::default(), + lifecycle: Mutex::new(()), + initial_load: (Mutex::new(false), Condvar::new()), + startup_finished: AtomicBool::new(false), + announced_theme: Mutex::new(None), + announced_languages: AtomicU64::new(0), + path_var: RwLock::new(Arc::new(|| std::env::var_os("PATH"))), + pins, + }; + host.refresh_pins(&host.ledger.lock()); + Ok(host) + } + + pub fn plugins_dir(&self) -> &Path { + &self.plugins_dir + } + + /// The extension registry: feature crates register their points here. + pub fn registry(&self) -> &Arc { + &self.registry + } + + /// `PATH` for resolving `process:` programs when they are granted. + pub fn set_program_path(&self, path: PathVar) { + *self.path_var.write() = path; + } + + /// Loads every installed and linked package (already loaded ones are kept). + /// Instantiates no Wasm: code runs on its activation events. + pub fn load_all(&self) { + let _guard = self.lifecycle.lock(); + let dirs = package_dirs(&self.plugins_dir); + self.migrate_grants(&dirs); + for dir in dirs { + if !self.is_loaded_from(&dir) { + self.load_dir(&dir, PluginSource::Installed, None); + } + } + for dir in self.links().paths { + if !self.is_loaded_from(&dir) && dir.join(MANIFEST_FILE).is_file() { + self.load_dir(&dir, PluginSource::Linked, None); + } + } + let (done, cv) = &self.initial_load; + *done.lock() = true; + cv.notify_all(); + self.changed(); + } + + /// Fires `on-startup-finished`: activates the plugins waiting for it. + /// Plugins loaded afterwards with that event activate right away. + pub fn startup_finished(&self) { + if self.startup_finished.swap(true, Ordering::AcqRel) { + return; + } + let waiting: Vec<_> = self + .plugins + .read() + .values() + .filter(|p| { + p.status() == PluginStatus::Inactive + && p.manifest().activation.contains(&ActivationEvent::StartupFinished) + }) + .cloned() + .collect(); + for p in waiting { + let _ = self.ensure_active(&p, None); + } + self.changed(); + } + + /// Where compiled components are kept between runs. + fn component_cache(&self) -> PathBuf { + self.plugins_dir.join(".cache").join("components") + } + + /// Precompiles every loaded plugin's component that is not cached yet and + /// drops cache entries no loaded plugin uses (after an engine upgrade or an + /// uninstall), compiled components and indexed contributions alike. Slow; + /// call it on a background thread after start-up. + pub fn warm_component_cache(&self) { + let digests: Vec> = self.plugins.read().values().filter_map(|p| p.pkg.digest.clone()).collect(); + self.index.prune(digests.iter().map(|d| &**d)); + let cache = self.component_cache(); + let wasm: Vec = self + .plugins + .read() + .values() + .filter(|p| p.status() != PluginStatus::Disabled) + .filter_map(|p| p.manifest().wasm.as_ref().map(|w| p.dir().join(w))) + .collect(); + let mut keep = BTreeSet::new(); + for file in wasm { + match self.engine.precompile(&file, &cache) { + Ok(name) => { + keep.insert(name); + } + Err(e) => tracing::debug!("precompiling {}: {e}", file.display()), + } + } + if let Ok(entries) = std::fs::read_dir(&cache) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if !keep.contains(&name) { + let _ = std::fs::remove_file(e.path()); + } + } + } + } + + /// Fires an activation event from the UI (`on-language:rust`, `on-file:…`, `on-view:…`). + pub fn activation_event(&self, event: &ActivationEvent, project: Option<&Path>) { + let targets: Vec<_> = self + .plugins + .read() + .values() + .filter(|p| p.status() == PluginStatus::Inactive) + .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) + .filter(|p| p.manifest().activation.iter().any(|a| activation_matches(a, event))) + .cloned() + .collect(); + if targets.is_empty() { + return; + } + for p in targets { + let _ = self.ensure_active(&p, None); + } + self.changed(); + } + + /// 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 + } + + /// Whether project plugins of a root may load (project trust). + pub fn set_project_policy(&self, policy: ProjectPolicy) { + *self.project_policy.write() = policy; + } + + /// Unloads the project plugins of `root` (the project became untrusted or closed). + 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.changed(); + } + + /// Loads new project plugins from `/.openidea/plugins/` (trusted + /// projects only) and unloads vanished ones. Each still needs the user's + /// consent before it runs. + 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 = self + .plugins + .read() + .iter() + .filter(|(_, p)| p.dev_root.as_deref() == Some(root) && !dirs.iter().any(|d| d == 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.changed(); + } + } + + /// Re-reads `links.json` (written by `openidea-plugin link`): loads new + /// linked plugins and unloads unlinked ones. + pub fn sync_links(&self) { + let links = self.links().paths; + let _guard = self.lifecycle.lock(); + let stale: Vec = self + .plugins + .read() + .iter() + .filter(|(_, p)| p.source == PluginSource::Linked && !links.iter().any(|l| l == p.dir())) + .map(|(id, _)| id.clone()) + .collect(); + let mut changed = !stale.is_empty(); + for id in stale { + self.unload(&id); + } + for dir in links { + if !self.is_loaded_from(&dir) && dir.join(MANIFEST_FILE).is_file() { + self.load_dir(&dir, PluginSource::Linked, None); + changed = true; + } + } + if changed { + self.changed(); + } + } + + /// Links a development plugin folder (loaded until unlinked; granted by path). + pub fn link(&self, dir: &Path) -> IdeResult { + let dir = dunce::canonicalize(dir)?; + Manifest::load(&dir).map_err(IdeError::invalid)?; + let mut links = self.links(); + if !links.paths.contains(&dir) { + links.paths.push(dir.clone()); + self.save_links(&links)?; + } + self.sync_links(); + let id = self.plugins.read().values().find(|p| p.dir() == dir).map(|p| p.id().to_owned()); + self.info_of(&id.ok_or_else(|| IdeError::invalid("the linked plugin's id is already in use"))?) + } + + pub fn unlink(&self, id: &str) -> IdeResult<()> { + let plugin = self.get(id)?; + if plugin.source != PluginSource::Linked { + return Err(IdeError::invalid(format!("plugin {id:?} is not linked"))); + } + let mut links = self.links(); + links.paths.retain(|p| p != plugin.dir()); + self.save_links(&links)?; + self.sync_links(); + Ok(()) + } + + /// Installed and linked plugins plus the project plugins of `project`. + pub fn list(&self, project: Option<&Path>) -> PluginsSnapshot { + let visible: Vec> = self + .plugins + .read() + .values() + .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) + .cloned() + .collect(); + let ids: BTreeSet<&str> = visible.iter().map(|p| p.id()).collect(); + let ui: Vec = + self.ui.snapshot().into_iter().filter(|c| ids.contains(c.plugin_id.as_str())).collect(); + let mut plugins: Vec = visible.iter().map(|p| self.info(p)).collect(); + plugins.sort_by_key(|p| p.name.to_lowercase()); + PluginsSnapshot { plugins, active_theme: self.active_theme(), ui } + } + + pub fn info_of(&self, id: &str) -> IdeResult { + let plugin = self.get(id)?; + Ok(self.info(&plugin)) + } + + /// UI contributions of one point (all live plugins). + pub fn ui_items(&self, point: &str) -> Vec { + self.ui.items(point, None) + } + + /// Validated assets of a plugin whose id starts with `prefix` (icon theme SVGs). + pub fn assets(&self, id: &str, prefix: &str) -> IdeResult> { + let plugin = self.get(id)?; + if !plugin.status().is_live() { + return Err(IdeError::unavailable(format!("plugin {id:?} is not enabled"))); + } + Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect()) + } + + 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.changed(); + Ok(()) + } + + /// Re-reads a plugin from disk (e.g. after rebuilding a dev plugin). + pub fn reload(&self, id: &str) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + let (dir, source, root) = (plugin.dir().to_path_buf(), plugin.source, plugin.dev_root.clone()); + self.unload(id); + let new_id = self.load_dir(&dir, source, root); + self.changed(); + self.info_of(&new_id) + } + + /// Validates a package for the install page: nothing is copied or run. + pub fn preview_install(&self, src: &Path) -> IdeResult { + let (pkg, set) = self.validate_package(src)?; + if let Some(problem) = self.signature_problem(src) { + return Err(IdeError::invalid(format!("refusing {}: {problem}", pkg.id()))); + } + let publisher = self.verified_publisher(src); + let m = &pkg.manifest; + let mut capabilities: Vec = + m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), false)).collect(); + for extra in &set.capabilities { + if !m.declares(extra) { + capabilities.push(CapabilityInfo::new(extra, true, false)); + } + } + capabilities.sort_by(|a, b| b.risk.cmp(&a.risk).then(a.id.cmp(&b.id))); + Ok(InstallPreview { + id: m.id.clone(), + name: m.name.clone(), + version: m.version.clone(), + description: m.description.clone(), + authors: m.authors.clone(), + has_code: m.wasm.is_some(), + capabilities, + contributions: counts(m), + warnings: set.warnings.clone(), + replaces: self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone()), + unsigned: publisher.is_none(), + publisher, + }) + } + + /// Installs the package in `src` (manifest + the files its declarations + /// reference) with the user's `consent`, replacing an installed version + /// with the same id atomically. + pub fn install_from_dir(&self, src: &Path, consent: &Consent) -> IdeResult { + let _guard = self.lifecycle.lock(); + let (pkg, set) = self.validate_package(src)?; + let id = pkg.id().to_owned(); + if let Some(existing) = self.plugins.read().get(&id) + && existing.source != PluginSource::Installed + { + return Err(IdeError::new( + ide_core::error::ErrorKind::AlreadyExists, + format!("a development plugin with id {id:?} is loaded from {}", existing.dir().display()), + )); + } + // A signature that doesn't hold is worse than none. + if let Some(problem) = self.signature_problem(src) { + return Err(IdeError::invalid(format!("refusing {id}: {problem}"))); + } + let version = pkg.manifest.version.clone(); + if let Some(r) = self.market.read().as_ref().and_then(|i| i.revocation(&id, &version)) { + return Err(IdeError::invalid(format!("{id} {version} was revoked: {}", r.reason))); + } + let mut files = set.files.clone(); + files.extend(pkg.manifest.wasm.clone()); + let hash = ledger::package_hash(src, &files)?; + let root = self.plugins_dir.join(&id); + let current = install::active_dir(&root); + let same_dir = dunce::canonicalize(src) + .ok() + .zip(current.as_ref().and_then(|c| dunce::canonicalize(c).ok())) + .is_some_and(|(a, b)| a == b); + let subject = Subject::Installed(id.clone()); + let old_grant = self.ledger.lock().get(&subject).cloned(); + self.unload(&id); + let previous = if same_dir { + std::fs::write(current.as_ref().expect("same dir").join(install::HASH_FILE), &hash)?; + None + } else { + let version_of = |d: &Path| Manifest::load(d).ok().map(|m| m.version); + let previous = install::install_version(src, &root, &version, &files, &hash, version_of)?; + market::copy_signature(src, &root.join(&version)); + previous + }; + // Consent covers what the install page showed. + let request = request_for(&subject, &pkg.manifest, &hash, &set.capabilities); + self.record_consent(&request, consent); + let dir = install::active_dir(&root).ok_or_else(|| IdeError::internal("the installed version is missing"))?; + self.load_dir(&dir, PluginSource::Installed, None); + if let Some(previous) = previous { + let failed = self + .get(&id) + .ok() + .filter(|p| p.status() == PluginStatus::Failed) + .map(|p| p.meta.lock().error.clone().unwrap_or_else(|| "it failed to load".into())); + if let Some(reason) = failed { + self.roll_back(&id, &previous, &version, old_grant); + self.changed(); + return Err(IdeError::invalid(format!( + "{} {version} could not start ({reason}); version {previous} was restored", + pkg.manifest.name + ))); + } + // Confirmed once the new version's code has started (or right away without code). + if pkg.manifest.wasm.is_some() { + self.updates.lock().insert(id.clone(), PendingUpdate { previous, grant: old_grant }); + } + } + install::prune(&root); + self.changed(); + self.info_of(&id) + } + + /// Makes `previous` the active version of `id` again and drops `failed` + /// (its directory, its consent). Called with the plugin unloaded or not. + fn roll_back(&self, id: &str, previous: &str, failed: &str, grant: Option) { + let root = self.plugins_dir.join(id); + self.unload(id); + if let Err(e) = install::activate_version(&root, previous) { + tracing::warn!("plugin {id}: rolling back to {previous}: {e}"); + return; + } + if failed != previous { + let _ = std::fs::remove_dir_all(root.join(failed)); + } + { + let mut ledger = self.ledger.lock(); + ledger.restore(&Subject::Installed(id.to_owned()), grant); + self.save_ledger(&ledger); + } + self.updates.lock().remove(id); + if let Some(dir) = install::active_dir(&root) { + self.load_dir(&dir, PluginSource::Installed, None); + } + } + + /// An update whose code failed to start: back to the previous version. + /// Returns the message for the user when it rolled back. + fn roll_back_failed_update( + &self, + plugin: &Arc, + reason: &str, + client: Option<&str>, + ) -> Option { + let id = plugin.id().to_owned(); + let pending = self.updates.lock().remove(&id)?; + let version = plugin.manifest().version.clone(); + let name = plugin.manifest().name.clone(); + // The caller may hold the lifecycle lock (activation during start-up): don't wait for it forever. + let _guard = self.lifecycle.try_lock_for(Duration::from_secs(5)); + self.roll_back(&id, &pending.previous, &version, pending.grant); + let message = format!("{name} {version} failed to start ({reason}); version {} was restored", pending.previous); + self.emit( + client, + PluginEvent::Notification { + plugin_id: id, + level: ide_plugin::NotificationLevel::Warning, + title: format!("{name} update rolled back"), + body: message.clone(), + }, + ); + self.changed(); + Some(message) + } + + /// Settings › Plugins › Roll Back: the version the last update replaced. + pub fn roll_back_update(&self, id: &str) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + if plugin.source != PluginSource::Installed { + return Err(IdeError::invalid("only installed plugins have versions to roll back to")); + } + let root = self.plugins_dir.join(id); + let previous = install::previous_version(&root) + .ok_or_else(|| IdeError::not_found(format!("{id} has no earlier version to roll back to")))?; + let current = plugin.manifest().version.clone(); + // The earlier version's consent: what it declared, as granted now where it overlaps. + let grant = self.ledger.lock().get(&Subject::Installed(id.to_owned())).cloned(); + self.roll_back(id, &previous, ¤t, grant); + self.changed(); + self.info_of(id) + } + + /// Confirms a plugin waiting for consent (project plugins, new capabilities). + pub fn grant(&self, id: &str, consent: &Consent) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + if plugin.invalid { + return Err(IdeError::invalid("the plugin's manifest is invalid")); + } + let hash = self.package_hash_of(&plugin); + let extra = plugin.meta.lock().declared_extra.clone(); + let request = request_for(&plugin.subject(), plugin.manifest(), &hash, &extra); + self.record_consent(&request, consent); + self.stop(&plugin); + if !self.state.lock().disabled.contains(id) { + self.start(&plugin); + } + self.changed(); + self.info_of(id) + } + + /// Grants or revokes one capability (Settings › Plugins › Permissions). + /// Revoking a required one stops the plugin until consent is given again. + pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + let cap: Capability = capability.parse().map_err(IdeError::invalid)?; + if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) { + return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}"))); + } + { + let mut ledger = self.ledger.lock(); + let subject = plugin.subject(); + if ledger.get(&subject).is_none() { + return Err(IdeError::invalid(format!("plugin {id:?} has not been confirmed yet"))); + } + ledger.set(&subject, &cap, granted); + if granted + && let Capability::Process(program) = &cap + && let Some(bin) = self.resolve_binary(program) + { + ledger.set_binary(&subject, &cap, bin); + } + self.save_ledger(&ledger); + } + self.stop(&plugin); + if !self.state.lock().disabled.contains(id) { + self.start(&plugin); + } + self.changed(); + self.info_of(id) + } + + /// The program a `process:` grant allows, checked against the file + /// approved for it. Errors ask the user to confirm again. + pub fn program_for(&self, id: &str, program: &str) -> IdeResult { + let plugin = self.get(id)?; + let cap = Capability::Process(program.to_owned()); + self.ledger + .lock() + .check_binary(&plugin.subject(), &cap) + .map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e)) + } + + 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( + "development plugins are removed by deleting them from .openidea/plugins or unlinking them", + )); + } + self.unload(id); + // Every version (`/`), or the flat directory of an older install. + let root = self.plugins_dir.join(id); + std::fs::remove_dir_all(if root.is_dir() { root } else { plugin.dir().to_path_buf() })?; + self.updates.lock().remove(id); + // Its storage goes with it. + let _ = std::fs::remove_dir_all(self.shared.data_dir.join(id)); + { + let mut ledger = self.ledger.lock(); + ledger.remove(&plugin.subject()); + self.save_ledger(&ledger); + } + { + let mut state = self.state.lock(); + state.disabled.remove(id); + if state.theme.as_ref().is_some_and(|t| t.plugin == id) { + state.theme = None; + } + } + self.save_state(); + self.changed(); + Ok(()) + } + + /// Runs a plugin command, activating the plugin first when needed + /// (`on-command`). Blocks for up to the configured time limit. + pub fn run_command( + &self, + id: &str, + command: &str, + arg: Option<&str>, + invocation: Invocation, + ) -> IdeResult> { + let (cmd, arg) = (command.to_owned(), arg.map(str::to_owned)); + self.code_call(id, invocation, "running the command", move |rt, inv| rt.run_command(&cmd, arg.as_deref(), inv)) + } + + /// Content of a declared view, or of a custom editor (`invocation.editor` + /// is its document). Activates the plugin (`on-view`). + pub fn render_view( + &self, + id: &str, + view: &str, + viewport: Option, + invocation: Invocation, + ) -> IdeResult { + self.declared_view(id, view)?; + let view = view.to_owned(); + self.code_call(id, invocation, "rendering the view", move |rt, inv| rt.render_view(&view, viewport, inv)) + } + + /// A user action in a view or custom editor. + pub fn view_event( + &self, + id: &str, + view: &str, + event: ide_plugin::ViewEvent, + invocation: Invocation, + ) -> IdeResult<()> { + self.declared_view(id, view)?; + let view = view.to_owned(); + self.code_call(id, invocation, "handling the view's event", move |rt, inv| { + rt.view_event(&view, event.clone(), inv).map(|fx| ((), fx)) + }) + } + + fn declared_view(&self, id: &str, view: &str) -> IdeResult<()> { + let declared = [ui_points::VIEWS, ui_points::CUSTOM_EDITORS] + .iter() + .any(|point| self.ui.items(point, Some(id)).iter().any(|i| i.key == view)); + if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) } + } + + /// One call into a plugin's code for the UI: checks it may run (asking + /// for missing consent), activates it, retries once when the instance was + /// reclaimed meanwhile, and reports crashes. + fn code_call( + &self, + id: &str, + invocation: Invocation, + what: &str, + f: impl Fn(&mut Runtime, Invocation) -> Result<(R, ide_plugin::CallEffects), CallError> + Clone + Send + 'static, + ) -> IdeResult { + let plugin = self.get(id)?; + let client = invocation.client.clone(); + { + let meta = plugin.meta.lock(); + let reason = match meta.status { + PluginStatus::Disabled => Some(format!("plugin {id:?} is disabled")), + PluginStatus::Static => Some(format!("plugin {id:?} has no code")), + PluginStatus::NeedsConsent => Some(format!("plugin {id:?} is waiting for your permission")), + PluginStatus::Failed => { + Some(format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed"))) + } + PluginStatus::Active | PluginStatus::Inactive => None, + }; + if let Some(reason) = reason { + return Err(IdeError::unavailable(reason)); + } + } + let pending = plugin.meta.lock().pending.clone(); + if !pending.is_empty() { + // Migrated from 0.1: ask for what was not granted automatically, on first use. + self.emit( + client.as_deref(), + PluginEvent::ConsentRequested { + plugin_id: id.to_owned(), + capabilities: pending.iter().map(|c| CapabilityInfo::new(c, false, false)).collect(), + }, + ); + return Err(IdeError::new( + ide_core::error::ErrorKind::PermissionDenied, + format!("plugin {id:?} needs your permission first"), + )); + } + // Twice at most: the instance may be reclaimed between picking it and queueing the call. + for attempt in 0..2 { + let (actor, activated) = self.ensure_active(&plugin, client.as_deref())?; + if activated { + self.changed(); + } + let started = Instant::now(); + let (call, inv) = (f.clone(), invocation.clone()); + let result = actor.call(None, move |rt| call(rt, inv)); + if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) { + let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_)))); + self.record_call(&plugin, started.elapsed(), result.is_ok(), !crashed); + } + match result { + Ok((value, fx)) => { + if fx.contributions_changed { + self.changed(); + } + return Ok(value); + } + Err(ActorError::Call(CallError::Guest(e))) => return Err(IdeError::internal(e)), + Err(ActorError::Call(CallError::Trap(e))) => { + self.reap(&plugin, &actor, client.as_deref()); + return Err(IdeError::internal(format!("plugin {id:?} {e}"))); + } + Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled) => { + self.reap(&plugin, &actor, client.as_deref()); + if attempt == 1 { + break; + } + } + } + } + Err(IdeError::unavailable(format!("plugin {id:?} stopped before {what}; try again"))) + } + + /// Interrupts the plugin's running call, if any. Its instance is dropped + /// and started again on next use; this does not count as a crash. + pub fn cancel(&self, id: &str) { + if let Some(actor) = self.plugins.read().get(id).and_then(|p| p.runtime.lock().clone()) { + actor.cancel(); + } + } + + /// Runs a `syntax-provider` call of the plugin contributing `language`, + /// activating it on first use. `None` when no running plugin provides it + /// (or the call failed: the editor then shows plain text). + fn syntax_call( + &self, + language: &LanguageId, + f: impl FnOnce(&mut Runtime, Duration) -> Result + Send + 'static, + ) -> Option { + let owner = self.languages.owner_of(language)?; + let plugin = self.get(&owner).ok()?; + { + let meta = plugin.meta.lock(); + let usable = + matches!(meta.status, PluginStatus::Active | PluginStatus::Inactive) && meta.pending.is_empty(); + // Known not to provide syntax (activated before without it). + if !usable || (meta.stats.activations > 0 && !meta.has_syntax) { + return None; + } + } + plugin.manifest().wasm.as_ref()?; + let (actor, activated) = self.ensure_active(&plugin, None).ok()?; + if activated { + self.changed(); + } + if !plugin.meta.lock().has_syntax { + return None; + } + // Edit path: stop waiting at the soft deadline (the result is dropped + // but the instance kept); interrupt at the hard one. + 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) + } + + /// Plugins' `runDetectors`, for [`ide_run::set_external_detection`]. + pub fn run_detection(&self) -> Arc { + Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() }) + } + + /// The syntax of plugin languages, for [`ide_language::set_external_syntax`]. + pub fn syntax_provider(self: &Arc) -> Arc { + Arc::new(PluginSyntax(Arc::downgrade(self))) + } + + /// The UI reports which file its focused editor shows (`editor-focused`). + pub fn set_active_file(&self, client: &str, path: Option) { + self.post_events(Some(client), vec![WorkbenchEvent::EditorFocused(path)]); + } + + /// Where plugin secrets are kept (default: files under the plugins + /// directory; the desktop app uses the OS keychain). + pub fn set_secret_store(&self, store: Arc) { + *self.shared.secrets.write() = store; + } + + /// Stores (or, with `None`, deletes) a secret the plugin declares under + /// `[[contributes.secrets]]`. + pub fn set_secret(&self, id: &str, name: &str, value: Option<&str>) -> IdeResult<()> { + let plugin = self.get(id)?; + if !self.ui.items(providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == name) { + return Err(IdeError::not_found(format!("plugin {id:?} declares no secret {name:?}"))); + } + let value = value.map(str::trim).filter(|v| !v.is_empty()); + self.shared.secrets.read().set(plugin.id(), name, value).map_err(IdeError::internal) + } + + /// The declared secrets of a plugin and whether each is set. + pub fn secrets(&self, id: &str) -> IdeResult> { + let plugin = self.get(id)?; + let store = self.shared.secrets.read().clone(); + Ok(self + .ui + .items(providers::SECRETS_POINT, Some(plugin.id())) + .into_iter() + .map(|s| { + let set = store.get(plugin.id(), &s.key).is_some(); + (s.key, set) + }) + .collect()) + } + + /// What plugins see of the open projects (documents, edits, trust). + pub fn set_workspace(&self, workspace: Option>) { + *self.shared.workspace.write() = workspace; + } + + /// Every plugin status bar item: (plugin, item id, item). + pub fn status_items(&self) -> Vec<(String, String, ide_plugin::StatusItem)> { + self.shared.status_items() + } + + /// The user cancelled a plugin task (`task_id` from `TaskStarted`). + pub fn cancel_task(&self, task_id: &str) -> bool { + task_id.parse().is_ok_and(|id| self.shared.cancel_task(id)) + } + + /// Kinds of workbench events some running or activatable plugin wants; + /// the UI reports only these. + pub fn event_kinds(&self) -> BTreeSet { + let subs = self.shared.subscriptions.lock().clone(); + let mut out = BTreeSet::new(); + for p in self.plugins.read().values() { + for kind in EventKind::ALL { + if self.wants(p, kind, subs.get(p.id())) { + out.insert(kind); + } + } + } + out + } + + /// Whether `plugin` gets `kind` events: subscribed at run time or with + /// `on-event:`, the capability granted, and able to run. + fn wants(&self, plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet>) -> bool { + let declared = + plugin.manifest().activation.iter().any(|a| matches!(a, ActivationEvent::Event(k) if k == kind.name())); + if !declared && !runtime.is_some_and(|s| s.contains(&kind)) { + return false; + } + let meta = plugin.meta.lock(); + matches!(meta.status, PluginStatus::Active | PluginStatus::Inactive) + && kind.capability().is_none_or(|cap| meta.granted.iter().any(|g| g.covers(&cap))) + } + + /// Queues workbench events from window `client` for the plugins that want + /// them; [`flush_events`](Self::flush_events) delivers them in batches. + pub fn post_events(&self, client: Option<&str>, events: Vec) { + if events.is_empty() { + return; + } + let subs = self.shared.subscriptions.lock().clone(); + let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); + let now = Instant::now(); + let mut queues = self.queues.lock(); + for p in plugins { + let wanted: Vec = + events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect(); + if wanted.is_empty() { + continue; + } + let queue = queues + .entry((p.id().to_owned(), client.map(str::to_owned))) + .or_insert_with(|| EventQueue { since: now, events: Vec::new() }); + queue.events.extend(wanted); + let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS); + queue.events.drain(..excess); + } + } + + /// Delivers queued events: to running plugins right away, to idle ones + /// (which must be activated) after [`IDLE_BATCH`], so frequent events + /// don't keep waking them. Call it a few times a second. + pub fn flush_events(&self) { + self.flush_events_at(Instant::now()); + } + + #[doc(hidden)] + pub fn flush_events_at(&self, now: Instant) { + let due: Vec<(QueueKey, Vec)> = { + let mut queues = self.queues.lock(); + let plugins = self.plugins.read(); + let keys: Vec<_> = queues + .iter() + .filter(|((id, _), q)| { + let running = + plugins.get(id).is_some_and(|p| p.runtime.lock().as_ref().is_some_and(|a| a.is_alive())); + running + || now.saturating_duration_since(q.since) >= IDLE_BATCH + || q.events.len() >= MAX_QUEUED_EVENTS / 2 + || q.events.contains(&WorkbenchEvent::Scheduled) + }) + .map(|(k, _)| k.clone()) + .collect(); + keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q.events))).collect() + }; + let mut changed = false; + for ((id, client), events) in due { + changed |= self.deliver(&id, client, events); + } + // Batches that finished since: contributions they changed, crashes. + let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); + for p in plugins { + changed |= std::mem::take(&mut p.batches.lock().changed); + let dead = p.runtime.lock().clone().filter(|a| !a.is_alive()); + if let Some(actor) = dead { + self.reap(&p, &actor, None); + } + } + if changed { + self.changed(); + } + } + + /// Waits (at most `timeout`) until every posted event batch was handled. + /// Returns whether they all were. + #[doc(hidden)] + pub fn wait_events(&self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + let busy = self.plugins.read().values().any(|p| p.batches.lock().pending > 0); + if !busy { + self.flush_events_at(Instant::now()); + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(5)); + } + } + + /// Hands one batch to the plugin's `event-handler.handle` without waiting + /// for it. A newer batch of the same kinds cooperatively cancels the one + /// being handled (a save while the previous save is still being checked). + /// Returns whether the plugin was activated. + fn deliver(&self, id: &str, client: Option, events: Vec) -> bool { + let Ok(plugin) = self.get(id) else { return false }; + if !plugin.meta.lock().pending.is_empty() { + return false; + } + let Ok((actor, activated)) = self.ensure_active(&plugin, client.as_deref()) else { return false }; + let invocation = Invocation { + workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)), + client: client.clone(), + editor: None, + }; + let kinds: BTreeSet = events.iter().map(WorkbenchEvent::kind).collect(); + { + let mut batches = plugin.batches.lock(); + if batches.running.as_ref() == Some(&kinds) && !kinds.contains(&EventKind::Scheduled) { + actor.cancel_soft(); + } + batches.pending += 1; + } + let started = Instant::now(); + let (start, finish) = (plugin.batches.clone(), plugin.batches.clone()); + let done_plugin = plugin.clone(); + actor.post( + move |rt| { + start.lock().running = Some(kinds); + rt.handle_events(events, invocation) + }, + move |result| { + let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_)))); + if !matches!(result, Err(ActorError::Stopped)) { + done_plugin.record_call(started.elapsed(), result.is_ok(), !crashed); + } + match &result { + Err(ActorError::Call(CallError::Guest(e))) => { + tracing::debug!(plugin = %done_plugin.id(), "events: {e}") + } + Err(ActorError::Call(CallError::Trap(e))) => { + tracing::warn!(plugin = %done_plugin.id(), "events: {e}") + } + _ => {} + } + let mut batches = finish.lock(); + batches.pending = batches.pending.saturating_sub(1); + batches.running = None; + batches.changed |= result.is_ok_and(|fx| fx.contributions_changed); + }, + ); + activated + } + + /// Selects a plugin theme (`None` restores the built-in theme). + pub fn set_theme(&self, selection: Option<(&str, &str)>) -> IdeResult> { + if let Some((plugin, theme)) = selection { + self.get(plugin)?; + if !self.ui.items(theme::POINT_ID, Some(plugin)).iter().any(|t| t.key == 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.announced_theme.lock() = active.clone(); + 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 { + 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 live. + pub fn active_theme(&self) -> Option { + let sel = self.state.lock().theme.clone()?; + let item = self.ui.items(theme::POINT_ID, Some(&sel.plugin)).into_iter().find(|t| t.key == sel.theme)?; + let theme: ThemeDef = serde_json::from_value(item.decl).ok()?; + Some(ActiveTheme { plugin_id: sel.plugin, theme }) + } + + /// Languages contributed by live plugins. + pub fn languages(&self) -> Vec { + self.languages.all() + } + + /// Plugin languages for a remote project's server, with the programs each + /// plugin was granted ([`remote::RemotePluginLanguages::sync`]). + pub fn remote_languages(&self) -> Vec { + let plugins = self.plugins.read(); + let ledger = self.ledger.lock(); + self.languages + .with_owners() + .into_iter() + .map(|(plugin, language)| { + let grant = plugins.get(&plugin).and_then(|p| ledger.get(&p.subject()).cloned()); + let granted = ide_language::plugin_point::required_capabilities(&language) + .into_iter() + .filter(|cap| grant.as_ref().is_some_and(|g| g.confirmed && g.has(cap))) + .filter_map(|cap| match cap { + Capability::Process(program) => Some(program), + _ => None, + }) + .collect(); + remote::RemoteLanguage { plugin, language, granted } + }) + .collect() + } + + /// The overlay of plugin languages (for the language registry). + pub fn language_store(&self) -> &Arc { + &self.languages + } + + /// Plugin packages found in `dirs` (first occurrence of an id wins). Invalid + /// manifests are skipped; packages that fail validation carry a `problem`. + pub fn catalog(&self, dirs: &[PathBuf]) -> Vec { + let mut out: Vec = Vec::new(); + for dir in dirs.iter().flat_map(|d| package_dirs(d)) { + let Ok(m) = Manifest::load(&dir) else { continue }; + if out.iter().any(|c| c.id == m.id) { + continue; + } + let pkg = Package::new(m.clone(), dir.clone()); + let problem = match self.registry.prepare(&pkg) { + Err(e) => Some(e), + Ok(_) => m + .wasm + .as_ref() + .filter(|w| !dir.join(w).is_file()) + .map(|w| format!("{} is missing; build the plugin first", w.display())), + }; + out.push(CatalogPlugin { + has_code: m.wasm.is_some(), + capabilities: m + .capabilities + .all() + .map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), false)) + .collect(), + contributions: counts(&m), + 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 + } + + /// Deactivates every plugin (also done on drop). + pub fn shutdown(&self) { + let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect(); + for a in &actors { + a.stop(false); + } + for a in actors { + a.stop(true); + } + } + + /// Periodic upkeep (every few seconds): reports crashes nobody waited + /// for, reclaims idle instances and fires due `on-schedule` activations + /// (which don't keep a plugin alive: it is reclaimed again when idle). + pub fn tick(&self) { + self.tick_at(Instant::now()); + } + + /// [`tick`](Self::tick) as if it were `now` (schedules only; tests). + #[doc(hidden)] + pub fn tick_at(&self, now: Instant) { + let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); + let mut changed = false; + for p in plugins { + let actor = p.runtime.lock().clone(); + if let Some(a) = &actor { + if !a.is_alive() { + self.reap(&p, a, None); + continue; + } + if self.limits.idle_timeout.is_some_and(|idle| !a.is_busy() && a.idle_for() >= idle) { + changed |= self.reclaim(&p, a); + } + } + let every = p.manifest().activation.iter().filter_map(|e| match e { + ActivationEvent::Schedule(secs) => Some(Duration::from_secs(u64::from(*secs).max(1))), + _ => None, + }); + let Some(every) = every.min() else { continue }; + let due = { + let mut meta = p.meta.lock(); + let next = *meta.next_schedule.get_or_insert(now + every); + if next > now { + false + } else { + meta.next_schedule = Some(now + every); + meta.status == PluginStatus::Inactive && meta.pending.is_empty() + } + }; + let restricted = p.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)); + if due && !restricted && self.startup_finished.load(Ordering::Acquire) { + // Activates it if needed and calls `handle([scheduled])`. + self.queues + .lock() + .entry((p.id().to_owned(), None)) + .or_insert_with(|| EventQueue { since: now, events: Vec::new() }) + .events + .push(WorkbenchEvent::Scheduled); + } + } + if changed { + self.changed(); + } + self.flush_events_at(now); + } + + /// Number of plugins with a live Wasm instance. + pub fn instances(&self) -> usize { + self.plugins.read().values().filter(|p| p.runtime.lock().as_ref().is_some_and(|a| a.is_alive())).count() + } + + // ---- internals ---- + + fn get(&self, id: &str) -> IdeResult> { + 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) + } + + /// Parses and validates a package directory; nothing takes effect. + fn validate_package(&self, src: &Path) -> IdeResult<(Package, ide_extension::PreparedSet)> { + let manifest = Manifest::load(src).map_err(IdeError::invalid)?; + let pkg = Package::new(manifest, src.to_path_buf()); + let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?; + if let Some(wasm) = &pkg.manifest.wasm { + if !src.join(wasm).is_file() { + return Err(IdeError::invalid(format!( + "{} is missing (referenced by plugin.toml); build the plugin first", + wasm.display() + ))); + } + // Precompiled on the way: the installed copy has the same bytes. + self.engine.compile_cached(&src.join(wasm), &self.component_cache()).map_err(IdeError::invalid)?; + } + Ok((pkg, set)) + } + + /// Loads one package and starts it unless disabled. Returns the plugin id. + fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option) -> String { + let (manifest, error) = match Manifest::load(dir) { + Ok(m) => (m, None), + Err(e) => (placeholder_manifest(dir), Some(e)), + }; + let invalid = error.is_some(); + let id = manifest.id.clone(); + let mut pkg = Package::new(manifest, dir.to_path_buf()); + if source == PluginSource::Installed + && let Ok(digest) = std::fs::read_to_string(dir.join(install::HASH_FILE)) + && !digest.trim().is_empty() + { + // Installed copies don't change: their validated UI declarations are indexed. + pkg = pkg.with_digest(digest.trim()); + } + let plugin = Arc::new(LoadedPlugin { + pkg, + invalid, + source, + dev_root, + meta: Mutex::new(Meta::new()), + batches: Arc::default(), + 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()); + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::Failed; + meta.error = Some(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); + } + + /// Validates, checks grants and commits the contributions; activates code + /// whose activation event already fired. + fn start(&self, plugin: &Arc) { + self.registry.dispose_plugin(plugin.id()); + if plugin.source == PluginSource::Installed + && let Some(reason) = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir()) + { + return self.set_failed(plugin, format!("revoked: {reason}")); + } + if plugin.source == PluginSource::Installed { + let publisher = self.verified_publisher(plugin.dir()); + plugin.meta.lock().publisher = publisher; + } + let set = match self.registry.prepare(&plugin.pkg) { + Ok(set) => set, + Err(e) => return self.set_failed(plugin, e), + }; + let hash = self.package_hash_of(plugin); + let request = request_for(&plugin.subject(), plugin.manifest(), &hash, &set.capabilities); + let decision = { + let mut ledger = self.ledger.lock(); + let d = ledger.decide(&request); + self.save_ledger(&ledger); + d + }; + { + let mut meta = plugin.meta.lock(); + meta.warnings = set.warnings.clone(); + meta.declared_extra = set.capabilities.clone(); + meta.error = None; + } + let (granted, pending) = match decision { + Decision::Allowed { granted, pending } => (granted, pending), + Decision::NeedsConfirmation { new, .. } => { + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::NeedsConsent; + meta.pending = new; + meta.granted.clear(); + return; + } + }; + if let Err(e) = self.registry.commit(set, Scope::Static) { + return self.set_failed(plugin, e); + } + { + let mut meta = plugin.meta.lock(); + meta.granted = granted; + meta.pending = pending; + meta.status = if plugin.manifest().wasm.is_some() { PluginStatus::Inactive } else { PluginStatus::Static }; + } + let eager = plugin.manifest().activation.contains(&ActivationEvent::StartupFinished) + && self.startup_finished.load(Ordering::Acquire); + if eager { + let _ = self.ensure_active(plugin, None); + } + } + + /// The plugin's running instance, instantiating and activating its code + /// first if needed. Returns whether it was activated now. + fn ensure_active(&self, plugin: &Arc, client: Option<&str>) -> IdeResult<(Arc, bool)> { + let mut runtime = plugin.runtime.lock(); + if let Some(actor) = runtime.as_ref() { + if actor.is_alive() { + return Ok((actor.clone(), false)); + } + // Crashed while nobody was waiting for it. + let actor = actor.clone(); + drop(runtime); + self.reap(plugin, &actor, client); + runtime = plugin.runtime.lock(); + if let Some(actor) = runtime.as_ref().filter(|a| a.is_alive()) { + return Ok((actor.clone(), false)); + } + } + if plugin.status() != PluginStatus::Inactive { + return Err(IdeError::unavailable(format!("plugin {:?} cannot be activated", plugin.id()))); + } + if let Some(wait) = plugin.meta.lock().retry_at.map(|at| at.saturating_duration_since(Instant::now())) + && !wait.is_zero() + { + return Err(IdeError::unavailable(format!( + "plugin {:?} crashed and restarts in {:.1}s", + plugin.id(), + wait.as_secs_f64() + ))); + } + let Some(wasm) = plugin.manifest().wasm.clone() else { + return Err(IdeError::unavailable(format!("plugin {:?} has no code", plugin.id()))); + }; + let started = Instant::now(); + let granted = plugin.meta.lock().granted.clone(); + let api: Arc = Arc::new(api::PluginApi { pkg: plugin.pkg.clone(), shared: self.shared.clone() }); + let setup = RuntimeSetup { + plugin_id: plugin.id().to_owned(), + version: plugin.manifest().version.clone(), + granted, + host: api, + limits: self.limits, + }; + let instance = self + .engine + .compile_cached(&plugin.dir().join(wasm), &self.component_cache()) + .and_then(|component| Runtime::instantiate(&self.engine, &component, setup)); + let mut rt = match instance { + Ok(rt) => rt, + Err(e) => { + drop(runtime); + if let Some(message) = self.roll_back_failed_update(plugin, &e, client) { + return Err(IdeError::unavailable(message)); + } + self.fail(plugin, &e, client); + return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id()))); + } + }; + let crash = match rt.activate() { + Ok(_) => None, + Err(CallError::Trap(e)) => Some(format!("activation {e}")), + Err(CallError::Guest(e)) => { + drop(runtime); + let e = format!("activation failed: {e}"); + if let Some(message) = self.roll_back_failed_update(plugin, &e, client) { + return Err(IdeError::unavailable(message)); + } + self.fail(plugin, &e, client); + return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id()))); + } + }; + if let Some(e) = crash { + drop(runtime); + if let Some(message) = self.roll_back_failed_update(plugin, &e, client) { + return Err(IdeError::unavailable(message)); + } + self.crashed(plugin, &e, client); + return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id()))); + } + // An update's first start: confirmed. + self.updates.lock().remove(plugin.id()); + let has_syntax = rt.has_syntax(); + let actor = match Actor::spawn(plugin.id(), rt) { + Ok(a) => Arc::new(a), + Err(e) => return Err(IdeError::internal(format!("plugin {:?}: {e}", plugin.id()))), + }; + *runtime = Some(actor.clone()); + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::Active; + meta.has_syntax = has_syntax; + meta.stats.activations += 1; + meta.stats.activation_ms = started.elapsed().as_secs_f64() * 1000.0; + Ok((actor, true)) + } + + fn record_call(&self, plugin: &LoadedPlugin, elapsed: Duration, ok: bool, healthy: bool) { + plugin.record_call(elapsed, ok, healthy); + } + + /// Clears `actor` from the plugin if it ended, reporting its crash once. + fn reap(&self, plugin: &LoadedPlugin, actor: &Arc, client: Option<&str>) { + if actor.is_alive() { + return; + } + { + let mut runtime = plugin.runtime.lock(); + if runtime.as_ref().is_some_and(|r| Arc::ptr_eq(r, actor)) { + *runtime = None; + } + } + let Some(error) = actor.take_crash() else { return }; + if error.starts_with(CANCELLED) { + // Cancelled on request: restart on next use, no backoff. + let mut meta = plugin.meta.lock(); + if meta.status == PluginStatus::Active { + meta.status = PluginStatus::Inactive; + } + drop(meta); + self.changed(); + return; + } + self.crashed(plugin, &error, client); + } + + /// A trap dropped the plugin's instance: restart it on next use after a + /// backoff, or stop it for the session after too many crashes in a row. + fn crashed(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { + let failures = { + let mut meta = plugin.meta.lock(); + meta.failures += 1; + meta.stats.crashes += 1; + meta.failures + }; + if failures >= self.limits.max_failures.max(1) { + let error = + if failures > 1 { format!("{error} (crashed {failures} times in a row)") } else { error.to_owned() }; + return self.fail(plugin, &error, client); + } + let delay = self.limits.backoff(failures); + tracing::warn!(plugin = %plugin.id(), "crashed ({failures}), restarting in {delay:?}: {error}"); + { + let mut meta = plugin.meta.lock(); + if meta.status == PluginStatus::Active { + meta.status = PluginStatus::Inactive; + } + meta.error = Some(error.to_owned()); + meta.retry_at = Some(Instant::now() + delay); + } + if failures == 1 { + self.emit( + client, + PluginEvent::Notification { + plugin_id: plugin.id().to_owned(), + level: NotificationLevel::Warning, + title: format!("Plugin \u{201c}{}\u{201d} crashed", plugin.manifest().name), + body: format!("{error}\nIt restarts the next time it is needed."), + }, + ); + } + self.changed(); + } + + /// Ends an idle instance; the plugin activates again on its next event. + fn reclaim(&self, plugin: &LoadedPlugin, actor: &Arc) -> bool { + { + let mut runtime = plugin.runtime.lock(); + if !runtime.as_ref().is_some_and(|r| Arc::ptr_eq(r, actor)) || actor.is_busy() { + return false; + } + *runtime = None; + } + actor.stop(false); + let mut meta = plugin.meta.lock(); + if meta.status == PluginStatus::Active { + meta.status = PluginStatus::Inactive; + } + meta.stats.reclaims += 1; + tracing::debug!(plugin = %plugin.id(), "reclaimed after {:?} idle", actor.idle_for()); + true + } + + fn stop(&self, plugin: &LoadedPlugin) { + if let Some(actor) = plugin.runtime.lock().take() { + actor.stop(true); + } + self.registry.dispose_plugin(plugin.id()); + self.shared.clear_plugin(plugin.id(), true); + self.queues.lock().retain(|(id, _), _| id != plugin.id()); + } + + fn set_failed(&self, plugin: &LoadedPlugin, error: String) { + tracing::warn!(plugin = %plugin.id(), "{error}"); + self.registry.dispose_plugin(plugin.id()); + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::Failed; + meta.error = Some(error); + } + + fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { + tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}"); + if let Some(actor) = plugin.runtime.lock().take() { + actor.stop(false); + } + self.shared.clear_plugin(plugin.id(), true); + self.set_failed(plugin, error.to_owned()); + self.emit( + client, + PluginEvent::Notification { + plugin_id: plugin.id().to_owned(), + level: NotificationLevel::Error, + title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest().name), + body: error.to_owned(), + }, + ); + self.changed(); + } + + /// Contributions or statuses changed: tell the UI and re-announce the theme if it changed. + fn changed(&self) { + // Held by this thread when called from a grant change, which refreshed them already. + if let Some(ledger) = self.ledger.try_lock() { + self.refresh_pins(&ledger); + } + self.emit(None, PluginEvent::CommandsChanged); + let languages = self.languages.revision(); + if self.announced_languages.swap(languages, Ordering::AcqRel) != languages { + self.emit(None, PluginEvent::LanguagesChanged); + } + let active = self.active_theme(); + let mut announced = self.announced_theme.lock(); + if *announced != active { + *announced = active.clone(); + drop(announced); + self.emit(None, PluginEvent::ThemeChanged { theme: active }); + } + } + + 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 save_ledger(&self, ledger: &Ledger) { + if let Err(e) = ledger.save() { + tracing::warn!("saving plugin grants: {e}"); + } + self.refresh_pins(ledger); + // Grants of programs that exist only on a remote host change no local pin, + // but what a remote server may start (`LanguagesChanged` re-syncs it). + if !self.languages.with_owners().is_empty() { + self.languages.invalidate(); + } + } + + /// Recomputes the approved programs of every plugin; plugin languages + /// pick up a change on their next lookup. + fn refresh_pins(&self, ledger: &Ledger) { + let mut pins = HashMap::new(); + for plugin in self.plugins.read().values() { + let subject = plugin.subject(); + let declared = + plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone()); + for cap in declared.filter(|c| matches!(c, Capability::Process(_))) { + if let Some(bin) = ledger.pinned_binary(&subject, &cap) { + pins.insert( + (plugin.id().to_owned(), cap.to_string()), + ProgramPin { path: bin.path, hash: bin.hash }, + ); + } + } + } + let mut current = self.pins.write(); + if *current != pins { + *current = pins; + drop(current); + self.languages.invalidate(); + } + } + + fn links(&self) -> Links { + std::fs::read_to_string(self.plugins_dir.join(LINKS_FILE)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + fn save_links(&self, links: &Links) -> IdeResult<()> { + let json = serde_json::to_string_pretty(links).map_err(|e| IdeError::internal(e.to_string()))?; + std::fs::write(self.plugins_dir.join(LINKS_FILE), json)?; + Ok(()) + } + + /// The installed package's recorded hash; development plugins are granted by path. + fn package_hash_of(&self, plugin: &LoadedPlugin) -> String { + if plugin.source != PluginSource::Installed { + return String::new(); + } + let file = plugin.dir().join(install::HASH_FILE); + if let Ok(h) = std::fs::read_to_string(&file) { + return h.trim().to_owned(); + } + // Installed before hashes were recorded: hash once and remember. + let files = self.registry.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default(); + let files: Vec = files.into_iter().chain(plugin.manifest().wasm.clone()).collect(); + let hash = ledger::package_hash(plugin.dir(), &files).unwrap_or_default(); + let _ = std::fs::write(&file, &hash); + hash + } + + /// One-time migration from 0.1, where declaring a permission granted it. + fn migrate_grants(&self, installed: &[PathBuf]) { + let mut ledger = self.ledger.lock(); + if !ledger.needs_migration() { + return; + } + let manifests: Vec = installed.iter().filter_map(|d| Manifest::load(d).ok()).collect(); + let requests: Vec> = + manifests.iter().map(|m| request_for(&Subject::Installed(m.id.clone()), m, "", &[])).collect(); + ledger.migrate(&requests); + self.save_ledger(&ledger); + } + + fn record_consent(&self, request: &Request<'_>, consent: &Consent) { + let accepted: Vec = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect(); + let binaries: BTreeMap = request + .required + .iter() + .chain(&accepted) + .filter_map(|c| match c { + Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)), + _ => None, + }) + .collect(); + let mut ledger = self.ledger.lock(); + ledger.confirm(request, &accepted, binaries); + self.save_ledger(&ledger); + } + + fn resolve_binary(&self, program: &str) -> Option { + let path_var = (self.path_var.read())(); + let path = ledger::resolve_program(program, path_var.as_deref()) + .map_err(|e| tracing::info!("process:{program}: {e}")) + .ok()?; + let hash = ledger::file_hash(&path).ok()?; + Some(BinaryGrant { path, hash }) + } + + fn info(&self, p: &LoadedPlugin) -> PluginInfo { + let m = p.manifest(); + let meta = p.meta.lock(); + let grant = self.ledger.lock().get(&p.subject()).cloned(); + let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.confirmed && g.has(c)); + let mut capabilities: Vec = + m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect(); + for extra in &meta.declared_extra { + if !m.declares(extra) { + capabilities.push(CapabilityInfo::new(extra, true, granted(extra))); + } + } + 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().to_path_buf().into(), + enabled: !self.state.lock().disabled.contains(&m.id), + status: meta.status, + error: meta.error.clone(), + has_code: m.wasm.is_some(), + capabilities, + contributions: self.registry.contributions(&m.id), + activation: m.activation.iter().map(ToString::to_string).collect(), + warnings: meta.warnings.clone(), + stats: PluginStats { p95_call_ms: meta.p95(), ..meta.stats }, + publisher: meta.publisher.clone(), + previous_version: (p.source == PluginSource::Installed) + .then(|| install::previous_version(&self.plugins_dir.join(&m.id))) + .flatten(), + } + } +} + +impl Drop for PluginHost { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn request_for<'a>(subject: &Subject, m: &'a Manifest, hash: &'a str, declared: &[Capability]) -> Request<'a> { + let mut optional = m.capabilities.optional.clone(); + // What declarations need (a language server's `process:`) is optional: + // refusing it keeps the rest of the plugin working. + for cap in declared { + if !m.declares(cap) && !optional.contains(cap) { + optional.push(cap.clone()); + } + } + Request { + subject: subject.clone(), + plugin_id: &m.id, + version: &m.version, + package_hash: hash, + required: m.capabilities.required.clone(), + optional, + } +} + +fn counts(m: &Manifest) -> Vec { + m.declaration_counts().into_iter().map(|(point, count)| ContributionCount { point, count }).collect() +} + +fn activation_matches(declared: &ActivationEvent, fired: &ActivationEvent) -> bool { + match (declared, fired) { + (ActivationEvent::Language(a), ActivationEvent::Language(b)) => a == b || a == "*", + (ActivationEvent::View(a), ActivationEvent::View(b)) => a == b, + (ActivationEvent::File(glob), ActivationEvent::File(path)) => glob_match(glob, path), + _ => false, + } +} + +/// `**` matches across `/`, `*` and `?` within one segment. +fn glob_match(glob: &str, path: &str) -> bool { + fn go(p: &[u8], s: &[u8]) -> bool { + match p.first() { + None => s.is_empty(), + Some(b'*') if p.get(1) == Some(&b'*') => { + let rest = p[2..].strip_prefix(b"/").unwrap_or(&p[2..]); + if rest.is_empty() { + return true; + } + (0..=s.len()).any(|i| (i == 0 || s[i - 1] == b'/') && go(rest, &s[i..])) || go(rest, s) + } + Some(b'*') => (0..=s.len()).take_while(|&i| i == 0 || s[i - 1] != b'/').any(|i| go(&p[1..], &s[i..])), + Some(b'?') => s.first().is_some_and(|c| *c != b'/') && go(&p[1..], &s[1..]), + Some(c) => s.first() == Some(c) && go(&p[1..], &s[1..]), + } + } + go(glob.as_bytes(), path.as_bytes()) +} + +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())) +} + +/// Subdirectories of `dir` that contain a `plugin.toml`, sorted. +/// The active version of every installed plugin (`/`, or `` +/// for installs from before versioning). +fn package_dirs(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut dirs: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))) + .filter_map(|p| install::active_dir(&p)) + .collect(); + dirs.sort(); + dirs +} + +/// Stand-in for a package whose manifest failed to parse, so it still shows up (as failed). +fn placeholder_manifest(dir: &Path) -> Manifest { + let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Manifest { + 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, + license: None, + change_notes: None, + engine: ide_extension::ENGINE_VERSION.into(), + legacy: false, + wasm: None, + activation: Vec::new(), + capabilities: Default::default(), + contributes: Default::default(), + } +} + +/// [`PluginHost::syntax_provider`]: plugin languages' `syntax-provider` exports. +struct PluginSyntax(std::sync::Weak); + +type SyntaxFn = fn(&mut Runtime, &str, &str, Duration) -> Result; + +/// A syntax call with its own copies of the arguments, to run on the plugin's thread. +fn owned( + lang: &LanguageId, + text: &str, + f: SyntaxFn, +) -> impl FnOnce(&mut Runtime, Duration) -> Result + Send + 'static { + let (lang, text) = (lang.as_str().to_owned(), text.to_owned()); + move |rt, timeout| f(rt, &lang, &text, timeout) +} + +impl ide_language::ExternalSyntax for PluginSyntax { + fn highlights(&self, lang: &LanguageId, text: &str) -> Option> { + let tokens = self.0.upgrade()?.syntax_call(lang, owned(lang, text, Runtime::syntax_highlights))?; + Some( + tokens + .into_iter() + .map(|t| ide_language::ExternalToken { start: t.start, end: t.end, kind: t.kind }) + .collect(), + ) + } + + fn outline(&self, lang: &LanguageId, text: &str) -> Option> { + let symbols = self.0.upgrade()?.syntax_call(lang, owned(lang, text, Runtime::syntax_outline))?; + Some( + symbols + .into_iter() + .map(|s| ide_language::ExternalSymbol { + name: s.name, + detail: s.detail, + kind: s.kind, + start: s.start, + end: s.end, + name_start: s.name_start, + name_end: s.name_end, + parent: s.parent, + }) + .collect(), + ) + } + + fn folding(&self, lang: &LanguageId, text: &str) -> Option> { + let folds = self.0.upgrade()?.syntax_call(lang, owned(lang, text, Runtime::syntax_folding))?; + Some( + folds + .into_iter() + .map(|f| ide_language::ExternalFold { start_line: f.start_line, end_line: f.end_line, kind: f.kind }) + .collect(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn globs() { + assert!(glob_match("**/*Mapper.xml", "src/main/UserMapper.xml")); + assert!(glob_match("**/*Mapper.xml", "UserMapper.xml")); + assert!(!glob_match("*.xml", "a/b.xml")); + assert!(glob_match("pom.xml", "pom.xml")); + assert!(glob_match("src/**", "src/a/b")); + assert!(glob_match("a?c", "abc") && !glob_match("a?c", "a/c")); + } +} diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs new file mode 100644 index 000000000..f6f32f9e3 --- /dev/null +++ b/crates/ide-host/src/plugins/points.rs @@ -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..` 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, + /// Enablement, evaluated by the client against context keys (`editorLang == rust`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub when: Option, +} + +fn command(_pkg: &Package, decl: toml::Value) -> Result { + 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, + languages: &Arc, + run_detectors: &Arc, + providers: &Arc, +) -> 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(()) +} diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs new file mode 100644 index 000000000..aa457b54c --- /dev/null +++ b/crates/ide-host/src/plugins/providers.rs @@ -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, + languages: Vec, +} + +/// (sequence, plugin, kinds, languages) of one declaration. +type Declared = (u64, String, Vec, Vec); + +/// Provider declarations of enabled plugins. +#[derive(Default)] +pub struct Providers { + items: RwLock>, + seq: AtomicU64, +} + +impl Providers { + fn add(self: &Arc, 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 { + let mut out: Vec = 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, +} + +impl ProvidersPoint { + pub fn new(store: Arc) -> 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 { + 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::>() + .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, +} + +/// `[[contributes.secrets]]` (a UI point: Settings › Plugins lists them). +pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result { + 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>> = 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>) { + *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> { + 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> { + 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( + &self, + plugin: &Arc, + f: impl FnOnce(&mut Runtime, Duration) -> Result + Send + 'static, + ) -> Option { + 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( + &self, + plugin: &Arc, + actor: &Arc, + started: Instant, + result: Result, + ) -> Option { + 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 { + 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 + } +} diff --git a/crates/ide-host/src/plugins/remote.rs b/crates/ide-host/src/plugins/remote.rs new file mode 100644 index 000000000..b1d8ffa21 --- /dev/null +++ b/crates/ide-host/src/plugins/remote.rs @@ -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, +} + +/// 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, + /// Granted programs not found on this machine's `PATH`. + pub unresolved: Vec, + /// Definitions that failed validation (not applied). + pub rejected: Vec, +} + +#[derive(Default, Serialize, Deserialize)] +struct PinsFile { + /// `plugin\nprocess:program` → the approved file. + #[serde(default)] + pins: BTreeMap, +} + +type PathVar = Arc Option + Send + Sync>; + +/// The server's side: an overlay for its language registry, filled by syncs. +pub struct RemotePluginLanguages { + store: Arc, + pins: Arc>>, + file: Option, + applied: Mutex>, + 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::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 { + 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 { + &self.store + } + + /// Replaces every plugin language with `languages`. + pub fn sync(&self, languages: Vec) -> SyncReport { + let mut report = SyncReport::default(); + let mut wanted: BTreeMap = 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) { + 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()); + } +} diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs new file mode 100644 index 000000000..86986e57a --- /dev/null +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -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 `/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`). +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, +} + +/// 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, + pub installed: Vec, + pub removed: Vec, + /// `id: reason`. + pub failed: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PluginEventMessage { + /// The window that invoked the plugin (`None`: every window of the connection). + pub window: Option, + 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 { + 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> { + 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 { + 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> { + 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 { + 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, + incoming: PathBuf, + sync: Mutex<()>, +} + +/// Secrets from the desktop's keychain, asked for when used. +struct ClientSecrets { + peer: Arc>, + runtime: tokio::runtime::Handle, +} + +impl SecretStore for ClientSecrets { + fn get(&self, plugin: &str, name: &str) -> Option { + 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>(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 `/remote-plugins`, loads what earlier + /// sessions installed and starts event delivery and supervision. + pub fn start(data_dir: &Path, projects: &Arc, peer: Arc>) -> IdeResult> { + 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 { + &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, + mut files: BTreeMap>, + ) -> 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 = 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 { + 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, 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, d: &mut Dispatcher) { + let this = self.clone(); + d.add(SYNC_PACKAGES, move |c| { + let this = this.clone(); + async move { + let packages: Vec = c.arg("packages")?; + let files: Option>> = 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 = c.arg("arg")?; + let editor: Option = 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::>()) } + }); + 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::>()) + } + }); + let h = host.clone(); + d.add("plugin_cancel_task", move |c| { + let h = h.clone(); + async move { Ok(alive(&h)?.cancel_task(&c.arg::("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) -> IdeResult> { + host.upgrade().ok_or_else(|| IdeError::unavailable("the plugin host has shut down")) +} + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} + +fn spawn_supervision(host: Weak) -> 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 { + 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("")); + } +} diff --git a/crates/ide-host/src/plugins/run_detectors.rs b/crates/ide-host/src/plugins/run_detectors.rs new file mode 100644 index 000000000..0fdb92c91 --- /dev/null +++ b/crates/ide-host/src/plugins/run_detectors.rs @@ -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, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub test: bool, +} + +/// Detectors of enabled plugins. +#[derive(Default)] +pub struct RunDetectors { + items: RwLock>, + seq: AtomicU64, +} + +impl RunDetectors { + fn add(self: &Arc, 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, +} + +impl RunDetectorsPoint { + pub fn new(store: Arc) -> 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 { + 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>>; + +/// [`ExternalDetection`] over the detectors and the approved programs. +pub struct PluginRunDetection { + pub(crate) store: Arc, + 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 { + 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 { + 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 { + 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) + } +} diff --git a/crates/ide-plugin/src/theme.rs b/crates/ide-host/src/plugins/theme.rs similarity index 73% rename from crates/ide-plugin/src/theme.rs rename to crates/ide-host/src/plugins/theme.rs index 0d4e65e7a..2de8eabdb 100644 --- a/crates/ide-plugin/src/theme.rs +++ b/crates/ide-host/src/plugins/theme.rs @@ -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, +} + +impl ThemesPoint { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct Decl { + path: Option, + #[serde(default)] + editor_scheme: Option, + /// The theme JSON inline (runtime `register-theme`). + definition: Option, +} + +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 { + 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 { diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs new file mode 100644 index 000000000..c9cac4309 --- /dev/null +++ b/crates/ide-host/src/plugins/types.rs @@ -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 `/plugins//`. + Installed, + /// Loaded from `/.openidea/plugins//` 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, + pub homepage: Option, + pub change_notes: Option, + pub source: PluginSource, + /// Package directory. + pub path: ide_core::IpcPath, + pub enabled: bool, + pub status: PluginStatus, + pub error: Option, + pub has_code: bool, + /// Declared capabilities with risk and grant state. + pub capabilities: Vec, + /// What it contributes while live (point + label). + pub contributions: Vec, + /// `activation` events of the manifest. + pub activation: Vec, + /// Non-fatal problems (unsupported contribution points). + pub warnings: Vec, + /// Statistics of this session. + pub stats: PluginStats, + /// The marketplace publisher whose signature the package carries (none: unsigned or untrusted). + pub publisher: Option, + /// The version an update replaced, to roll back to. + pub previous_version: Option, +} + +#[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, + pub homepage: Option, + pub change_notes: Option, + /// Package directory; install with `install_from_dir(path)`. + pub path: ide_core::IpcPath, + pub has_code: bool, + pub capabilities: Vec, + pub contributions: Vec, + /// Why the package cannot be installed as is (e.g. its WASM is not built). + pub problem: Option, +} + +#[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, + pub active_theme: Option, + /// UI contributions of live plugins, projected by the client. + pub ui: Vec, +} + +/// 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 }, + /// 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, message: Option }, + #[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 }, + /// 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 }, + /// 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, + 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, 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, +} + +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, + pub tooltip: String, + /// Opened on click. + pub target: Option, +} diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs new file mode 100644 index 000000000..538e402e4 --- /dev/null +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -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 { + 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(v: &T) -> Result { + 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, +} + +pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result { + 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, +} + +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::().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 { + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option, + #[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 { + 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, + schema: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct SchemaFile { + properties: serde_json::Map, +} + +/// 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, + pub default: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option>, + #[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 { + 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::, _>>()?, + ), + Some(_) => return Err(format!("setting {key:?}: `enum` is 1–100 strings of a string setting")), + }; + let text = |name: &str, max: usize| -> Result, 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, 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 { + 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, + #[serde(default = "right")] + location: String, +} + +pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { + 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, + #[serde(default = "default_priority")] + priority: String, +} + +fn default_priority() -> String { + "default".into() +} + +pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result { + 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()); + } +} diff --git a/crates/ide-host/src/plugins/views.rs b/crates/ide-host/src/plugins/views.rs new file mode 100644 index 000000000..e096ddb30 --- /dev/null +++ b/crates/ide-host/src/plugins/views.rs @@ -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) -> IdeResult { + 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(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + 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, + window: &str, + plugin_id: String, + view: String, + viewport: Option, + doc: Option, +) -> IdeResult { + 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, + window: &str, + plugin_id: String, + view: String, + event: ViewEvent, + doc: Option, +) -> IdeResult<()> { + let invocation = view_invocation(host, window, doc)?; + blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await +} diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 2ab5bd7f6..38795f7d0 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -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}; diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index d0e0731dd..ea9b96042 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -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" diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs new file mode 100644 index 000000000..f7a1ac64a --- /dev/null +++ b/crates/ide-host/tests/distribution.rs @@ -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>>; + +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, + revoked: Vec, + 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)); +} diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs new file mode 100644 index 000000000..80611eb51 --- /dev/null +++ b/crates/ide-host/tests/plugins.rs @@ -0,0 +1,1867 @@ +//! End-to-end tests of the plugin host against real components. +//! +//! The components are checked in (`plugins/hello/dist/plugin.wasm`, +//! `crates/ide-plugin/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/`. + +use ide_host::plugins::*; +use ide_plugin::{EditorContext, HostError, Invocation, Limits}; +use parking_lot::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +type Events = Arc, PluginEvent)>>>; + +/// What plugins see of the test project: real `Documents`, as in the IDE. +struct TestWorkspace { + root: PathBuf, + documents: Arc, + trusted: bool, + /// (producer, path) → published diagnostics. + diagnostics: Mutex>>, + /// plugin → its settings as the user set them. + settings: Mutex>>, +} + +impl TestWorkspace { + fn new(root: &Path) -> Arc { + let documents = ide_workspace::Documents::new( + Arc::new(ide_core::NullSink), + Box::new(|_: &Path, _: Option<&str>| ide_core::LanguageId::new("text")), + ); + Arc::new(Self { + root: root.to_path_buf(), + documents: Arc::new(documents), + trusted: true, + diagnostics: Mutex::default(), + settings: Mutex::default(), + }) + } + + /// Opens `rel` in an "editor": the invocation's focused editor. + fn open(&self, rel: &str, selections: &[(u32, u32, u32, u32)]) -> EditorContext { + let path = self.root.join(rel); + self.documents.open(&path).unwrap(); + let selections = selections + .iter() + .map(|&(a, b, c, d)| ide_core::Range::new(ide_core::Pos::new(a, b), ide_core::Pos::new(c, d))) + .collect(); + EditorContext { path, language: "text".into(), selections } + } + + fn text(&self, rel: &str) -> String { + self.documents.text_of(&self.root.join(rel)).unwrap() + } + + /// The user types at the start of the document. + fn type_text(&self, rel: &str, text: &str) { + let doc = self.documents.by_path(&self.root.join(rel)).unwrap(); + let edit = ide_core::TextEdit { range: ide_core::Range::default(), new_text: text.into() }; + self.documents.apply_edits(doc, &[edit]).unwrap(); + } +} + +impl PluginWorkspace for TestWorkspace { + fn projects(&self) -> Vec { + vec![self.root.clone()] + } + fn buffer_text(&self, path: &Path) -> Option { + self.documents.text_of(path) + } + fn document(&self, path: &Path) -> Option<(u64, String)> { + let snap = self.documents.snapshot(self.documents.by_path(path)?).ok()?; + Some((u64::from(snap.version), snap.text)) + } + fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result { + let doc = self.documents.by_path(path).ok_or_else(|| HostError::NotFound("not open".into()))?; + match self.documents.apply_edits_at(doc, version as u32, edits) { + Ok(Some(v)) => Ok(u64::from(v)), + Ok(None) => Err(HostError::Stale), + Err(e) => Err(HostError::Failed(e.message)), + } + } + fn trusted(&self, _root: &Path) -> bool { + self.trusted + } + fn project_of(&self, _window: &str) -> Option { + Some(self.root.clone()) + } + fn publish_diagnostics( + &self, + source: &str, + path: &Path, + items: Vec, + ) -> Result<(), HostError> { + if !path.starts_with(&self.root) { + return Err(HostError::Invalid("not in the project".into())); + } + self.diagnostics.lock().insert((source.to_owned(), path.to_path_buf()), items); + Ok(()) + } + fn plugin_settings(&self, plugin: &str, _root: Option<&Path>) -> serde_json::Map { + self.settings.lock().get(plugin).cloned().unwrap_or_default() + } +} + +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") +} + +fn misbehave_pkg() -> PathBuf { + repo().join("crates/ide-plugin/tests/fixtures/misbehave") +} + +/// 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\"\nengines.openidea = \"^1.0\"\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 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 `hello` whose manifest is rewritten by `f`. +fn hello_variant(dir: &Path, f: impl Fn(&str) -> String) -> PathBuf { + let pkg = dir.join("hello"); + copy_dir(&sample("hello"), &pkg); + let manifest = pkg.join("plugin.toml"); + let text = std::fs::read_to_string(&manifest).unwrap(); + std::fs::write(&manifest, f(&text)).unwrap(); + pkg +} + +/// `hello` requiring `required`, activated by `insert-date` only. +fn hello_02(text: &str, required: &str) -> String { + text.replace("activation = [\"on-command:*\", \"on-view:greetings\"]", "activation = [\"on-command:insert-date\"]") + .replace("required = [\"editor.read\", \"editor.write\"]", &format!("required = [{required}]")) +} + +/// `hello` with a 0.1 manifest (`openidea-api`, `permissions`, no activation). +fn hello_01(text: &str) -> String { + text.replace("engines.openidea = \"^1.0\"", "openidea-api = \"0.1\"") + .replace("activation = [\"on-command:*\", \"on-view:greetings\"]\n", "") + .replace( + "[capabilities]\nrequired = [\"editor.read\", \"editor.write\"]", + "permissions = [\"project-read\", \"editor-read\", \"editor-write\"]", + ) +} + +struct Fixture { + host: PluginHost, + workspace: Arc, + 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(); + let workspace = TestWorkspace::new(&project); + host.set_workspace(Some(workspace.clone())); + host.load_all(); + host.startup_finished(); + Fixture { host, workspace, events, _tmp: tmp, plugins_dir, project } +} + +impl Fixture { + fn inv(&self) -> Invocation { + Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), editor: None } + } + + fn install(&self, dir: &Path) -> PluginInfo { + self.host.install_from_dir(dir, &Consent::all()).unwrap() + } + + /// The installed copy of a plugin (its active version's directory). + fn installed(&self, id: &str) -> PathBuf { + self.host.info_of(id).unwrap().path.0 + } + + fn run(&self, plugin: &str, cmd: &str, arg: Option<&str>) -> Result, String> { + self.host.run_command(plugin, cmd, arg, self.inv()).map_err(|e| e.message) + } + + /// Runs a command with `editor` focused. + fn run_in(&self, editor: &EditorContext, plugin: &str, cmd: &str) -> Result, String> { + let inv = Invocation { editor: Some(editor.clone()), ..self.inv() }; + self.host.run_command(plugin, cmd, None, 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, PluginEvent)> { + std::mem::take(&mut *self.events.lock()) + } + + /// Titles of the `commands` UI contributions of `plugin`. + fn command_titles(&self, plugin: &str) -> Vec { + self.host + .ui_items(COMMANDS) + .into_iter() + .filter(|c| c.plugin_id == plugin) + .map(|c| c.decl["title"].as_str().unwrap().to_owned()) + .collect() + } + + fn theme_kinds(&self, plugin: &str) -> Vec<(String, ThemeKind)> { + self.host + .ui_items("themes") + .into_iter() + .filter(|c| c.plugin_id == plugin) + .map(|c| { + let t: ThemeDef = serde_json::from_value(c.decl).unwrap(); + (t.id, t.kind) + }) + .collect() + } +} + +#[test] +fn hello_plugin_commands() { + let f = fixture(Limits::default()); + let info = f.install(&sample("hello")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + assert!(f.installed("hello").join("plugin.toml").is_file()); + assert!(f.installed("hello").join("dist/plugin.wasm").is_file()); + assert_eq!(f.command_titles("hello"), ["Hello: Insert Date", "Hello: Word Count of Active File"]); + + // Insert Date: one versioned edit replacing every selection (one undo step). + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 0), (1, 0, 1, 4)]); + let date = f.run_in(&editor, "hello", "insert-date").unwrap().unwrap(); + assert!(date.len() == 10 && date.as_bytes()[4] == b'-' && date.starts_with("20"), "{date}"); + assert_eq!(f.workspace.text("src/notes.txt"), format!("{date}one two three\n{date} five\n")); + assert_eq!(f.status("hello"), PluginStatus::Active, "activated by its command"); + + // Word Count reads the document, unsaved changes included. + let summary = f.run_in(&editor, "hello", "word-count").unwrap().unwrap(); + assert!(summary.starts_with("5 words, 2 lines"), "{summary}"); + assert!(f.take_events().iter().any(|(client, e)| client.as_deref() == Some("main") + && matches!(e, + PluginEvent::Notification { title, level: NotificationLevel::Info, .. } if title == "Word count: notes.txt"))); + + // Without an editor it fails cleanly; guest errors don't disable the plugin. + assert!(f.run("hello", "word-count", None).unwrap_err().contains("no file is open")); + assert!(f.run_in(&editor, "hello", "nope").unwrap_err().contains("unknown command")); + assert_eq!(f.status("hello"), PluginStatus::Active); + let stats = f.host.info_of("hello").unwrap().stats; + assert_eq!(stats.activations, 1); + assert!(stats.calls >= 4 && stats.errors >= 2, "{stats:?}"); +} + +/// Plan P5: edits name the document version they were computed for. +#[test] +fn edits_are_refused_when_the_document_changed() { + use ide_plugin::{HostApi, Runtime, RuntimeSetup}; + let f = fixture(Limits::default()); + f.install(&sample("hello")); + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 3)]); + let doc = f.workspace.document(&editor.path).unwrap(); + f.workspace.type_text("src/notes.txt", "typed "); + let edit = ide_core::TextEdit { + range: ide_core::Range::new(ide_core::Pos::new(0, 0), ide_core::Pos::new(0, 3)), + new_text: "ONE".into(), + }; + assert_eq!(f.workspace.apply_edits(&editor.path, doc.0, std::slice::from_ref(&edit)), Err(HostError::Stale)); + assert_eq!(f.workspace.text("src/notes.txt"), "typed one two three\nfour five\n"); + let now = f.workspace.document(&editor.path).unwrap().0; + assert_eq!(f.workspace.apply_edits(&editor.path, now, &[edit]), Ok(now + 1)); + assert_eq!(f.workspace.text("src/notes.txt"), "ONEed one two three\nfour five\n"); + let _ = ( + std::any::type_name::(), + std::any::type_name::(), + std::any::type_name::<&dyn HostApi>(), + ); +} + +#[test] +fn read_file_is_confined_to_the_project() { + let f = fixture(Limits::default()); + f.install(&misbehave_pkg()); + 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, editor: 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); +} + +/// Plan P5: a missing capability is an error the plugin can handle, not a trap. +#[test] +fn ungranted_capabilities_are_denied_not_trapped() { + let f = fixture(Limits::default()); + f.install(&misbehave_pkg()); + let editor = f.workspace.open("src/notes.txt", &[]); + let err = f.run_in(&editor, "misbehave", "insert").unwrap_err(); + assert!(err.contains("Denied") && err.contains("editor.write"), "{err}"); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.crashes), (PluginStatus::Active, 0), "the instance lives on"); + assert_eq!(f.workspace.text("src/notes.txt"), "one two three\nfour five\n"); +} + +/// Plan P4: a trap drops the instance, which restarts on next use after a +/// backoff that doubles; only consecutive crashes stop the plugin. +#[test] +fn crashes_restart_with_backoff_until_the_circuit_opens() { + let backoff = Duration::from_millis(150); + let f = fixture(Limits { restart_backoff: backoff, max_failures: 3, ..Limits::default() }); + f.install(&sample("hello")); + f.install(&misbehave_pkg()); + 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::Inactive); + assert!(info.error.as_deref().is_some_and(|e| e.contains("boom"))); + assert!(!info.contributions.is_empty(), "contributions stay while it can restart"); + let events = f.take_events(); + assert!(events.iter().any(|(client, e)| client.as_deref() == Some("main") + && matches!(e, PluginEvent::Notification { level: NotificationLevel::Warning, title, .. } if title.contains("crashed")))); + + // Backing off, then a fresh instance; a call that returns resets the count. + assert!(f.run("misbehave", "echo", Some("x")).unwrap_err().contains("restarts in")); + std::thread::sleep(backoff + Duration::from_millis(50)); + assert_eq!(f.run("misbehave", "echo", Some("back")).unwrap().as_deref(), Some("back")); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.activations, info.error), (PluginStatus::Active, 2, None)); + + // Three in a row: stopped for the session, with growing waits between. + f.take_events(); + let mut waits = Vec::new(); + for _ in 0..3 { + let started = Instant::now(); + let err = loop { + match f.run("misbehave", "panic", None) { + Err(e) if e.contains("restarts in") => std::thread::sleep(Duration::from_millis(20)), + other => break other.unwrap_err(), + } + }; + assert!(err.contains("crashed"), "{err}"); + waits.push(started.elapsed()); + } + assert!(waits[2] >= backoff * 2 && waits[2] > waits[1], "{waits:?}"); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!(info.status, PluginStatus::Failed); + assert!(info.error.as_deref().is_some_and(|e| e.contains("3 times in a row")), "{:?}", info.error); + assert!(info.enabled, "failure is per session, not persisted"); + assert!(info.contributions.is_empty(), "a failed plugin's contributions are withdrawn"); + let events = f.take_events(); + assert!(events.iter().any(|(_, e)| matches!(e, + PluginEvent::Notification { level: NotificationLevel::Error, title, .. } if title.contains("was disabled")))); + assert_eq!( + events + .iter() + .filter(|(_, e)| matches!(e, PluginEvent::Notification { level: NotificationLevel::Warning, .. })) + .count(), + 1, + "one crash notice per streak" + ); + assert!(f.run("misbehave", "echo", Some("x")).unwrap_err().contains("not running")); + // Other plugins are unaffected. + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 0)]); + assert!(f.run_in(&editor, "hello", "insert-date").is_ok()); + + // Toggling restarts it with a fresh instance. + f.host.set_enabled("misbehave", true).unwrap(); + assert_eq!(f.run("misbehave", "echo", Some("again")).unwrap().as_deref(), Some("again")); + assert_eq!(f.status("misbehave"), PluginStatus::Active); +} + +#[test] +fn fuel_exhaustion_traps() { + let f = + fixture(Limits { fuel: 50_000_000, timeout: Duration::from_secs(30), max_failures: 1, ..Limits::default() }); + f.install(&misbehave_pkg()); + let err = f.run("misbehave", "spin", None).unwrap_err(); + assert!(err.contains("CPU budget"), "{err}"); + assert_eq!(f.status("misbehave"), PluginStatus::Failed, "max_failures 1: the first crash stops it"); +} + +#[test] +fn wall_clock_timeout_traps() { + let f = fixture(Limits { fuel: u64::MAX, timeout: Duration::from_millis(200), ..Limits::default() }); + f.install(&misbehave_pkg()); + let started = std::time::Instant::now(); + let err = f.run("misbehave", "spin", None).unwrap_err(); + assert!(err.contains("timed out after 200ms"), "{err}"); + assert!(started.elapsed() < Duration::from_secs(5)); + assert_eq!(f.host.info_of("misbehave").unwrap().stats.crashes, 1); +} + +#[test] +fn memory_limit_traps() { + let f = fixture(Limits { memory_bytes: 32 << 20, ..Limits::default() }); + f.install(&misbehave_pkg()); + let err = f.run("misbehave", "alloc", None).unwrap_err(); + assert!(err.contains("memory"), "{err}"); + assert_eq!(f.host.info_of("misbehave").unwrap().stats.crashes, 1); +} + +/// Plan P4: a running call can be interrupted from another thread; the +/// instance is rebuilt on next use without counting as a crash. +#[test] +fn running_calls_can_be_cancelled() { + let f = Arc::new(fixture(Limits { fuel: u64::MAX, timeout: Duration::from_secs(30), ..Limits::default() })); + f.install(&misbehave_pkg()); + let spinning = { + let f = f.clone(); + std::thread::spawn(move || f.run("misbehave", "spin", None)) + }; + std::thread::sleep(Duration::from_millis(100)); + let started = Instant::now(); + f.host.cancel("misbehave"); + let err = spinning.join().unwrap().unwrap_err(); + assert!(err.contains("was cancelled"), "{err}"); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x"), "no backoff"); + let stats = f.host.info_of("misbehave").unwrap().stats; + assert_eq!((stats.crashes, stats.activations), (0, 2)); +} + +/// `misbehave` contributing a language, so its `syntax-provider` serves it. +fn misbehave_with_language(dir: &Path) -> PathBuf { + let pkg = dir.join("misbehave"); + copy_dir(&misbehave_pkg(), &pkg); + std::fs::write( + pkg.join("plugin.toml"), + "id = \"misbehave\"\nname = \"Misbehave\"\nversion = \"0.0.1\"\nengines.openidea = \"^1.0\"\n\ + wasm = \"dist/plugin.wasm\"\nactivation = [\"on-language:mis\"]\n\ + [[contributes.languages]]\npath = \"mis.toml\"\n", + ) + .unwrap(); + std::fs::write(pkg.join("mis.toml"), "id = \"mis\"\nname = \"Mis\"\nextensions = [\"mis\"]\n").unwrap(); + pkg +} + +/// Plan P4: edit-path calls stop waiting at the soft deadline (the result is +/// dropped, the instance kept) and are interrupted at the hard one. +#[test] +fn edit_path_calls_have_soft_and_hard_deadlines() { + use ide_core::LanguageId; + let tmp = tempfile::tempdir().unwrap(); + let limits = Limits { + edit_soft: Duration::from_millis(50), + edit_hard: Duration::from_millis(400), + fuel: u64::MAX, + restart_backoff: Duration::ZERO, + ..Limits::default() + }; + let host = + Arc::new(PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()); + host.load_all(); + host.startup_finished(); + host.install_from_dir(&misbehave_with_language(tmp.path()), &Consent::all()).unwrap(); + let provider = host.syntax_provider(); + let mis = LanguageId::new("mis"); + + let tokens = provider.highlights(&mis, "fast").unwrap(); + assert_eq!((tokens[0].start, tokens[0].end, tokens[0].kind.as_str()), (0, 4, "keyword")); + + // Slower than the soft deadline: dropped, but the instance lives on. + let started = Instant::now(); + assert!(provider.highlights(&mis, "sleep:150").is_none()); + assert!(started.elapsed() < Duration::from_millis(120), "{:?}", started.elapsed()); + std::thread::sleep(Duration::from_millis(150)); + assert!(provider.highlights(&mis, "fast").is_some()); + let info = host.info_of("misbehave").unwrap(); + assert_eq!((info.stats.slow_calls, info.stats.crashes, info.stats.activations), (1, 0, 1)); + + // Past the hard deadline: interrupted; the next call gets a new instance. + assert!(provider.highlights(&mis, "spin").is_none()); + std::thread::sleep(Duration::from_millis(450)); + host.tick(); + let info = host.info_of("misbehave").unwrap(); + assert_eq!(info.stats.crashes, 1, "{:?}", info.error); + assert!(info.error.as_deref().is_some_and(|e| e.contains("timed out after 400ms")), "{:?}", info.error); + assert!(provider.highlights(&mis, "fast").is_some()); + assert_eq!(host.info_of("misbehave").unwrap().stats.activations, 2); +} + +/// Plan P4: idle instances are reclaimed; the plugin activates again on its +/// next use, and `on-schedule` activations don't keep it alive. +#[test] +fn idle_instances_are_reclaimed_and_schedules_reactivate() { + let tmp = tempfile::tempdir().unwrap(); + let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() }); + f.install(&misbehave_pkg()); + assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x")); + assert_eq!(f.host.instances(), 1); + f.host.tick(); + assert_eq!(f.host.instances(), 1, "not idle yet"); + std::thread::sleep(Duration::from_millis(150)); + f.host.tick(); + assert_eq!(f.host.instances(), 0); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1)); + assert_eq!(f.command_titles("misbehave").len(), 9, "runtime contributions stay"); + assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y")); + assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2); + + let pkg = hello_variant(tmp.path(), |t| { + hello_02(t, "\"editor.read\"") + .replace("activation = [\"on-command:insert-date\"]", "activation = [\"on-schedule:60\"]") + }); + f.install(&pkg); + assert_eq!(f.status("hello"), PluginStatus::Inactive); + let now = Instant::now(); + f.host.tick_at(now); + f.host.tick_at(now + Duration::from_secs(59)); + assert_eq!(f.status("hello"), PluginStatus::Inactive, "not due yet"); + f.host.tick_at(now + Duration::from_secs(61)); + assert!(f.host.wait_events(Duration::from_secs(10))); + assert_eq!(f.status("hello"), PluginStatus::Active, "due"); + std::thread::sleep(Duration::from_millis(150)); + f.host.tick(); + assert_eq!(f.status("hello"), PluginStatus::Inactive, "reclaimed again"); +} + +#[test] +fn runtime_language_and_theme_registration() { + let f = fixture(Limits::default()); + f.install(&misbehave_pkg()); + 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::>(), ["zz"]); + assert_eq!(f.host.languages()[0].extensions, ["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() + ); + assert_eq!(f.theme_kinds("misbehave"), [("t1".to_owned(), 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.install(&theme_template()); + assert_eq!(theme.status, PluginStatus::Static); + assert!(!theme.has_code); + assert_eq!(f.theme_kinds("theme-harbor-night")[0].0, "harbor-night"); + let lang = f.install(&sample("language-toy")); + assert_eq!(lang.status, PluginStatus::Static); + assert_eq!(f.host.languages()[0].id.as_str(), "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.install(&theme_template()); + assert_eq!(info.status, PluginStatus::Static); + assert_eq!(f.theme_kinds("theme-harbor-night"), [("harbor-night".to_owned(), ThemeKind::Dark)]); + // The scheme file is copied with the package and merged over the UI tokens. + assert!(f.installed("theme-harbor-night").join("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.install(dir.path()); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + let installed = f.installed("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.install(dir.path()); + // 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_is_rejected() { + 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\"\nengines.openidea = \"^1.0\"\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(); + // Validation happens before anything is copied. + let err = f.host.install_from_dir(dir.path(), &Consent::all()).unwrap_err().message; + assert!(err.contains("a.scheme.json"), "{err}"); + assert!(!f.plugins_dir.join("bad-scheme").exists()); + // Already on disk (copied by hand): listed as failed. + copy_dir(dir.path(), &f.plugins_dir.join("bad-scheme")); + f.host.load_all(); + let info = f.host.info_of("bad-scheme").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.install(&sample("hello")); + f.install(&theme_template()); + 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 startup_loads_without_instantiating_wasm() { + let f = fixture(Limits::default()); + f.install(&sample("hello")); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + let hello = again.info_of("hello").unwrap(); + assert_eq!(hello.status, PluginStatus::Inactive, "no instance before start-up finished"); + assert_eq!(hello.stats.activations, 0); + assert_eq!(again.ui_items(COMMANDS).len(), 2, "commands are shown without running code"); + again.startup_finished(); + assert_eq!(again.instances(), 0, "hello waits for its commands"); + + // `on-startup-finished` plugins are activated then, not before. + let tmp = tempfile::tempdir().unwrap(); + let eager = hello_variant(tmp.path(), |t| t.replace("on-command:*", "on-startup-finished")); + f.install(&eager); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.instances(), 0); + again.startup_finished(); + assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Active); +} + +#[test] +fn lazy_activation_on_command() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = hello_variant(tmp.path(), |t| hello_02(t, "\"project.read\", \"editor.read\", \"editor.write\"")); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + assert_eq!(info.activation, ["on-command:insert-date"]); + assert_eq!(f.command_titles("hello").len(), 2); + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 0)]); + assert!(f.run_in(&editor, "hello", "insert-date").unwrap().is_some()); + assert_eq!(f.status("hello"), PluginStatus::Active); +} + +#[test] +fn project_plugins_need_consent_even_in_trusted_projects() { + 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_eq!(mine.plugins[0].status, PluginStatus::NeedsConsent); + assert!(f.host.languages().is_empty(), "nothing takes effect before consent"); + assert!(f.host.list(None).plugins.is_empty(), "project plugins only show for their project"); + + f.host.grant("language-toy", &Consent::default()).unwrap(); + assert_eq!(f.status("language-toy"), PluginStatus::Static); + assert_eq!(f.host.languages().len(), 1); + assert!(f.host.uninstall("language-toy").is_err()); + assert!(f.host.install_from_dir(&sample("language-toy"), &Consent::all()).is_err(), "id clash with a dev plugin"); + + // Edit + reload picks up changes; the grant is by path, so no new prompt. + 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(); + let info = f.host.reload("language-toy").unwrap(); + assert_eq!(info.name, "Toy Language (dev)"); + assert_eq!(info.status, PluginStatus::Static); + + 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 untrusted_or_unconfirmed_project_plugins_never_run() { + let f = fixture(Limits::default()); + // Its commands are registered by `activate` only. + let dev = f.project.join(DEV_PLUGINS_DIR).join("misbehave"); + copy_dir(&misbehave_pkg(), &dev); + 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); + assert!(f.host.list(Some(&f.project)).plugins.is_empty()); + assert!(f.host.info_of("misbehave").is_err()); + + // Trusted but not confirmed: listed, nothing runs, activation registers nothing. + trusted.store(true, std::sync::atomic::Ordering::Release); + f.host.sync_project(&f.project); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!(info.status, PluginStatus::NeedsConsent); + assert!(f.command_titles("misbehave").is_empty(), "unconfirmed plugin registered its activation witness"); + assert!(f.run("misbehave", "echo", None).unwrap_err().contains("permission")); + + // Positive control: after consent, activation itself registers the commands. + let info = f.host.grant("misbehave", &Consent::default()).unwrap(); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x")); + assert!(f.command_titles("misbehave").iter().any(|t| t == "echo")); + + trusted.store(false, std::sync::atomic::Ordering::Release); + assert!(f.host.reload("misbehave").is_err()); + assert!(f.host.set_enabled("misbehave", true).is_err()); + assert!(f.run("misbehave", "echo", None).is_err()); + f.host.sync_project(&f.project); + assert!(f.host.list(Some(&f.project)).plugins.is_empty()); +} + +#[test] +fn new_capabilities_require_consent_again() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let v1 = hello_variant(&tmp.path().join("v1"), |t| hello_02(t, "\"project.read\"")); + let info = f.install(&v1); + assert_eq!(info.status, PluginStatus::Inactive); + assert!(info.capabilities.iter().all(|c| c.granted)); + + // Same capabilities, new version: accepted silently. + let v2 = hello_variant(&tmp.path().join("v2"), |t| { + hello_02(t, "\"project.read\"").replace("version = \"0.3.0\"", "version = \"0.3.1\"") + }); + f.host.load_all(); + std::fs::remove_dir_all(f.plugins_dir.join("hello")).unwrap(); + copy_dir(&v2, &f.plugins_dir.join("hello")); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Inactive); + drop(again); + + // A new required capability: waits for consent, nothing contributed. + let v3 = hello_variant(&tmp.path().join("v3"), |t| { + hello_02(t, "\"project.read\", \"editor.write\"").replace("version = \"0.3.0\"", "version = \"0.4.0\"") + }); + std::fs::remove_dir_all(f.plugins_dir.join("hello")).unwrap(); + copy_dir(&v3, &f.plugins_dir.join("hello")); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + let info = again.info_of("hello").unwrap(); + assert_eq!(info.status, PluginStatus::NeedsConsent); + let write = info.capabilities.iter().find(|c| c.id == "editor.write").unwrap(); + assert!(!write.granted); + assert!(again.ui_items(COMMANDS).is_empty()); + again.grant("hello", &Consent::default()).unwrap(); + assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Inactive); + + // Revoking a required capability stops it again. + let info = again.set_capability("hello", "editor.write", false).unwrap(); + assert_eq!(info.status, PluginStatus::NeedsConsent); +} + +#[test] +fn migrated_plugins_get_low_risk_capabilities_and_ask_on_first_use() { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + // Installed by a version without the grant ledger (a 0.1 manifest). + copy_dir(&sample("hello"), &plugins_dir.join("hello")); + let manifest = plugins_dir.join("hello/plugin.toml"); + std::fs::write(&manifest, hello_01(&std::fs::read_to_string(&manifest).unwrap())).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::default()).unwrap(); + host.load_all(); + let info = host.info_of("hello").unwrap(); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let granted: Vec<_> = info.capabilities.iter().filter(|c| c.granted).map(|c| c.id.as_str()).collect(); + assert_eq!(granted, ["project.read", "editor.read"]); + let inv = Invocation { client: Some("w".into()), ..Invocation::default() }; + let err = host.run_command("hello", "insert-date", None, inv.clone()).unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied); + assert!(events.lock().iter().any(|(c, e)| c.as_deref() == Some("w") + && matches!(e, PluginEvent::ConsentRequested { capabilities, .. } if capabilities[0].id == "editor.write"))); + host.grant("hello", &Consent::default()).unwrap(); + // Runs now (and fails only for want of an editor). + let err = host.run_command("hello", "insert-date", None, inv).unwrap_err(); + assert!(err.message.contains("no file is open"), "{err:?}"); +} + +#[cfg(unix)] +#[test] +fn replaced_binaries_are_refused() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + 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(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + + let pkg = tmp.path().join("pkg"); + copy_dir(&sample("language-toy"), &pkg); + let lang = pkg.join("languages/toy.toml"); + let text = std::fs::read_to_string(&lang).unwrap(); + std::fs::write(&lang, format!("{text}\n[[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n")).unwrap(); + + let preview = f.host.preview_install(&pkg).unwrap(); + let lsp = preview.capabilities.iter().find(|c| c.id == "process:toylsp").unwrap(); + assert_eq!(lsp.risk, ide_extension::Risk::High); + assert!(lsp.optional, "declaration-derived capabilities can be refused"); + + // Refused: the language still works. + f.host.install_from_dir(&pkg, &Consent::default()).unwrap(); + assert_eq!(f.host.languages().len(), 1); + assert!(f.host.program_for("language-toy", "toylsp").is_err()); + + // Accepted: pinned to the resolved file. + f.host.set_capability("language-toy", "process:toylsp", true).unwrap(); + let resolved = f.host.program_for("language-toy", "toylsp").unwrap(); + assert!(resolved.ends_with("toylsp")); + std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho replaced\n").unwrap(); + let err = f.host.program_for("language-toy", "toylsp").unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied); + assert!(err.message.contains("confirm"), "{}", err.message); +} + +/// Plan P3: a declaration-only language plugin reaches detection and LSP of +/// every project; its server starts only while `process:` is granted, from +/// the approved file. +#[cfg(unix)] +#[test] +fn plugin_languages_join_the_registry_and_start_only_granted_servers() { + use ide_core::LanguageId; + use ide_language::LanguageRegistry; + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + 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(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + + let registry = LanguageRegistry::load(None).unwrap(); + registry.attach_plugins(f.host.language_store().clone()); + let project = registry.fork(); + let toy = LanguageId::new("toy"); + assert_eq!(project.detect(Path::new("/p/a.toy"), None), LanguageId::plain_text()); + + let pkg = tmp.path().join("pkg"); + copy_dir(&sample("language-toy"), &pkg); + let lang = pkg.join("languages/toy.toml"); + let text = std::fs::read_to_string(&lang).unwrap(); + std::fs::write(&lang, format!("{text}\n[[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n")).unwrap(); + + // Refused: detected and listed, no server. + f.host.install_from_dir(&pkg, &Consent::default()).unwrap(); + assert_eq!(project.detect(Path::new("/p/a.toy"), None), toy); + assert!(registry.all().iter().any(|l| l.id == toy), "the UI language list includes it"); + assert!(project.get(&toy).unwrap().language_servers.is_empty()); + + // Granted: the server is pinned to the approved file. + f.host.set_capability("language-toy", "process:toylsp", true).unwrap(); + let server = project.get(&toy).unwrap().language_servers[0].clone(); + let pin = server.pinned.expect("pinned"); + assert_eq!(pin.verify().unwrap(), dunce::canonicalize(bin.join("toylsp")).unwrap()); + // Replaced afterwards: refused when it would start. + std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho replaced\n").unwrap(); + assert!(pin.verify().unwrap_err().contains("changed since it was approved")); + + // Revoked, then disabled. + f.host.set_capability("language-toy", "process:toylsp", false).unwrap(); + assert!(project.get(&toy).unwrap().language_servers.is_empty()); + f.host.set_enabled("language-toy", false).unwrap(); + assert_eq!(project.detect(Path::new("/p/a.toy"), None), LanguageId::plain_text()); +} + +/// Plan P3: a plugin provides the syntax of a language the IDE has no grammar +/// for (the official Solidity plugin, built with the SDK), activated on first use. +#[test] +fn plugin_syntax_for_languages_without_a_grammar() { + use ide_core::{DocId, LanguageId, SymbolKind}; + use ide_language::{Syntax, SyntaxCache, SyntaxHighlightKind}; + let tmp = tempfile::tempdir().unwrap(); + let host = Arc::new( + PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(), + ); + host.load_all(); + host.startup_finished(); + let info = host.install_from_dir(&repo().join("plugins/official/solidity"), &Consent::default()).unwrap(); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + assert!(info.capabilities.iter().all(|c| c.optional && !c.granted), "language servers were refused"); + + let provider = host.syntax_provider(); + let sol = LanguageId::new("solidity"); + let text = "// c\ncontract A {\n uint256 public x;\n function f() public {}\n}\n"; + assert!(provider.highlights(&LanguageId::new("rust"), text).is_none(), "only its own languages"); + let tokens = provider.highlights(&sol, text).unwrap(); + let kind_at = + |s: &str| tokens.iter().find(|t| &text[t.start as usize..t.end as usize] == s).map(|t| t.kind.as_str()); + assert_eq!(kind_at("contract"), Some("keyword")); + assert_eq!(kind_at("uint256"), Some("type")); + assert_eq!(kind_at("// c"), Some("comment")); + assert_eq!(host.info_of("openidea.solidity").unwrap().status, PluginStatus::Active, "activated on first use"); + + // As the editor gets it: through a project's syntax cache. + ide_language::set_external_syntax(Some(provider.clone())); + let cache = SyntaxCache::new(Arc::new(Syntax::new())); + let outline = cache.outline(DocId(1), &sol, 1, text); + assert_eq!(outline.len(), 1); + assert_eq!((outline[0].name.as_str(), outline[0].kind), ("A", SymbolKind::Class)); + let members: Vec<_> = outline[0].children.iter().map(|c| (c.name.as_str(), c.kind)).collect(); + assert_eq!(members, [("x", SymbolKind::Field), ("f", SymbolKind::Method)]); + assert_eq!(outline[0].selection_range.start.line, 1); + let folds = cache.folding(DocId(1), &sol, 1, text); + assert!(folds.iter().any(|f| f.start_line == 1 && f.end_line == 4)); + let highlights = cache.highlights(DocId(1), &sol, 1, text); + let keyword = SyntaxHighlightKind::ALL.iter().position(|k| *k == SyntaxHighlightKind::Keyword).unwrap() as u32; + assert!(highlights.data.chunks(3).any(|t| t == [5, 13, keyword]), "UTF-16 offsets of `contract`"); + ide_language::set_external_syntax(None); +} + +/// Plan P3: plugins add run configurations to `ide-run`'s detection; the +/// command runs only while granted, from the approved file. +#[cfg(unix)] +#[test] +fn run_detectors_offer_granted_commands_in_marked_projects() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + std::fs::write(bin.join("forge"), "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(bin.join("forge"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + + let pkg = tmp.path().join("pkg"); + std::fs::create_dir(&pkg).unwrap(); + std::fs::write( + pkg.join("plugin.toml"), + "id = \"acme.forge\"\nname = \"Forge\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.runDetectors]]\nid = \"test\"\nname = \"forge test\"\nfiles = [\"foundry.toml\"]\n\ + command = \"forge\"\nargs = [\"test\"]\ntest = true\n", + ) + .unwrap(); + let preview = f.host.preview_install(&pkg).unwrap(); + assert!(preview.capabilities.iter().any(|c| c.id == "process:forge" && c.optional)); + f.host.install_from_dir(&pkg, &Consent::default()).unwrap(); + + let detection = f.host.run_detection(); + let project = tmp.path().join("chain"); + std::fs::create_dir(&project).unwrap(); + assert!(detection.detect(&project).is_empty(), "no marker"); + std::fs::write(project.join("foundry.toml"), "").unwrap(); + assert!(detection.detect(&project).is_empty(), "not granted"); + + f.host.set_capability("acme.forge", "process:forge", true).unwrap(); + let configs = detection.detect(&project); + assert_eq!(configs.len(), 1); + let config = &configs[0]; + assert_eq!( + (config.id.as_str(), config.name.as_str(), config.is_test), + ("plugin:acme.forge:test", "forge test", true) + ); + assert_eq!(detection.launch(config).unwrap(), dunce::canonicalize(bin.join("forge")).unwrap()); + + std::fs::write(bin.join("forge"), "#!/bin/sh\necho swapped\n").unwrap(); + assert!(detection.launch(config).unwrap_err().contains("changed since it was approved")); + f.host.set_enabled("acme.forge", false).unwrap(); + assert!(detection.detect(&project).is_empty()); + assert!(detection.launch(config).unwrap_err().contains("no longer enabled")); +} + +/// Plan P4: compiled components are cached across runs (keyed by module and +/// engine), so a cold activation only deserializes. +#[test] +fn precompiled_components_make_cold_activation_cheap() { + let f = fixture(Limits::default()); + f.install(&sample("hello")); + let cache = f.plugins_dir.join(".cache/components"); + let cached = || std::fs::read_dir(&cache).map(|d| d.count()).unwrap_or(0); + assert_eq!(cached(), 1, "install validation compiled it once, into the cache"); + + // Another run of the IDE: activation deserializes. + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + let started = Instant::now(); + let _ = again.run_command("hello", "word-count", None, Invocation::default()); + let cold = started.elapsed(); + let stats = again.info_of("hello").unwrap().stats; + assert_eq!(stats.activations, 1); + eprintln!("cold activation with a precompiled component: {:.1} ms (wall {cold:?})", stats.activation_ms); + assert!(stats.activation_ms < 250.0, "{} ms", stats.activation_ms); + + // A stale entry (other engine or uninstalled plugin) is pruned by the warm-up. + std::fs::write(cache.join("0000-stale.cwasm"), b"junk").unwrap(); + again.warm_component_cache(); + assert_eq!(cached(), 1); + // A corrupt entry is discarded and recompiled. + for e in std::fs::read_dir(&cache).unwrap().flatten() { + std::fs::write(e.path(), b"junk").unwrap(); + } + again.reload("hello").unwrap(); + assert!( + again.run_command("hello", "word-count", None, Invocation::default()).is_err(), + "no active file, but it runs" + ); + assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Active); +} + +#[test] +fn install_validation_atomic_replace_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\"\nengines.openidea = \"^1.0\"\nwasm = \"x.wasm\"", + ) + .unwrap(); + let install = |p: &Path| f.host.install_from_dir(p, &Consent::all()); + assert!(install(&pkg).unwrap_err().message.contains("missing")); + // Not a component. + std::fs::write(pkg.join("x.wasm"), b"\0asm garbage").unwrap(); + assert!(install(&pkg).unwrap_err().message.contains("not a valid plugin component")); + // Bad manifest. + std::fs::write(pkg.join("plugin.toml"), "id = \"B!\"").unwrap(); + assert!(install(&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; a failing reinstall keeps the installed version. + f.install(&sample("hello")); + f.install(&sample("hello")); + assert_eq!(f.status("hello"), PluginStatus::Inactive); + let bad = hello_variant(tmp.path(), |t| t.replace("version = \"0.3.0\"", "version = \"0.9.0\"")); + std::fs::write(bad.join("dist/plugin.wasm"), b"not wasm").unwrap(); + assert!(install(&bad).is_err()); + assert_eq!(f.host.info_of("hello").unwrap().version, "0.3.0"); + assert!(f.installed("hello").join("dist/plugin.wasm").is_file()); + assert!(f.installed("hello").join(".package-hash").is_file()); + let leftovers: Vec<_> = std::fs::read_dir(&f.plugins_dir) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with(".staging")) + .collect(); + assert!(leftovers.is_empty()); + + f.host.uninstall("hello").unwrap(); + assert!(!f.plugins_dir.join("hello").exists()); + assert!(f.host.info_of("hello").is_err()); +} + +#[test] +fn unknown_contribution_points_are_skipped_with_a_warning() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("future"); + copy_dir(&sample("language-toy"), &pkg); + let manifest = pkg.join("plugin.toml"); + let text = std::fs::read_to_string(&manifest).unwrap(); + std::fs::write(&manifest, format!("{text}\n[[contributes.holograms]]\nshape = \"cube\"\n")).unwrap(); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static); + assert!(info.warnings.iter().any(|w| w.contains("holograms"))); + assert_eq!(f.host.languages().len(), 1); +} + +#[test] +fn icon_themes_are_a_contribution_type_like_any_other() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("icons"); + std::fs::create_dir_all(pkg.join("svg")).unwrap(); + std::fs::write(pkg.join("svg/rust.svg"), r#""#).unwrap(); + std::fs::write(pkg.join("svg/file.svg"), r#""#).unwrap(); + std::fs::write( + pkg.join("theme.json"), + r#"{"iconDefinitions":{"rust":"svg/rust.svg","file":"svg/file.svg"},"file":"file","fileExtensions":{"rs":"rust"}}"#, + ) + .unwrap(); + std::fs::write( + pkg.join("plugin.toml"), + "id = \"acme.icons\"\nname = \"Icons\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.iconThemes]]\nid = \"acme\"\nlabel = \"Acme Icons\"\npath = \"theme.json\"\n", + ) + .unwrap(); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + assert!(f.installed("acme.icons").join("svg/rust.svg").is_file(), "referenced files are installed"); + let items = f.host.ui_items("iconThemes"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].decl["label"], "Acme Icons"); + let svgs = f.host.assets("acme.icons", &icon_theme_svg_prefix("acme")).unwrap(); + assert_eq!(svgs.len(), 2); + let map = f.host.assets("acme.icons", &icon_theme_map_asset("acme")).unwrap(); + assert!(map[0].1.contains("\"rs\":\"rust\"")); + f.host.set_enabled("acme.icons", false).unwrap(); + assert!(f.host.ui_items("iconThemes").is_empty()); + assert!(f.host.assets("acme.icons", "").is_err()); +} + +#[test] +fn official_material_icons_install() { + let f = fixture(Limits::default()); + let info = f.install(&repo().join("plugins/official/material-icons")); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + let theme: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(repo().join("plugins/official/material-icons/icons/theme.json")).unwrap(), + ) + .unwrap(); + let defined = theme["iconDefinitions"].as_object().unwrap().len(); + let svgs = f.host.assets("openidea.material-icons", &icon_theme_svg_prefix("material")).unwrap(); + assert_eq!(svgs.len(), defined, "every icon survives the sanitiser"); + let map = &f.host.assets("openidea.material-icons", &icon_theme_map_asset("material")).unwrap()[0].1; + assert!(map.contains("\"src\":\"folder-src\""), "folder names are part of the mapping"); + assert!( + !f.installed("openidea.material-icons").join("generate.py").exists(), + "only referenced files are installed" + ); +} + +/// Plan P1: an icon theme with thousands of SVGs (sanitising, package size, start-up). +#[test] +fn thousands_of_icons_install_and_reload_within_budget() { + const ICONS: usize = 3000; + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("many"); + std::fs::create_dir_all(pkg.join("svg")).unwrap(); + let mut defs = serde_json::Map::new(); + let mut exts = serde_json::Map::new(); + for i in 0..ICONS { + let svg = format!( + r##"{}"##, + i * 2654435 % 0xff_ffff, + i % 100 + ); + std::fs::write(pkg.join(format!("svg/i{i}.svg")), svg).unwrap(); + defs.insert(format!("i{i}"), format!("svg/i{i}.svg").into()); + exts.insert(format!("x{i}"), format!("i{i}").into()); + } + let theme = serde_json::json!({ "iconDefinitions": defs, "file": "i0", "fileExtensions": exts }); + std::fs::write(pkg.join("theme.json"), theme.to_string()).unwrap(); + std::fs::write( + pkg.join("plugin.toml"), + "id = \"acme.many\"\nname = \"Many\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.iconThemes]]\nid = \"many\"\nlabel = \"Many\"\npath = \"theme.json\"\n", + ) + .unwrap(); + + let started = Instant::now(); + let info = f.install(&pkg); + let install = started.elapsed(); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + assert_eq!(f.host.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap().len(), ICONS); + + let started = Instant::now(); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + let reload = started.elapsed(); + assert_eq!(again.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap().len(), ICONS); + eprintln!("{ICONS} icons: install {install:?}, load at start-up {reload:?} (from the contribution index)"); + // Generous for debug builds; release is several times faster. + assert!(install < Duration::from_secs(5), "install took {install:?}"); + assert!(reload < Duration::from_secs(3), "start-up load took {reload:?}"); + + // Start-up reads the validated declaration from the index, not the SVGs: + // a tampered icon file is not even looked at... + let index = f.plugins_dir.join(".cache/contributions"); + assert_eq!(std::fs::read_dir(&index).unwrap().count(), 1); + std::fs::write(f.installed("acme.many").join("svg/i1.svg"), "").unwrap(); + let third = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + third.load_all(); + let icon = third.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap(); + assert!(icon.iter().all(|(_, svg)| !svg.contains("script"))); + // ...and without the index everything is validated again. + std::fs::remove_dir_all(&index).unwrap(); + let fourth = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + fourth.load_all(); + assert_eq!(fourth.info_of("acme.many").unwrap().status, PluginStatus::Failed, "the tampered icon is rejected"); +} + +#[test] +fn linked_development_plugins() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("toy"); + copy_dir(&sample("language-toy"), &pkg); + let info = f.host.link(&pkg).unwrap(); + assert_eq!(info.source, PluginSource::Linked); + assert_eq!(info.status, PluginStatus::NeedsConsent); + f.host.grant("language-toy", &Consent::default()).unwrap(); + assert_eq!(f.host.languages().len(), 1); + // Survives a restart (links.json) and keeps its grant (by path). + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.info_of("language-toy").unwrap().status, PluginStatus::Static); + again.unlink("language-toy").unwrap(); + assert!(again.info_of("language-toy").is_err()); +} + +#[test] +fn catalog_lists_sample_packages() { + let f = fixture(Limits::default()); + 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\"\nengines.openidea = \"^1.0\"\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 = f.host.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.contributions.iter().find(|c| c.point == "commands").unwrap().count, 2); + let theme = catalog.iter().find(|c| c.id == "theme-harbor-night").unwrap(); + assert!(!theme.has_code); + assert_eq!(theme.contributions[0].count, 1); + let x = catalog.iter().find(|c| c.id == "x").unwrap(); + assert!(x.problem.as_deref().unwrap().contains("plugin.wasm")); +} + +fn official(name: &str) -> PathBuf { + repo().join("plugins/official").join(name) +} + +/// Plan P5, String Tools: 26 commands show before the code loads; one command +/// edits every selection as one versioned change. +#[test] +fn official_string_tools() { + let f = fixture(Limits::default()); + let info = f.install(&official("string-tools")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let titles = f.command_titles("openidea.string-tools"); + assert_eq!(titles.len(), 26); + assert!(titles.contains(&"String Tools: To snake_case".to_owned())); + let categories: std::collections::BTreeSet = + f.host.ui_items(COMMANDS).iter().filter_map(|c| c.decl["category"].as_str().map(str::to_owned)).collect(); + assert_eq!(categories.len(), 4, "{categories:?}"); + + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 3), (1, 5, 1, 5)]); + let before = f.workspace.document(&editor.path).unwrap().0; + f.run_in(&editor, "openidea.string-tools", "upper-case").unwrap(); + assert_eq!(f.workspace.text("src/notes.txt"), "ONE two three\nfour FIVE\n"); + assert_eq!(f.workspace.document(&editor.path).unwrap().0, before + 1, "both selections in one change"); +} + +/// Plan P5, Key Promoter: action events are batched; an idle plugin is +/// woken once per batch, and its counts persist in storage. +#[test] +fn official_key_promoter() { + let f = fixture(Limits::default()); + f.install(&official("key-promoter")); + assert!(f.host.event_kinds().contains(&EventKind::ActionPerformed), "subscribed by its manifest"); + let menu = |action: &str| { + WorkbenchEvent::ActionPerformed(ActionPerformed { + action: action.into(), + title: "Duplicate Line".into(), + source: ActionSource::Menu, + shortcut: Some("Ctrl+D".into()), + }) + }; + let typed = WorkbenchEvent::ActionPerformed(ActionPerformed { + action: "editor.copy".into(), + title: "Copy".into(), + source: ActionSource::Keyboard, + shortcut: Some("Ctrl+C".into()), + }); + f.take_events(); + let now = Instant::now(); + f.host.post_events(Some("main"), vec![menu("code.duplicate"), typed]); + f.host.flush_events_at(now); + assert_eq!(f.host.instances(), 0, "an idle plugin collects a batch first"); + f.host.post_events(Some("main"), vec![menu("code.duplicate"), menu("code.duplicate")]); + f.host.flush_events_at(now + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + let info = f.host.info_of("openidea.key-promoter").unwrap(); + assert_eq!((info.status, info.stats.activations, info.stats.calls), (PluginStatus::Active, 1, 1), "one wake-up"); + let tips: Vec<_> = f + .take_events() + .into_iter() + .filter_map(|(client, e)| match e { + PluginEvent::Notification { title, body, .. } => Some((client, title, body)), + _ => None, + }) + .collect(); + assert_eq!(tips.len(), 1, "{tips:?}"); + assert_eq!(tips[0].0.as_deref(), Some("main"), "shown in the window the actions came from"); + assert!(tips[0].1.contains("Ctrl+D") && tips[0].2.contains("3 times"), "{tips:?}"); + let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.key-promoter/storage.json")).unwrap(); + assert!(stored.contains("\"count:code.duplicate\":\"3\""), "{stored}"); + + // Uninstalling removes its data. + f.host.uninstall("openidea.key-promoter").unwrap(); + assert!(!f.plugins_dir.join(".data/openidea.key-promoter").exists()); + assert!(f.host.event_kinds().is_empty()); +} + +fn git(dir: &Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"]) + .args(args) + .current_dir(dir) + .output() + .expect("git"); + assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); +} + +/// Plan P5 acceptance: Git Toolbox's scheduled fetch still runs after the +/// instance was reclaimed while idle, and the status bar follows. +#[test] +fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() { + let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() }); + let tmp = tempfile::tempdir().unwrap(); + // The project tracks a remote that gets one more commit. + let remote = tmp.path().join("remote.git"); + std::fs::create_dir(&remote).unwrap(); + git(&remote, &["init", "--bare", "-b", "main"]); + git(&f.project, &["init", "-b", "main"]); + git(&f.project, &["add", "."]); + git(&f.project, &["commit", "-m", "one"]); + git(&f.project, &["remote", "add", "origin", remote.to_str().unwrap()]); + git(&f.project, &["push", "-u", "origin", "main"]); + let other = tmp.path().join("other"); + git(tmp.path(), &["clone", remote.to_str().unwrap(), other.to_str().unwrap()]); + std::fs::write(other.join("b.txt"), "b").unwrap(); + git(&other, &["add", "."]); + git(&other, &["commit", "-m", "two"]); + git(&other, &["push"]); + + f.install(&official("git-toolbox")); + // Focusing an editor wakes it to show the branch (nothing fetched yet). + let now = Instant::now(); + f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]); + f.host.flush_events_at(now + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + let item = |host: &PluginHost| { + host.status_items().into_iter().find(|(p, id, _)| p == "openidea.git-toolbox" && id == "branch") + }; + assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main".into())); + + // Idle: reclaimed. + std::thread::sleep(Duration::from_millis(100)); + f.host.tick(); + assert_eq!(f.host.instances(), 0); + assert_eq!(f.status("openidea.git-toolbox"), PluginStatus::Inactive); + assert!(item(&f.host).is_some(), "its status item stays while it sleeps"); + + // Five minutes later: activated by its schedule, fetches, shows ↓1. + f.take_events(); + f.host.tick_at(Instant::now() + Duration::from_secs(301)); + assert!(f.host.wait_events(Duration::from_secs(30))); + let info = f.host.info_of("openidea.git-toolbox").unwrap(); + assert_eq!(info.stats.activations, 2, "{:?}", info.error); + assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main ↓1".into())); + let events = f.take_events(); + assert!( + events.iter().any( + |(_, e)| matches!(e, PluginEvent::TaskStarted { title, cancellable: true, .. } if title == "Fetching") + ) + ); + assert!(events.iter().any(|(_, e)| matches!(e, PluginEvent::TaskEnded { .. }))); + let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.git-toolbox/storage.json")).unwrap(); + assert!(stored.contains("last-fetch:"), "{stored}"); +} + +/// Restricted Mode: git does not run in an untrusted project (no fetch). +#[test] +fn git_toolbox_does_not_fetch_untrusted_projects() { + let f = fixture(Limits::default()); + git(&f.project, &["init", "-b", "main"]); + f.install(&official("git-toolbox")); + let untrusted = Arc::new(TestWorkspace { + root: f.project.clone(), + documents: f.workspace.documents.clone(), + trusted: false, + diagnostics: Mutex::default(), + settings: Mutex::default(), + }); + f.host.set_workspace(Some(untrusted)); + let err = f.run("openidea.git-toolbox", "fetch", None).unwrap_err(); + assert!(err.contains("Restricted Mode"), "{err}"); +} + +/// A tiny HTTP server: each request is answered after `delay(n)` (n counts +/// requests) with `body`. Returns its port and the request bodies it got. +fn http_server( + body: &'static str, + delay: impl Fn(usize) -> Duration + Send + Sync + 'static, +) -> (u16, Arc>>) { + use std::io::{BufRead, BufReader, Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen: Arc>> = Arc::default(); + let (log, delay) = (seen.clone(), Arc::new(delay)); + std::thread::spawn(move || { + for (n, stream) in listener.incoming().flatten().enumerate() { + let (log, delay) = (log.clone(), delay.clone()); + std::thread::spawn(move || { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut len = 0; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut req = vec![0; len]; + let _ = reader.read_exact(&mut req); + log.lock().push(String::from_utf8_lossy(&req).into_owned()); + std::thread::sleep(delay(n)); + let mut stream = stream; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + }); + } + }); + (port, seen) +} + +/// Plan P6, AI Completion: typing on cancels the previous request (the fetch +/// returns `cancelled`, the instance stays); the plugin's own work stays +/// within the edit-path budget. +#[test] +fn official_ai_completion_cancels_superseded_requests() { + let f = fixture(Limits::default()); + f.install(&official("ai-completion")); + let (port, seen) = http_server(r#"{"choices":[{"text":"a + b\n"}]}"#, |n| { + if n == 0 { Duration::from_secs(3) } else { Duration::ZERO } + }); + f.run("openidea.ai-completion", "set-endpoint", Some(&format!("http://localhost:{port}"))).unwrap(); + let doc = ide_plugin::ProviderDocument { + path: f.project.join("src/a.rs"), + language: "rust".into(), + version: 1, + text: "fn add(a: i32, b: i32) -> i32 {\n \n}\n".into(), + }; + let at = ide_core::Pos::new(1, 4); + let host = Arc::new(f); + // A slow request, superseded 200 ms later by the next keystroke's. + let first = ide_core::CancellationToken::new(); + let slow = { + let (host, doc, first) = (host.clone(), doc.clone(), first.clone()); + std::thread::spawn(move || { + let started = Instant::now(); + let r = host.host.provide_inline_completion(&doc, at, &first); + (r, started.elapsed()) + }) + }; + std::thread::sleep(Duration::from_millis(200)); + first.cancel(); + let started = Instant::now(); + let second = host.host.provide_inline_completion(&doc, at, &ide_core::CancellationToken::new()); + let took = started.elapsed(); + let (old, old_took) = slow.join().unwrap(); + assert!(old.is_none(), "the superseded request yields nothing"); + assert!(old_took < Duration::from_secs(1), "cancelled, not waited out: {old_took:?}"); + assert_eq!(second.map(|(_, s)| s.insert_text).as_deref(), Some("a + b")); + eprintln!("inline completion after cancelling the previous request: {took:?}"); + let info = host.host.info_of("openidea.ai-completion").unwrap(); + assert_eq!((info.stats.crashes, info.stats.activations), (0, 1), "cooperative: same instance"); + assert!(seen.lock()[1].contains("\"prompt\":\"fn add(a: i32, b: i32) -> i32 {\\n \""), "{:?}", seen.lock()); + + // The plugin's own share of a call (local server, no delay): edit-path budget. + let mut times = Vec::new(); + for _ in 0..10 { + let started = Instant::now(); + assert!(host.host.provide_inline_completion(&doc, at, &ide_core::CancellationToken::new()).is_some()); + times.push(started.elapsed()); + } + times.sort(); + eprintln!("inline completion round trip p95 (local server): {:?}", times[9]); + assert!(times[9] < Duration::from_millis(500), "{times:?}"); +} + +/// Plan P6, Checkstyle: a save runs the approved `checkstyle` and publishes +/// diagnostics; quick fixes come through the code-action provider; saving +/// again cancels the check still running. +#[cfg(unix)] +#[test] +fn official_checkstyle_checks_on_save() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let bin = f.plugins_dir.parent().unwrap().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + // Reports a tab on line 2; files containing SLOW take 5 s. + std::fs::write( + bin.join("checkstyle"), + "#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q SLOW \"$file\" && sleep 5\n\ + echo ''\n\ + echo ''\n\ + echo ''\nexit 1\n", + ) + .unwrap(); + std::fs::set_permissions(bin.join("checkstyle"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + let info = f.install(&official("checkstyle")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + + let java = f.project.join("src/A.java"); + std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap(); + let saved = |f: &Fixture| { + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + }; + saved(&f); + assert!(f.host.wait_events(Duration::from_secs(20))); + let diagnostics = + |f: &Fixture| f.workspace.diagnostics.lock().get(&("openidea.checkstyle".into(), java.clone())).cloned(); + let d = diagnostics(&f).expect("published"); + assert_eq!((d.len(), d[0].code.as_deref(), d[0].range.start.line), (1, Some("FileTabCharacter"), 1)); + + // The quick fix, through the provider (as the editor asks for it). + let doc = ide_plugin::ProviderDocument { + path: java.clone(), + language: "java".into(), + version: 1, + text: std::fs::read_to_string(&java).unwrap(), + }; + let range = ide_core::Range::new(ide_core::Pos::new(1, 0), ide_core::Pos::new(1, 0)); + let actions = f.host.provide_code_actions(&doc, range); + assert_eq!(actions.len(), 1, "{actions:?}"); + assert_eq!(actions[0].1.title, "Checkstyle: Replace tabs with spaces"); + assert_eq!(actions[0].1.edits[0].1[0].new_text, " int x;"); + + // A slow check is cancelled by the next save. + f.workspace.diagnostics.lock().clear(); + std::fs::write(&java, "class A { // SLOW\n\tint x;\n}\n").unwrap(); + let started = Instant::now(); + saved(&f); + std::thread::sleep(Duration::from_millis(300)); + std::fs::write(&java, "class A {\n\tint y;\n}\n").unwrap(); + saved(&f); + assert!(f.host.wait_events(Duration::from_secs(20))); + assert!(started.elapsed() < Duration::from_secs(4), "the slow run was killed: {:?}", started.elapsed()); + assert_eq!(diagnostics(&f).map(|d| d.len()), Some(1), "published by the second check"); + let info = f.host.info_of("openidea.checkstyle").unwrap(); + assert_eq!(info.stats.crashes, 0); +} + +/// Plan P6, MyBatis Navigator: interface ↔ XML through the providers, on a +/// real project tree (`find-files`), within the edit-path budget. +#[test] +fn official_mybatis_navigates_between_interface_and_xml() { + let f = fixture(Limits::default()); + f.install(&official("mybatis")); + let java_path = f.project.join("src/main/java/com/acme/UserMapper.java"); + let xml_path = f.project.join("src/main/resources/mapper/UserMapper.xml"); + std::fs::create_dir_all(java_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(xml_path.parent().unwrap()).unwrap(); + let java = "package com.acme;\n\npublic interface UserMapper {\n User findById(long id);\n}\n"; + let xml = "\n \n\n"; + std::fs::write(&java_path, java).unwrap(); + std::fs::write(&xml_path, xml).unwrap(); + // Noise the index has to skip. + for i in 0..200 { + std::fs::write(f.project.join(format!("src/f{i}.xml")), "").unwrap(); + } + let doc = |path: &Path, language: &str, text: &str| ide_plugin::ProviderDocument { + path: path.to_path_buf(), + language: language.into(), + version: 1, + text: text.into(), + }; + let jdoc = doc(&java_path, "java", java); + let markers = f.host.provide_line_markers(&jdoc); + assert_eq!(markers.len(), 1, "{markers:?}"); + let target = markers[0].1.target.clone().unwrap(); + assert_eq!((target.path, target.range.start), (xml_path.clone(), ide_core::Pos::new(1, 14))); + + let xdoc = doc(&xml_path, "xml", xml); + let to_java = f.host.provide_definition(&xdoc, ide_core::Pos::new(1, 16)); + assert_eq!( + to_java.iter().map(|l| (l.path.clone(), l.range.start)).collect::>(), + [(java_path.clone(), ide_core::Pos::new(3, 9))] + ); + assert!( + f.host.provide_definition(&doc(&f.project.join("src/a.rs"), "rust", ""), ide_core::Pos::new(0, 0)).is_empty(), + "not its language" + ); + + // Warm (index cached): well inside the edit-path budget. + let mut times = Vec::new(); + for _ in 0..20 { + let started = Instant::now(); + assert_eq!(f.host.provide_line_markers(&jdoc).len(), 1); + times.push(started.elapsed()); + } + times.sort(); + eprintln!("mybatis line markers p95: {:?}", times[18]); + assert!(times[18] < Duration::from_millis(50), "{times:?}"); + assert_eq!(f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls, 0); +} + +/// Plan P7, CSV Editor: a custom editor renders the document as a table (the +/// rows asked for), and edits the document itself — versioned, so a text edit +/// made meanwhile is never overwritten. +#[test] +fn official_csv_editor_edits_the_shared_document() { + let f = fixture(Limits::default()); + let info = f.install(&official("csv-editor")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + std::fs::write(f.project.join("people.csv"), "name,city\nAda,London\nBob,Oslo\n").unwrap(); + let editor = f.workspace.open("people.csv", &[]); + let inv = Invocation { editor: Some(editor.clone()), ..f.inv() }; + let viewport = Some(ide_plugin::Viewport { first_row: 1, rows: 10 }); + let view = f.host.render_view("openidea.csv-editor", "table", viewport, inv.clone()).unwrap(); + let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") }; + assert_eq!((t.columns.clone(), t.total_rows, t.first_row), (vec!["name".to_owned(), "city".into()], 2, 1)); + assert_eq!(t.rows, [vec!["Bob".to_owned(), "Oslo".into()]]); + assert_eq!(view.status.as_deref(), Some("2 rows × 2 columns, delimiter ','")); + + let cell = ide_plugin::ViewEvent::Cell { row: 0, column: 1, value: "Paris, FR".into() }; + f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap(); + assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n"); + + // Settings: without a header row every record is a row. + let mut values = serde_json::Map::new(); + values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false)); + f.workspace.settings.lock().insert("openidea.csv-editor".into(), values); + let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap(); + let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") }; + assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3)); + + // Undeclared views are refused before any code runs. + let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound); +} + +/// Plan P7 acceptance: the sample plugin makes a tool window with a tree view. +/// Its menus, shortcut and status bar item are declarations, listed before +/// any code runs; the tree is rendered by the code, which loads on first show. +#[test] +fn sample_plugin_tool_window_with_a_tree() { + let f = fixture(Limits::default()); + let info = f.install(&sample("hello")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + for point in ["menus", "keymaps", "statusBar", "views"] { + assert_eq!(f.host.ui_items(point).len(), 1, "{point}"); + } + let view = f.host.ui_items("views").remove(0); + assert_eq!((view.key.as_str(), view.decl["location"].as_str()), ("greetings", Some("right"))); + assert_eq!(f.status("hello"), PluginStatus::Inactive, "declarations need no code"); + + let tree = |f: &Fixture| match f.host.render_view("hello", "greetings", None, f.inv()).unwrap().body { + ide_plugin::ViewBody::Tree(items) => items, + other => panic!("{other:?}"), + }; + assert_eq!(tree(&f).iter().map(|i| i.label.as_str()).collect::>(), ["English", "Deutsch", "日本語"]); + assert_eq!(f.status("hello"), PluginStatus::Active, "activated on show (on-view)"); + f.take_events(); + f.host.view_event("hello", "greetings", ide_plugin::ViewEvent::Expand { key: "English".into() }, f.inv()).unwrap(); + let refreshed = f.take_events().into_iter().any(|(_, e)| { + matches!(e, PluginEvent::ViewChanged { ref plugin_id, ref view } if plugin_id == "hello" && view == "greetings") + }); + assert!(refreshed, "the plugin asked for a refresh"); + let items = tree(&f); + assert_eq!(items.len(), 6); + assert_eq!(items[1].parent.as_deref(), Some("English")); + f.host + .view_event("hello", "greetings", ide_plugin::ViewEvent::Activate { key: "English/Hi".into() }, f.inv()) + .unwrap(); + assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::Notification { title, .. } if title == "Hi"))); + + // Disabling withdraws every declaration. + f.host.set_enabled("hello", false).unwrap(); + for point in ["menus", "keymaps", "statusBar", "views"] { + assert!(f.host.ui_items(point).is_empty(), "{point}"); + } +} + +/// Plan P7, Kubernetes: the cluster tree through `kubectl` (pinned), logs in +/// the terminal of the invoking window. +#[test] +fn official_kubernetes_browses_the_cluster_and_opens_logs() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let bin = f.plugins_dir.parent().unwrap().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\":\"default\"}}]}' ;;\n\ + *'get pods'*) echo '{\"items\":[{\"metadata\":{\"name\":\"web-1\"},\"status\":{\"phase\":\"Running\",\"containerStatuses\":[{\"ready\":true}]}}]}' ;;\n\ + *) echo '{\"items\":[]}' ;;\nesac\n", + ) + .unwrap(); + std::fs::set_permissions(bin.join("kubectl"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + let info = f.install(&official("kubernetes")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let ev = |e: ide_plugin::ViewEvent| f.host.view_event("openidea.kubernetes", "cluster", e, f.inv()).unwrap(); + ev(ide_plugin::ViewEvent::Expand { key: "default".into() }); + ev(ide_plugin::ViewEvent::Expand { key: "default/pods".into() }); + let view = f.host.render_view("openidea.kubernetes", "cluster", None, f.inv()).unwrap(); + let ide_plugin::ViewBody::Tree(items) = &view.body else { panic!("{view:?}") }; + let pod = items.iter().find(|i| i.key == "default/pods/web-1").expect("the pod"); + assert_eq!(pod.detail.as_deref(), Some("Running 1/1")); + f.take_events(); + ev(ide_plugin::ViewEvent::Activate { key: "default/pods/web-1".into() }); + let terminal = f.take_events().into_iter().find_map(|(client, e)| match e { + PluginEvent::OpenTerminal { title, program, args, .. } => Some((client, title, program, args)), + _ => None, + }); + let (client, title, program, args) = terminal.expect("a terminal"); + assert_eq!((client.as_deref(), title.as_str()), (Some("main"), "logs: web-1")); + assert_eq!(program.0, bin.join("kubectl").canonicalize().unwrap()); + assert_eq!(args, ["logs", "-f", "--tail=200", "-n", "default", "web-1"]); +} diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs new file mode 100644 index 000000000..0b5ac11ce --- /dev/null +++ b/crates/ide-host/tests/remote_plugins.rs @@ -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>, + plugin_events: Mutex>, + secret_requests: Mutex>, +} + +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 { + 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 { + 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 = 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 ''; exit 0; }\n\ + echo ''\n\ + echo ''\n\ + echo ''\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(); +} diff --git a/crates/ide-language/Cargo.toml b/crates/ide-language/Cargo.toml index b830b706e..4bda809a6 100644 --- a/crates/ide-language/Cargo.toml +++ b/crates/ide-language/Cargo.toml @@ -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 diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index bd4fa6d59..ca544edfc 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -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/.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, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -157,6 +164,41 @@ pub struct DebuggerConfig { pub connect: Option, #[serde(default)] pub install_hint: Option, + /// Set on plugin languages: start exactly this approved program. + #[serde(skip)] + pub pinned: Option, +} + +/// 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) -> std::io::Result { + 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 { diff --git a/crates/ide-language/src/plugin_point.rs b/crates/ide-language/src/plugin_point.rs new file mode 100644 index 000000000..1aa4cbe56 --- /dev/null +++ b/crates/ide-language/src/plugin_point.rs @@ -0,0 +1,313 @@ +//! The `languages` extension point: language definitions contributed by +//! plugins, in the same format as `languages/.toml`. +//! +//! ```toml +//! [[contributes.languages]] +//! path = "languages/solidity.toml" +//! ``` +//! +//! A definition that starts programs (`language-servers.command`, +//! `debugger.command`) needs `process:` 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 + Send + Sync; + +/// Languages of enabled plugins, in commit order. +#[derive(Default)] +pub struct PluginLanguages { + items: RwLock>, + seq: AtomicU64, + revision: AtomicU64, + policy: RwLock>>, +} + +impl PluginLanguages { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn all(&self) -> Vec { + 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 { + 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) { + *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 { + 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, 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, +} + +impl LanguagesPoint { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + /// A `languages/.toml`-style file in the package. + path: Option, + /// The same, inline (runtime `register-language`). + definition: Option, +} + +/// Parses and checks one language definition. +pub fn parse_definition(src: &str) -> Result { + 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 { + let mut caps: Vec = 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 { + 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")); + } +} diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 34caf9517..832645955 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -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, - /// Lookup tables of the definitions with the user's file types applied. - tables: RwLock>, + /// Built-in definitions and the user's `languages/*.toml`. + base: Vec, + /// Languages of enabled plugins, shared by every fork; `base` wins on id clashes. + plugins: OnceLock>, + state: RwLock>, file_types: RwLock, } +/// The effective definitions and their lookup tables (the user's file types applied). +struct State { + languages: Vec, + 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) { + 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 { + 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) -> 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 = + 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 { - self.languages.clone() + self.state().languages.clone() } pub fn get(&self, id: &LanguageId) -> Option { - 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 { - 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() diff --git a/crates/ide-language/src/syntax/cache.rs b/crates/ide-language/src/syntax/cache.rs index 038ace1cb..393eb7529 100644 --- a/crates/ide-language/src/syntax/cache.rs +++ b/crates/ide-language/src/syntax/cache.rs @@ -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)) }, )) } diff --git a/crates/ide-language/src/syntax/external.rs b/crates/ide-language/src/syntax/external.rs new file mode 100644 index 000000000..3d91a14a8 --- /dev/null +++ b/crates/ide-language/src/syntax/external.rs @@ -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, + pub kind: String, + pub start: u32, + pub end: u32, + pub name_start: u32, + pub name_end: u32, + pub parent: Option, +} + +#[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>; + fn outline(&self, lang: &LanguageId, text: &str) -> Option>; + fn folding(&self, lang: &LanguageId, text: &str) -> Option>; +} + +static PROVIDER: RwLock>> = RwLock::new(None); + +/// Installs (or removes) the provider every project's syntax cache consults. +pub fn set_external_syntax(provider: Option>) { + *PROVIDER.write() = provider; +} + +fn provider() -> Option> { + 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> { + 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> { + 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> = Vec::with_capacity(symbols.len()); + let mut parents: Vec> = 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> { + let folds = provider()?.folding(lang, text)?; + let last_line = LineIndex::new(text).line_of(text.len()) as u32; + let mut out: Vec = 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> { + (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> { + 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> { + (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::>(), + [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); + } +} diff --git a/crates/ide-language/src/syntax/highlight.rs b/crates/ide-language/src/syntax/highlight.rs index 07c942492..fc43637b4 100644 --- a/crates/ide-language/src/syntax/highlight.rs +++ b/crates/ide-language/src/syntax/highlight.rs @@ -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::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. diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index e961eb352..8c0c9bf24 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -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}; diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 5c7d53c01..c8f68f0e0 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -96,8 +96,12 @@ impl Launcher for ProcessLauncher { cancel: &CancellationToken, ) -> Result<(Peer, Option), 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() } } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 5a600ec83..fef3fb364 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -228,6 +228,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { max_java: None, launch_error: None, tsserver_project: false, + pinned: None, } } diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 7ed98d3bf..3bd922754 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -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) + 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>>, + thread: Mutex>>, + cancel: Arc, + alive: Arc, + /// The trap that ended the actor, until someone takes it to report it. + crash: Arc>>, + busy: Arc, + /// Milliseconds since `epoch` at the last call's end. + last_used: Arc, + epoch: Instant, +} + +impl Actor { + /// Moves an activated runtime onto its own thread. + pub fn spawn(name: &str, runtime: Runtime) -> std::io::Result { + let (tx, rx) = mpsc::channel::(); + 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( + &self, + wait: Option, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + ) -> Result { + 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( + &self, + cancel: &ide_core::CancellationToken, + grace: Duration, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + ) -> Result { + 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 = 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( + &self, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + done: impl FnOnce(Result) + 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 { + 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); + } +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index dc6a3ed4e..bebcad554 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -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/plugin@0.1.0` 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: `//` (installed) and `/.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, - #[serde(default)] - theme: Option, -} - -#[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, -} - -struct Meta { - status: PluginStatus, - error: Option, -} - -struct LoadedPlugin { - manifest: PluginManifest, - dir: PathBuf, - source: PluginSource, - /// Project that owns a dev plugin. - dev_root: Option, - /// Manifest contributions, restored on every (re)start. - static_regs: Registrations, - registrations: Arc>, - meta: Mutex, - runtime: Mutex>, -} - -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 bool + Send + Sync>; - -pub struct PluginHost { - project_policy: RwLock, - engine: PluginEngine, - plugins_dir: PathBuf, - sink: Arc, - limits: Limits, - plugins: RwLock>>, - state: Mutex, - /// client (window) → path of its focused editor. - active_files: Arc>>, - /// 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, Condvar), -} - -impl PluginHost { - /// Creates the host; call [`load_all`](Self::load_all) to load installed plugins. - pub fn new(plugins_dir: PathBuf, sink: Arc, limits: Limits) -> IdeResult { - 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 `/.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 = 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 = 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 { - 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 { - 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 { - 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 { - 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> { - 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) { - 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> { - 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 { - 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 { - 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 { - 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> { - 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) -> 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) { - *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 { - let mut out: Vec = 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 { - let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; - let mut dirs: Vec = 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 { - 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; diff --git a/crates/ide-plugin/src/manifest.rs b/crates/ide-plugin/src/manifest.rs deleted file mode 100644 index c32ac6cad..000000000 --- a/crates/ide-plugin/src/manifest.rs +++ /dev/null @@ -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, - #[serde(default)] - pub homepage: Option, - /// Release notes of this version (Settings › Plugins › What's New). - #[serde(default)] - pub change_notes: Option, - pub openidea_api: String, - /// Component file relative to the package directory; absent for - /// contribution-only plugins (themes, languages). - #[serde(default)] - pub wasm: Option, - #[serde(default)] - pub permissions: Vec, - #[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, - #[serde(default)] - pub languages: Vec, - #[serde(default)] - pub themes: Vec, -} - -#[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, -} - -/// 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, -} - -impl PluginManifest { - pub fn parse(src: &str) -> Result { - let manifest: Self = toml::from_str(src).map_err(|e| e.to_string())?; - manifest.validate()?; - Ok(manifest) - } - - pub fn load(dir: &Path) -> Result { - 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")); - } -} diff --git a/crates/ide-plugin/src/package.rs b/crates/ide-plugin/src/package.rs deleted file mode 100644 index cefb3fa90..000000000 --- a/crates/ide-plugin/src/package.rs +++ /dev/null @@ -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 { - 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 { - 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) -} diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 83962044f..8a8d10c4b 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -1,41 +1,120 @@ -//! Wasmtime glue: one shared engine, one `Store` per plugin, host imports. +//! Wasmtime glue for the `openidea:plugin@1.0.0` world (`wit/`): one shared +//! engine, one `Store` per plugin. Everything a call may do outside the +//! sandbox goes through [`HostApi`], implemented by the host; the imports +//! themselves are in `wit_host`. -use crate::manifest::{API_VERSION, CommandDecl, Permission, validate_command_id}; -use crate::sandbox; -use crate::theme::ThemeDef; -use crate::types::{Invocation, Limits, NotificationLevel, PluginEvent, PluginEventSink}; -use ide_language::LanguageConfig; +use crate::types::{CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument}; +use crate::types::{ + EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, + RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent, +}; +use crate::types::{ + FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, +}; +use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings}; +use ide_core::CancellationToken; +use ide_extension::Capability; use parking_lot::Mutex; use std::path::Path; use std::sync::Arc; -use std::time::Duration; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; -use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder}; +use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDeadline}; use wasmtime_wasi::p2::pipe::MemoryOutputPipe; use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; -mod bindings { - wasmtime::component::bindgen!({ - world: "plugin", - path: "../../wit", - imports: { default: trappable }, - }); +use bindings::openidea::plugin::events as wit_events; +use bindings::openidea::plugin::types as wit_types; +use providers_bindings::exports::openidea::plugin::providers as wit_prov; +use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax; +use views_bindings::exports::openidea::plugin::view_provider as wit_view; +use views_bindings::openidea::plugin::view_types as wit_vt; + +/// The API version of the world above. +pub const API_VERSION: &str = "1.0"; +const EPOCH_TICK: Duration = Duration::from_millis(10); +pub(crate) const MAX_TEXT: usize = 1 << 20; +pub(crate) const MAX_TITLE: usize = 256; +const STDERR_CAPACITY: usize = 256 << 10; +/// Message of a call interrupted through [`Runtime::cancel_handle`]. +pub const CANCELLED: &str = "was cancelled"; + +/// What plugin code may ask of the IDE, per plugin. Implemented by the host, +/// which routes notifications and status items to windows, edits to open +/// documents and runtime registrations through the extension registry. +/// Capabilities are already checked when these are called. +pub trait HostApi: Send + Sync { + fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String); + /// Sets (`Some`) or removes the plugin's status bar item `id`. + fn status_item(&self, id: &str, item: Option); + /// Registers a declaration with an extension point at runtime + /// (`commands`, `themes`, `languages`). Returns whether anything changed. + fn contribute(&self, point: &str, decl: toml::Value) -> Result; + /// Roots of the open projects. + fn projects(&self) -> Vec; + /// Unsaved text of an open editor's file. + fn buffer_text(&self, path: &Path) -> Option; + /// 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`. + fn apply_edits( + &self, + client: Option<&str>, + path: &Path, + version: u64, + edits: Vec, + ) -> Result; + fn storage_get(&self, key: &str) -> Result, HostError>; + /// Sets, or (`None`) deletes. + fn storage_set(&self, key: &str, value: Option) -> Result<(), HostError>; + fn storage_keys(&self, prefix: &str) -> Result, HostError>; + fn vcs_status(&self, root: &Path) -> Result, HostError>; + fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>; + /// Subscribes to (or unsubscribes from) a kind of workbench event. + /// Project files matching `glob` (root-relative), at most `limit`. + fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result, HostError>; + /// Replaces the plugin's diagnostics for `path`. + fn publish_diagnostics(&self, path: &Path, items: Vec) -> Result<(), HostError>; + /// Performs `request` (its host is granted), filling in secret placeholders. + fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result; + fn secret_is_set(&self, name: &str) -> bool; + /// Runs the approved file of `process:`. + fn run_process( + &self, + program: &str, + args: Vec, + cwd: Option<&Path>, + stdin: Option, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result; + fn subscribe(&self, kind: EventKind, on: bool); + /// Starts a progress indicator; cancelling it from the UI cancels `cancel`. + fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64; + fn task_report(&self, id: u64, done: u32, total: Option, message: Option); + fn task_cancelled(&self, id: u64) -> bool; + fn task_end(&self, id: u64); + /// The plugin's view (or custom editors) `view` must be rendered again. + fn refresh_view(&self, view: &str); + /// A message for the script of the `html` body of `view`. + fn post_view_message(&self, view: &str, message: String) -> Result<(), HostError>; + /// Opens a terminal in `client`'s window running the approved file of + /// `process:` in `cwd`. + fn open_terminal( + &self, + client: Option<&str>, + title: String, + program: &str, + args: Vec, + cwd: Option<&Path>, + ) -> Result<(), HostError>; + /// A setting of the plugin as JSON (project over global over the schema default). + fn setting(&self, root: Option<&Path>, key: &str) -> Option; } -use bindings::openidea::plugin::host::{self as wit_host}; -use bindings::openidea::plugin::types::{self as wit_types, LogLevel}; - -/// client → path of its focused editor. -pub(crate) type ActiveFileFn = Arc) -> Option + Send + Sync>; - -const EPOCH_TICK: Duration = Duration::from_millis(10); -const MAX_TEXT: usize = 1 << 20; -const MAX_TITLE: usize = 256; -const MAX_COMMANDS: usize = 256; -const STDERR_CAPACITY: usize = 256 << 10; - /// Shared compilation engine plus an epoch ticker thread for wall-clock limits. -pub(crate) struct PluginEngine { +pub struct PluginEngine { engine: Engine, linker: Linker, } @@ -65,73 +144,141 @@ impl PluginEngine { pub fn compile(&self, wasm: &Path) -> Result { let bytes = std::fs::read(wasm).map_err(|e| format!("{}: {e}", wasm.display()))?; - Component::new(&self.engine, &bytes) - .map_err(|e| format!("{}: not a valid plugin component: {e}", wasm.display())) + self.compile_bytes(&bytes).map_err(|e| format!("{}: {e}", wasm.display())) } -} -/// What a plugin contributed (manifest files + runtime registrations). -#[derive(Default, Clone)] -pub(crate) struct Registrations { - pub commands: Vec, - pub languages: Vec, - pub themes: Vec, -} + pub fn compile_bytes(&self, bytes: &[u8]) -> Result { + Component::new(&self.engine, bytes).map_err(|e| format!("not a valid plugin component: {e}")) + } -impl Registrations { - pub fn add_command(&mut self, cmd: CommandDecl) -> bool { - match self.commands.iter_mut().find(|c| c.id == cmd.id) { - Some(existing) if *existing == cmd => false, - Some(existing) => { - *existing = cmd; - true - } - None => { - self.commands.push(cmd); - true + /// Compiles `wasm`, or loads it precompiled from `cache_dir` (docs/plugin_plan.md + /// 4.5): the key is the module's hash plus the engine's compatibility hash, + /// so an engine upgrade simply misses and recompiles. Cache failures only + /// cost the compilation. + pub fn compile_cached(&self, wasm: &Path, cache_dir: &Path) -> Result { + let bytes = std::fs::read(wasm).map_err(|e| format!("{}: {e}", wasm.display()))?; + let file = cache_dir.join(format!("{}.cwasm", self.cache_key(&bytes))); + if file.is_file() { + // SAFETY: the cache directory belongs to the IDE (inside its plugins + // directory) and is written only by `store_precompiled` below from + // components this engine compiled; a corrupt file fails validation. + match unsafe { Component::deserialize_file(&self.engine, &file) } { + Ok(component) => return Ok(component), + Err(e) => { + tracing::info!("discarding precompiled {}: {e}", file.display()); + let _ = std::fs::remove_file(&file); + } } } + let component = self.compile_bytes(&bytes).map_err(|e| format!("{}: {e}", wasm.display()))?; + store_precompiled(&component, &file); + Ok(component) } - pub fn add_language(&mut self, cfg: LanguageConfig) { - self.languages.retain(|l| l.id != cfg.id); - self.languages.push(cfg); + /// Fills the cache for `wasm` if needed (background warm-up). Returns the + /// cache file name, to keep it when pruning. + pub fn precompile(&self, wasm: &Path, cache_dir: &Path) -> Result { + let bytes = std::fs::read(wasm).map_err(|e| format!("{}: {e}", wasm.display()))?; + let name = format!("{}.cwasm", self.cache_key(&bytes)); + let file = cache_dir.join(&name); + if !file.is_file() { + let component = self.compile_bytes(&bytes)?; + store_precompiled(&component, &file); + } + Ok(name) } - pub fn add_theme(&mut self, theme: ThemeDef) { - self.themes.retain(|t| t.id != theme.id); - self.themes.push(theme); + fn cache_key(&self, bytes: &[u8]) -> String { + use std::hash::{Hash, Hasher}; + let mut engine = std::collections::hash_map::DefaultHasher::new(); + self.engine.precompile_compatibility_hash().hash(&mut engine); + let module = blake3::hash(bytes).to_hex(); + format!("{}-{:016x}", &module[..32], engine.finish()) + } + + pub fn engine(&self) -> &Engine { + &self.engine + } + + /// Interfaces a component imports (`openidea:plugin/host@0.1.0`, `wasi:…`). + pub fn imports(&self, component: &Component) -> Vec { + component.component_type().imports(&self.engine).map(|(name, _)| name.to_owned()).collect() } } -pub(crate) fn parse_language(src: &str) -> Result { - let cfg: LanguageConfig = toml::from_str(src).map_err(|e| format!("invalid language definition: {e}"))?; - if cfg.id.as_str().is_empty() || cfg.name.trim().is_empty() { - return Err("language `id` and `name` must not be empty".into()); +fn store_precompiled(component: &Component, file: &Path) { + let Ok(bytes) = component.serialize() else { return }; + if let Some(dir) = file.parent() { + let _ = std::fs::create_dir_all(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); } - Ok(cfg) } -/// Side effects of one call the host must act on afterwards. +/// Side effects of one call the host acts on afterwards. +#[derive(Default, Debug)] +pub struct CallEffects { + /// A runtime registration changed contributions. + pub contributions_changed: bool, +} + +/// Interrupts the running call: the epoch callback traps on `flag`, host +/// operations (a fetch) watch `token`. Reset when a call starts. #[derive(Default)] -pub(crate) struct CallEffects { - pub commands_changed: bool, - pub themes_changed: Vec, +pub struct CallCancel { + flag: AtomicBool, + token: Mutex, } -pub(crate) struct StoreData { +impl CallCancel { + pub fn cancel(&self) { + self.flag.store(true, Ordering::Release); + self.token.lock().cancel(); + } + + /// Cooperative cancellation: host operations (`net`, `process`, fetch) + /// return `cancelled` and tasks report it; the guest keeps its instance. + pub fn cancel_soft(&self) { + self.token.lock().cancel(); + } + + pub(crate) fn token(&self) -> CancellationToken { + self.token.lock().clone() + } + + fn is_set(&self) -> bool { + self.flag.load(Ordering::Acquire) + } + + fn reset(&self) { + self.flag.store(false, Ordering::Release); + *self.token.lock() = CancellationToken::new(); + } +} + +pub struct StoreData { + /// The running call is interrupted at the next epoch tick past this. + pub(crate) deadline: Instant, + /// Wall-clock limit of the running call (for the message). + pub(crate) timeout: Duration, + pub(crate) call_started: Instant, + pub(crate) cancel: Arc, + pub(crate) limits: Limits, wasi: WasiCtx, - table: ResourceTable, - limits: StoreLimits, + pub(crate) table: ResourceTable, + store_limits: StoreLimits, stderr: MemoryOutputPipe, stderr_seen: usize, - plugin_id: String, - permissions: Vec, - registrations: Arc>, - sink: Arc, - invocation: Invocation, - active_file: ActiveFileFn, - effects: CallEffects, + pub(crate) plugin_id: String, + pub(crate) granted: Vec, + pub(crate) host: Arc, + pub(crate) invocation: Invocation, + pub(crate) effects: CallEffects, + /// Tasks begun by the running call and not ended yet. + pub(crate) open_tasks: Vec, + pub(crate) last_report: Option, } impl WasiView for StoreData { @@ -141,21 +288,6 @@ impl WasiView for StoreData { } impl StoreData { - fn require(&self, p: Permission) -> wasmtime::Result<()> { - if self.permissions.contains(&p) { - Ok(()) - } else { - Err(wasmtime::Error::msg(format!( - "permission denied: add \"{}\" to `permissions` in plugin.toml", - p.as_str() - ))) - } - } - - fn emit(&self, event: PluginEvent) { - self.sink.emit(self.invocation.client.as_deref(), event); - } - /// New guest stderr output since the last call (panic messages, eprintln!). fn drain_stderr(&mut self) -> String { let all = self.stderr.contents(); @@ -165,7 +297,7 @@ impl StoreData { } } -fn clip(mut s: String, max: usize) -> String { +pub(crate) fn clip(mut s: String, max: usize) -> String { if s.len() > max { let mut end = max; while !s.is_char_boundary(end) { @@ -176,170 +308,330 @@ fn clip(mut s: String, max: usize) -> String { s } -impl wit_types::Host for StoreData {} - -impl wit_host::Host for StoreData { - fn log(&mut self, level: LogLevel, message: String) -> wasmtime::Result<()> { - let id = &self.plugin_id; - let message = clip(message, MAX_TEXT); - match level { - LogLevel::Trace => tracing::trace!(plugin = %id, "{message}"), - LogLevel::Debug => tracing::debug!(plugin = %id, "{message}"), - LogLevel::Info => tracing::info!(plugin = %id, "{message}"), - LogLevel::Warn => tracing::warn!(plugin = %id, "{message}"), - LogLevel::Error => tracing::error!(plugin = %id, "{message}"), - } - Ok(()) - } - - fn register_command(&mut self, id: String, title: String, category: Option) -> wasmtime::Result<()> { - validate_command_id(&id).map_err(wasmtime::Error::msg)?; - let title = clip(title, MAX_TITLE); - if title.trim().is_empty() { - return Err(wasmtime::Error::msg(format!("command {id:?} has an empty title"))); - } - let cmd = CommandDecl { id, title, category: category.map(|c| clip(c, MAX_TITLE)).filter(|c| !c.is_empty()) }; - let mut regs = self.registrations.lock(); - if regs.commands.len() >= MAX_COMMANDS && !regs.commands.iter().any(|c| c.id == cmd.id) { - return Err(wasmtime::Error::msg(format!("too many commands (max {MAX_COMMANDS})"))); - } - if regs.add_command(cmd) { - self.effects.commands_changed = true; - } - Ok(()) - } - - fn show_notification( - &mut self, - level: wit_types::NotificationLevel, - title: String, - body: String, - ) -> wasmtime::Result<()> { - let level = match level { - wit_types::NotificationLevel::Info => NotificationLevel::Info, - wit_types::NotificationLevel::Warning => NotificationLevel::Warning, - wit_types::NotificationLevel::Error => NotificationLevel::Error, - }; - self.emit(PluginEvent::Notification { - plugin_id: self.plugin_id.clone(), - level, - title: clip(title, MAX_TITLE), - body: clip(body, 16 << 10), - }); - Ok(()) - } - - fn workspace_root(&mut self) -> wasmtime::Result> { - self.require(Permission::ProjectRead)?; - Ok(self.invocation.workspace_root.as_ref().map(|p| p.to_string_lossy().into_owned())) - } - - fn active_file(&mut self) -> wasmtime::Result> { - self.require(Permission::EditorRead)?; - Ok((self.active_file)(self.invocation.client.as_deref())) - } - - fn read_file(&mut self, path: String) -> wasmtime::Result> { - self.require(Permission::ProjectRead)?; - let Some(root) = self.invocation.workspace_root.clone() else { - return Ok(Err("no project is open".into())); - }; - let real = match sandbox::resolve_in_root(&root, &path) { - Ok(real) => real, - Err(e) => return Ok(Err(e)), - }; - if let Some(text) = self.invocation.buffer_text.as_ref().and_then(|f| f(&real)) { - return Ok(Ok(text)); - } - Ok(sandbox::read_text(&real)) - } - - fn insert_text(&mut self, text: String) -> wasmtime::Result<()> { - self.require(Permission::EditorWrite)?; - self.emit(PluginEvent::InsertText { plugin_id: self.plugin_id.clone(), text: clip(text, MAX_TEXT) }); - Ok(()) - } - - fn replace_selection(&mut self, text: String) -> wasmtime::Result<()> { - self.require(Permission::EditorWrite)?; - self.emit(PluginEvent::ReplaceSelection { plugin_id: self.plugin_id.clone(), text: clip(text, MAX_TEXT) }); - Ok(()) - } - - fn register_language(&mut self, definition: String) -> wasmtime::Result> { - Ok(parse_language(&definition).map(|cfg| { - self.registrations.lock().add_language(cfg); - self.effects.commands_changed = true; - })) - } - - fn register_theme(&mut self, definition: String) -> wasmtime::Result> { - Ok(ThemeDef::parse(&definition).map(|theme| { - self.effects.themes_changed.push(theme.id.clone()); - self.registrations.lock().add_theme(theme); - self.effects.commands_changed = true; - })) - } -} - /// A call failed: either the plugin returned an error (it stays usable) or it /// trapped / ran out of budget (the instance is poisoned and must be dropped). #[derive(Debug)] -pub(crate) enum CallError { +pub enum CallError { Guest(String), Trap(String), } -pub(crate) struct RuntimeSetup { +pub struct RuntimeSetup { pub plugin_id: String, pub version: String, - pub permissions: Vec, - pub registrations: Arc>, - pub sink: Arc, - pub active_file: ActiveFileFn, + /// Capabilities granted to the plugin. + pub granted: Vec, + pub host: Arc, pub limits: Limits, } -pub(crate) struct Runtime { +pub struct Runtime { store: Store, - plugin: bindings::Plugin, + exports: Exports, limits: Limits, version: String, } +/// What the component exports: the `plugin` world, plus optional interfaces. +struct Exports { + plugin: bindings::Plugin, + syntax: Option, + providers: Option, + views: Option, +} + +/// Most results one syntax call may return (more are dropped). +const MAX_SYNTAX_ITEMS: usize = 200_000; +/// Most events per delivered batch. +const MAX_BATCH: usize = 1000; + +fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event { + use crate::types::ActionSource as S; + match e { + WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p), + WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p), + WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p), + WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p), + WorkbenchEvent::Scheduled => wit_events::Event::Scheduled, + WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed { + action: a.action, + title: a.title, + source: match a.source { + S::Keyboard => wit_events::ActionSource::Keyboard, + S::Menu => wit_events::ActionSource::Menu, + S::Toolbar => wit_events::ActionSource::Toolbar, + S::ContextMenu => wit_events::ActionSource::ContextMenu, + S::Search => wit_events::ActionSource::Search, + S::Other => wit_events::ActionSource::Other, + }, + shortcut: a.shortcut, + }), + } +} + impl Runtime { pub fn instantiate(engine: &PluginEngine, component: &Component, setup: RuntimeSetup) -> Result { let stderr = MemoryOutputPipe::new(STDERR_CAPACITY); // No preopened dirs, env, args or network: host imports are the only way out. let wasi = WasiCtx::builder().stderr(stderr.clone()).build(); - let limits = StoreLimitsBuilder::new() + let store_limits = StoreLimitsBuilder::new() .memory_size(setup.limits.memory_bytes) .instances(32) .tables(64) .memories(8) .trap_on_grow_failure(true) .build(); + let now = Instant::now(); let data = StoreData { + deadline: now + setup.limits.timeout, + timeout: setup.limits.timeout, + call_started: now, + cancel: Arc::default(), + limits: setup.limits, wasi, table: ResourceTable::new(), - limits, + store_limits, stderr, stderr_seen: 0, plugin_id: setup.plugin_id, - permissions: setup.permissions, - registrations: setup.registrations, - sink: setup.sink, + granted: setup.granted, + host: setup.host, invocation: Invocation::default(), - active_file: setup.active_file, effects: CallEffects::default(), + open_tasks: Vec::new(), + last_report: None, }; let mut store = Store::new(&engine.engine, data); - store.limiter(|d| &mut d.limits); - store.epoch_deadline_trap(); - arm(&mut store, &setup.limits).map_err(|e| e.to_string())?; - let plugin = bindings::Plugin::instantiate(&mut store, component, &engine.linker) - .map_err(|e| describe_trap(&e, &setup.limits))?; - Ok(Self { store, plugin, limits: setup.limits, version: setup.version }) + store.limiter(|d| &mut d.store_limits); + // Checked every epoch tick: the per-call deadline and cancellation. + store.epoch_deadline_callback(|ctx| { + let data = ctx.data(); + if data.cancel.is_set() { + Err(wasmtime::Error::msg(CANCELLED)) + } else if Instant::now() >= data.deadline { + Err(wasmtime::Error::msg(format!("timed out after {:?}", data.timeout))) + } else { + Ok(UpdateDeadline::Continue(1)) + } + }); + arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?; + let api_hint = |e: wasmtime::Error| { + let msg = describe_trap(&e, &setup.limits); + if msg.contains("openidea:plugin/") { + format!("{msg} (the component was built for a different plugin API; this IDE runs API {API_VERSION})") + } else { + msg + } + }; + let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?; + let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?; + let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok(); + let provider_indices = wit_prov::GuestIndices::new(&pre).ok(); + let view_indices = wit_view::GuestIndices::new(&pre).ok(); + let instance = pre.instantiate(&mut store).map_err(api_hint)?; + let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?; + let syntax = match syntax_indices { + Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; + let providers = match provider_indices { + Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; + let views = match view_indices { + Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; + Ok(Self { + store, + exports: Exports { plugin, syntax, providers, views }, + limits: setup.limits, + version: setup.version, + }) + } + + /// The component exports `syntax-provider`. + pub fn has_syntax(&self) -> bool { + self.exports.syntax.is_some() + } + + pub fn syntax_highlights( + &mut self, + language: &str, + text: &str, + timeout: Duration, + ) -> Result, CallError> { + let tokens = self.syntax_call(timeout, |s, store| s.call_highlights(store, language, text))?; + Ok(tokens + .into_iter() + .take(MAX_SYNTAX_ITEMS) + .map(|t| SyntaxToken { start: t.start, end: t.end, kind: clip(t.kind, 32) }) + .collect()) + } + + pub fn syntax_outline( + &mut self, + language: &str, + text: &str, + timeout: Duration, + ) -> Result, CallError> { + let symbols = self.syntax_call(timeout, |s, store| s.call_outline(store, language, text))?; + Ok(symbols + .into_iter() + .take(MAX_SYNTAX_ITEMS) + .map(|s| SyntaxSymbol { + name: clip(s.name, MAX_TITLE), + detail: s.detail.map(|d| clip(d, MAX_TITLE)), + kind: clip(s.kind, 32), + start: s.start, + end: s.end, + name_start: s.name_start, + name_end: s.name_end, + parent: s.parent, + }) + .collect()) + } + + pub fn syntax_folding( + &mut self, + language: &str, + text: &str, + timeout: Duration, + ) -> Result, CallError> { + let folds = self.syntax_call(timeout, |s, store| s.call_folding(store, language, text))?; + Ok(folds + .into_iter() + .take(MAX_SYNTAX_ITEMS) + .map(|f| SyntaxFold { start_line: f.start_line, end_line: f.end_line, kind: clip(f.kind, 32) }) + .collect()) + } + + fn syntax_call( + &mut self, + timeout: Duration, + f: impl FnOnce(&wit_syntax::Guest, &mut Store) -> wasmtime::Result, + ) -> Result { + if self.exports.syntax.is_none() { + return Err(CallError::Guest("the plugin does not provide syntax".into())); + } + self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store)) + .map(|(r, _)| r) + } + + /// The component exports `providers`. + pub fn has_providers(&self) -> bool { + self.exports.providers.is_some() + } + + fn provider_call( + &mut self, + timeout: Duration, + f: impl FnOnce(&wit_prov::Guest, &mut Store) -> wasmtime::Result, + ) -> Result { + if self.exports.providers.is_none() { + return Err(CallError::Guest("the plugin does not export providers".into())); + } + self.call(Invocation::default(), timeout, |e, store| f(e.providers.as_ref().expect("checked"), store)) + .map(|(r, _)| r) + } + + pub fn provide_completion( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + trigger: Option<&str>, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let items = self.provider_call(timeout, |p, s| p.call_completion(s, &d, pos_to_wit(at), trigger))?; + Ok(items + .into_iter() + .take(MAX_PROVIDER_ITEMS) + .map(|i| CompletionItem { + label: clip(i.label, MAX_TITLE), + detail: i.detail.map(|d| clip(d, MAX_TITLE)), + kind: i.kind.map(|k| completion_kind(k).to_owned()), + insert_text: clip(i.insert_text, 64 << 10), + replace: i.replace.map(range_from_wit), + sort_text: i.sort_text.map(|t| clip(t, 64)), + documentation: i.documentation.map(|d| clip(d, 16 << 10)), + }) + .collect()) + } + + pub fn provide_inline_completion( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let s = self.provider_call(timeout, |p, s| p.call_inline_completion(s, &d, pos_to_wit(at)))?; + Ok(s.map(|s| InlineSuggestion { + insert_text: clip(s.insert_text, 64 << 10), + replace: s.replace.map(range_from_wit), + })) + } + + pub fn provide_code_actions( + &mut self, + doc: &ProviderDocument, + selection: ide_core::Range, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?; + Ok(actions + .into_iter() + .take(MAX_PROVIDER_ITEMS) + .map(|a| CodeAction { + title: clip(a.title, MAX_TITLE), + kind: a.kind.map(|k| clip(k, 64)), + preferred: a.preferred, + edits: a + .edits + .into_iter() + .map(|f| { + let edits = f + .edits + .into_iter() + .map(|e| ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text }) + .collect(); + (std::path::PathBuf::from(f.path), edits) + }) + .collect(), + }) + .collect()) + } + + pub fn provide_definition( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let locations = self.provider_call(timeout, |p, s| p.call_definition(s, &d, pos_to_wit(at)))?; + Ok(locations.into_iter().take(MAX_PROVIDER_ITEMS).map(location_from_wit).collect()) + } + + pub fn provide_line_markers( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let markers = self.provider_call(timeout, |p, s| p.call_line_markers(s, &d))?; + Ok(markers + .into_iter() + .take(MAX_PROVIDER_ITEMS) + .map(|m| LineMarker { + line: m.line, + icon: m.icon.map(|i| clip(i, 32)), + tooltip: clip(m.tooltip, 1024), + target: m.target.map(location_from_wit), + }) + .collect()) } pub fn activate(&mut self) -> Result { @@ -348,10 +640,12 @@ impl Runtime { plugin_id: data.plugin_id.clone(), plugin_version: self.version.clone(), api_version: API_VERSION.into(), - permissions: data.permissions.iter().map(|p| p.as_str().to_owned()).collect(), + capabilities: data.granted.iter().map(|c| c.to_string()).collect(), }; - self.call(Invocation::default(), |plugin, store| plugin.call_activate(store, &ctx)) - .and_then(|(r, fx)| r.map(|()| fx).map_err(CallError::Guest)) + self.call(Invocation::default(), self.limits.timeout, |e, store| { + e.plugin.openidea_plugin_lifecycle().call_activate(store, &ctx) + }) + .and_then(|(r, fx)| r.map(|()| fx).map_err(CallError::Guest)) } pub fn run_command( @@ -360,29 +654,123 @@ impl Runtime { arg: Option<&str>, invocation: Invocation, ) -> Result<(Option, CallEffects), CallError> { - let (result, fx) = self.call(invocation, |plugin, store| plugin.call_run_command(store, id, arg))?; + let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let r = e.plugin.openidea_plugin_commands().call_run( + &mut *store, + id, + wasmtime::component::Resource::new_borrow(ctx.rep()), + arg, + ); + store.data_mut().drop_invocation(ctx); + r + })?; result.map(|v| (v, fx)).map_err(CallError::Guest) } + /// The component exports `view-provider`. + pub fn has_views(&self) -> bool { + self.exports.views.is_some() + } + + /// Content of a view or custom editor (`invocation.editor`: its document). + pub fn render_view( + &mut self, + view: &str, + viewport: Option, + invocation: Invocation, + ) -> Result<(View, CallEffects), CallError> { + if self.exports.views.is_none() { + return Err(CallError::Guest("the plugin does not export view-provider".into())); + } + let viewport = viewport.map(|v| wit_vt::Viewport { first_row: v.first_row, rows: v.rows.min(MAX_TABLE_ROWS) }); + let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let views = e.views.as_ref().expect("checked"); + let r = + views.call_render(&mut *store, view, viewport, wasmtime::component::Resource::new_borrow(ctx.rep())); + store.data_mut().drop_invocation(ctx); + r + })?; + match result { + Ok(v) => Ok((view_from_wit(v), fx)), + Err(e) => Err(CallError::Guest(crate::wit_host::error_text(e))), + } + } + + /// A user action in a view or custom editor. + pub fn view_event( + &mut self, + view: &str, + event: ViewEvent, + invocation: Invocation, + ) -> Result { + if self.exports.views.is_none() { + return Err(CallError::Guest("the plugin does not export view-provider".into())); + } + let event = event_to_wit_view(event); + let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let views = e.views.as_ref().expect("checked"); + let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep())); + store.data_mut().drop_invocation(ctx); + r + })?; + result.map(|()| fx).map_err(|e| CallError::Guest(crate::wit_host::error_text(e))) + } + + /// Delivers a batch of events (from the window in `invocation`). + pub fn handle_events( + &mut self, + events: Vec, + invocation: Invocation, + ) -> Result { + let events: Vec = events.into_iter().take(MAX_BATCH).map(event_to_wit).collect(); + self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let r = e.plugin.openidea_plugin_event_handler().call_handle( + &mut *store, + &events, + wasmtime::component::Resource::new_borrow(ctx.rep()), + ); + store.data_mut().drop_invocation(ctx); + r + }) + .map(|((), fx)| fx) + } + /// Best effort; errors are logged. pub fn deactivate(&mut self) { if let Err(CallError::Trap(e) | CallError::Guest(e)) = - self.call(Invocation::default(), |plugin, store| plugin.call_deactivate(store)) + self.call(Invocation::default(), self.limits.timeout, |e, store| { + e.plugin.openidea_plugin_lifecycle().call_deactivate(store) + }) { tracing::warn!(plugin = %self.store.data().plugin_id, "deactivate failed: {e}"); } } + /// Interrupts the running call when cancelled (the instance is then + /// poisoned, like after a timeout). Reset when the next call starts. + pub fn cancel_handle(&self) -> Arc { + self.store.data().cancel.clone() + } + fn call( &mut self, invocation: Invocation, - f: impl FnOnce(&bindings::Plugin, &mut Store) -> wasmtime::Result, + timeout: Duration, + f: impl FnOnce(&Exports, &mut Store) -> wasmtime::Result, ) -> Result<(R, CallEffects), CallError> { - arm(&mut self.store, &self.limits).map_err(|e| CallError::Trap(e.to_string()))?; + arm(&mut self.store, &self.limits, timeout).map_err(|e| CallError::Trap(e.to_string()))?; self.store.data_mut().invocation = invocation; - let result = f(&self.plugin, &mut self.store); + let result = f(&self.exports, &mut self.store); let data = self.store.data_mut(); data.invocation = Invocation::default(); + // Tasks don't outlive the call that began them. + for id in std::mem::take(&mut data.open_tasks) { + data.host.task_end(id); + } let effects = std::mem::take(&mut data.effects); let stderr = data.drain_stderr(); if !stderr.trim().is_empty() { @@ -405,17 +793,78 @@ impl Runtime { } } -fn arm(store: &mut Store, limits: &Limits) -> wasmtime::Result<()> { +/// Most items one provider call may return (more are dropped). +const MAX_PROVIDER_ITEMS: usize = 10_000; + +fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position { + wit_types::Position { line: p.line, character: p.col } +} + +fn range_to_wit(r: ide_core::Range) -> wit_types::Range { + wit_types::Range { start: pos_to_wit(r.start), end: pos_to_wit(r.end) } +} + +fn range_from_wit(r: wit_types::Range) -> ide_core::Range { + ide_core::Range::new( + ide_core::Pos::new(r.start.line, r.start.character), + ide_core::Pos::new(r.end.line, r.end.character), + ) +} + +fn location_from_wit(l: wit_types::Location) -> Location { + Location { path: std::path::PathBuf::from(l.path), range: range_from_wit(l.range) } +} + +fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document { + wit_prov::Document { + path: d.path.to_string_lossy().into_owned(), + language: d.language.clone(), + version: d.version, + text: d.text.clone(), + } +} + +fn completion_kind(k: wit_prov::CompletionKind) -> &'static str { + use wit_prov::CompletionKind as K; + match k { + K::Text => "text", + K::Method => "method", + K::Function => "function", + K::Constructor => "constructor", + K::Field => "field", + K::Variable => "variable", + K::Class => "class", + K::Interface => "interface", + K::Module => "module", + K::Property => "property", + K::Keyword => "keyword", + K::Snippet => "snippet", + K::Value => "value", + K::Constant => "constant", + K::File => "file", + K::Reference => "reference", + } +} + +/// Resets the budgets for the next call: fuel, deadline, cancellation. +fn arm(store: &mut Store, limits: &Limits, timeout: Duration) -> wasmtime::Result<()> { store.set_fuel(limits.fuel)?; - let ticks = (limits.timeout.as_millis() / EPOCH_TICK.as_millis()).max(1) as u64; - store.set_epoch_deadline(ticks); + let data = store.data_mut(); + let now = Instant::now(); + data.call_started = now; + data.deadline = now + timeout; + data.timeout = timeout; + data.last_report = None; + data.cancel.reset(); + // The callback above runs every tick and decides. + store.set_epoch_deadline(1); Ok(()) } fn describe_trap(e: &wasmtime::Error, limits: &Limits) -> String { match e.downcast_ref::() { Some(wasmtime::Trap::OutOfFuel) => format!("exceeded its CPU budget ({} fuel per call)", limits.fuel), - Some(wasmtime::Trap::Interrupt) => format!("timed out after {:?}", limits.timeout), + Some(wasmtime::Trap::Interrupt) => CANCELLED.to_owned(), Some(wasmtime::Trap::UnreachableCodeReached) => "crashed (panic / unreachable)".to_owned(), Some(trap) => format!("trapped: {trap}"), None => { @@ -428,3 +877,110 @@ fn describe_trap(e: &wasmtime::Error, limits: &Limits) -> String { } } } + +/// Most table rows one render returns (and a viewport asks for). +const MAX_TABLE_ROWS: u32 = 2_000; +/// Most tree / list items and form fields per view. +const MAX_VIEW_ITEMS: usize = 20_000; +const MAX_CELL: usize = 64 << 10; +const MAX_HTML: usize = 4 << 20; + +fn opt_clip(s: Option, max: usize) -> Option { + s.map(|s| clip(s, max)) +} + +fn view_from_wit(v: wit_vt::View) -> View { + let body = match v.body { + wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)), + wit_vt::Body::Tree(items) => ViewBody::Tree( + items + .into_iter() + .take(MAX_VIEW_ITEMS) + .map(|i| ViewTreeItem { + key: clip(i.key, 1024), + parent: opt_clip(i.parent, 1024), + label: clip(i.label, MAX_TITLE), + icon: opt_clip(i.icon, 64), + detail: opt_clip(i.detail, MAX_TITLE), + expandable: i.expandable, + expanded: i.expanded, + }) + .collect(), + ), + wit_vt::Body::List(items) => ViewBody::List( + items + .into_iter() + .take(MAX_VIEW_ITEMS) + .map(|i| ViewListItem { + key: clip(i.key, 1024), + label: clip(i.label, MAX_TITLE), + icon: opt_clip(i.icon, 64), + detail: opt_clip(i.detail, MAX_TITLE), + }) + .collect(), + ), + wit_vt::Body::Form(fields) => ViewBody::Form( + fields + .into_iter() + .take(MAX_VIEW_ITEMS) + .map(|f| ViewField { + key: clip(f.key, 128), + label: clip(f.label, MAX_TITLE), + value: match f.value { + wit_vt::FieldValue::Text(t) => FieldValue::Text(clip(t, MAX_CELL)), + wit_vt::FieldValue::Multiline(t) => FieldValue::Multiline(clip(t, 1 << 20)), + wit_vt::FieldValue::Checkbox(b) => FieldValue::Checkbox(b), + wit_vt::FieldValue::Choice((options, selected)) => FieldValue::Choice { + options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(), + selected: clip(selected, MAX_TITLE), + }, + }, + }) + .collect(), + ), + wit_vt::Body::Table(t) => ViewBody::Table(ViewTable { + columns: t.columns.into_iter().take(1000).map(|c| clip(c, MAX_TITLE)).collect(), + total_rows: t.total_rows, + first_row: t.first_row, + rows: t + .rows + .into_iter() + .take(MAX_TABLE_ROWS as usize) + .map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect()) + .collect(), + editable: t.editable, + }), + wit_vt::Body::Html(h) => ViewBody::Html(clip(h, MAX_HTML)), + }; + View { + toolbar: v + .toolbar + .into_iter() + .take(32) + .map(|b| ViewButton { + id: clip(b.id, 64), + label: clip(b.label, MAX_TITLE), + icon: opt_clip(b.icon, 64), + enabled: b.enabled, + }) + .collect(), + body, + status: opt_clip(v.status, 1024), + } +} + +fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent { + use wit_vt::ViewEvent as W; + match e { + ViewEvent::Button { id } => W::Button(id), + ViewEvent::Activate { key } => W::Activate(key), + ViewEvent::Select { key } => W::Select(key), + ViewEvent::Expand { key } => W::Expand(key), + ViewEvent::Collapse { key } => W::Collapse(key), + ViewEvent::Field { key, value } => W::Field((key, value)), + ViewEvent::Cell { row, column, value } => W::Cell(wit_vt::CellEdit { row, column, value }), + ViewEvent::InsertRow { before } => W::InsertRow(before), + ViewEvent::DeleteRows { rows } => W::DeleteRows(rows), + ViewEvent::Message { text } => W::Message(text), + } +} diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 47890f3de..e9074a700 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -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 `/plugins//`. - Installed, - /// Loaded from `/.openidea/plugins//` 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, } -#[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, -} - -#[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, - pub homepage: Option, - pub change_notes: Option, - pub source: PluginSource, - /// Package directory. - pub path: ide_core::IpcPath, - pub enabled: bool, - pub status: PluginStatus, - pub error: Option, - pub has_code: bool, - pub permissions: Vec, - /// Commands contributed while enabled (manifest + registered at runtime). - pub commands: Vec, - pub languages: Vec, - pub themes: Vec, -} - -/// 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, - pub homepage: Option, - pub change_notes: Option, - /// Package directory; install with `install_from_path(path)`. - pub path: ide_core::IpcPath, - pub has_code: bool, - pub permissions: Vec, - pub commands: Vec, - 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, -} - -#[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, - pub active_theme: Option, -} - -/// 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 }, -} - -/// 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, PluginEvent) + Send + Sync> PluginEventSink for F { - fn emit(&self, client: Option<&str>, event: PluginEvent) { - self(client, event) - } -} - -pub type BufferText = Arc Option + 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, pub workspace_root: Option, - /// Unsaved editor contents win over disk for `read-file`. - pub buffer_text: Option, + pub editor: Option, } -/// 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, + /// One of the plugin's commands, run on click. + pub command: Option, +} + +/// Kinds of workbench events a plugin can subscribe to (`on-event:`). +#[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::ALL.into_iter().find(|k| k.name() == s) + } + + /// The capability a subscription needs (`None`: `scheduled`, which the + /// manifest declares). + pub fn capability(self) -> Option { + 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, +} + +/// 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), + 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, + pub commit: Option, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub operation: Option, + 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, } 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, + 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, +} + +/// 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:}}` 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>, + pub timeout: Duration, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HttpResponse { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +/// What a one-off process printed (WIT `process.output`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProcessOutput { + pub status: Option, + 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 { + 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:pw@h.io/").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, + /// WIT `completion-kind` name (`method`, `keyword`, …). + pub kind: Option, + pub insert_text: String, + pub replace: Option, + pub sort_text: Option, + pub documentation: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineSuggestion { + pub insert_text: String, + pub replace: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CodeAction { + pub title: String, + pub kind: Option, + pub preferred: bool, + pub edits: Vec<(PathBuf, Vec)>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineMarker { + pub line: u32, + pub icon: Option, + pub tooltip: String, + pub target: Option, +} + +// ---- 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, + pub enabled: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ViewTreeItem { + pub key: String, + pub parent: Option, + pub label: String, + pub icon: Option, + pub detail: Option, + 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, + pub detail: Option, +} + +#[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, 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, + pub total_rows: u32, + pub first_row: u32, + pub rows: Vec>, + 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), + List(Vec), + Form(Vec), + 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, + pub body: ViewBody, + pub status: Option, +} + +/// 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 }, + Message { text: String }, +} diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs new file mode 100644 index 000000000..5de21e760 --- /dev/null +++ b/crates/ide-plugin/src/wit_host.rs @@ -0,0 +1,691 @@ +//! The host side of the `openidea:plugin@1.0.0` 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 = wasmtime::Result>; + +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(&mut self, value: T) -> wasmtime::Result> { + Ok(self.table.push(value)?) + } + + pub(crate) fn new_invocation(&mut self) -> wasmtime::Result> { + self.push(InvocationRes) + } + + pub(crate) fn drop_invocation(&mut self, res: Resource) { + 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) -> 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> { + 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> { + 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>> { + 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) -> Fallible { + 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, path: String) -> Fallible { + 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, glob: String, limit: u32) -> Fallible> { + 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) -> wasmtime::Result<()> { + self.table.delete(rep)?; + Ok(()) + } +} + +impl editor::Host for StoreData {} + +impl editor::HostTextEditor for StoreData { + fn path(&mut self, this: Resource) -> wasmtime::Result { + Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned()) + } + + fn language(&mut self, this: Resource) -> wasmtime::Result { + Ok(self.table.get(&this)?.ctx.language.clone()) + } + + fn document(&mut self, this: Resource) -> Fallible { + 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) -> Fallible> { + 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, version: u64, edits: Vec) -> Fallible { + 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 = 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) -> wasmtime::Result<()> { + self.table.delete(rep)?; + Ok(()) + } +} + +impl invocation::Host for StoreData {} + +impl invocation::HostInvocation for StoreData { + fn project(&mut self, _this: Resource) -> wasmtime::Result>> { + match self.invocation.workspace_root.clone() { + Some(root) => Ok(Some(self.push(ProjectRes { root })?)), + None => Ok(None), + } + } + + fn editor(&mut self, _this: Resource) -> wasmtime::Result>> { + match self.invocation.editor.clone() { + Some(ctx) => Ok(Some(self.push(EditorRes { ctx })?)), + None => Ok(None), + } + } + + fn drop(&mut self, rep: Resource) -> 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) -> Fallible> { + 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) -> 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> { + 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, + done: u32, + total: Option, + message: Option, + ) -> 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) -> wasmtime::Result { + let id = self.table.get(&this)?.id; + Ok(self.cancel.token().is_cancelled() || self.host.task_cancelled(id)) + } + + fn drop(&mut self, rep: Resource) -> 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) -> 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 { + 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 { + 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, + cwd: Option>, + stdin: Option, + ) -> Fallible { + 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) -> 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> { + 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; diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index dfc65c7a8..8ef7fdba7 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml index daf0dbdde..964df0cd2 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml @@ -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"] diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 8f4ad0714..dd9fba2a6 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -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) -> Result, String> { +impl commands::Guest for Misbehave { + fn run(id: String, ctx: &Invocation, arg: Option) -> Result, 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, _ctx: &Invocation) {} +} + +/// Edit-path budgets: the text says how to behave. `sleep:` 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 { + if let Some(ms) = text.strip_prefix("sleep:").and_then(|n| n.trim().parse::().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 { + Vec::new() + } + + fn folding(_language: String, _text: String) -> Vec { + Vec::new() + } +} + export!(Misbehave); diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/.gitignore b/crates/ide-plugin/tests/fixtures/p0-probe/.gitignore new file mode 100644 index 000000000..a82021dbd --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/.gitignore @@ -0,0 +1,2 @@ +target/ +!dist/ diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.lock b/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.lock new file mode 100644 index 000000000..1cbb372cd --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.lock @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.toml b/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.toml new file mode 100644 index 000000000..41c48f07b --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/build.sh b/crates/ide-plugin/tests/fixtures/p0-probe/build.sh new file mode 100755 index 000000000..18ed6f7ba --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/build.sh @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/dist/full.wasm b/crates/ide-plugin/tests/fixtures/p0-probe/dist/full.wasm new file mode 100644 index 000000000..a7b7215bf Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/p0-probe/dist/full.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/dist/min.wasm b/crates/ide-plugin/tests/fixtures/p0-probe/dist/min.wasm new file mode 100644 index 000000000..ac9499bea Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/p0-probe/dist/min.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/src/lib.rs b/crates/ide-plugin/tests/fixtures/p0-probe/src/lib.rs new file mode 100644 index 000000000..f12996fa6 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/src/lib.rs @@ -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 { + 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 { + vec![format!("fix:{path}")] + } +} + +#[cfg(feature = "full")] +impl exports::openidea::probe::completion::Guest for Probe { + fn complete(prefix: String) -> Vec { + vec![format!("{prefix}1"), format!("{prefix}2")] + } +} + +export!(Probe); diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/wit/deps/io/poll.wit b/crates/ide-plugin/tests/fixtures/p0-probe/wit/deps/io/poll.wit new file mode 100644 index 000000000..2c81ea016 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/wit/deps/io/poll.wit @@ -0,0 +1,11 @@ +// Subset of wasi:io@0.2.0 (the host links its full 0.2.x implementation). +package wasi:io@0.2.0; + +interface poll { + resource pollable { + ready: func() -> bool; + block: func(); + } + + poll: func(in: list>) -> list; +} diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit b/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit new file mode 100644 index 000000000..d3cbece56 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit @@ -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:probe@0.1.0; + +interface host-api { + use wasi:io/poll@0.2.0.{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); + /// 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; + } + + /// 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; +} + +/// Optional exports: a plugin implements only the providers it needs. +interface code-actions { + actions: func(path: string) -> list; +} + +interface completion { + complete: func(prefix: string) -> list; +} + +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; +} diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs deleted file mode 100644 index 2c2572c03..000000000 --- a/crates/ide-plugin/tests/host.rs +++ /dev/null @@ -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, 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, 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, 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::>(), ["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, 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()); -} diff --git a/crates/ide-plugin/tests/p0_probe.rs b/crates/ide-plugin/tests/p0_probe.rs new file mode 100644 index 000000000..11026d12b --- /dev/null +++ b/crates/ide-plugin/tests/p0_probe.rs @@ -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>>, + cancelled: Arc, + notify: Arc, + progress: Arc, +} + +/// The pollable of `next-ready`: ready once an item is queued or the task is cancelled. +struct NextReady { + queue: Arc>>, + cancelled: Arc, + notify: Arc, +} + +#[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>>, + cancelled: Arc, + notify: Arc, + progress: Arc, +} + +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, done: u32, _total: Option) -> wasmtime::Result<()> { + self.table.get(&task)?.progress.store(u64::from(done), Ordering::Release); + Ok(()) + } + fn is_cancelled(&mut self, task: Resource) -> wasmtime::Result { + Ok(self.table.get(&task)?.cancelled.load(Ordering::Acquire)) + } + fn next_ready(&mut self, task: Resource) -> wasmtime::Result> { + 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) -> wasmtime::Result> { + 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) -> wasmtime::Result<()> { + self.table.delete(task)?; + Ok(()) + } +} + +impl host_api::Host for Data { + fn current_task(&mut self) -> wasmtime::Result> { + 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, + stop: Arc, +} + +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 { + 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/base@0.1.0"; +const CODE_ACTIONS: &str = "openidea:probe/code-actions@0.1.0"; + +/// 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::(), 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); +} diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 24cf12f64..c9195b404 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -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())); diff --git a/crates/ide-run/src/external.rs b/crates/ide-run/src/external.rs new file mode 100644 index 000000000..0be3405d2 --- /dev/null +++ b/crates/ide-run/src/external.rs @@ -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::`). +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; + /// The program to start for `config` (one this provider detected), or why + /// it may not start. + fn launch(&self, config: &RunConfig) -> Result; +} + +static PROVIDER: RwLock>> = RwLock::new(None); + +/// Installs (or removes) the provider [`crate::detect`] and runs consult. +pub fn set_external_detection(provider: Option>) { + *PROVIDER.write() = provider; +} + +pub(crate) fn detect(root: &Path, out: &mut Vec) { + 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> { + 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 { + 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 { + 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::>(), ["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")); + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 1ca1a0a18..7e85a1643 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -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 { diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index ce900ecaa..2899c026a 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -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()); diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 3c1eb64f0..600d8dd65 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -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(); diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 167efed1b..3117fdda1 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -36,6 +36,7 @@ fn server(name: &str) -> LanguageServerConfig { max_java: None, launch_error: None, tsserver_project: false, + pinned: None, } } diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index dbcee862b..7bb741991 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -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> { + 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, diff --git a/docs/plugin_p0.md b/docs/plugin_p0.md new file mode 100644 index 000000000..4c374dd71 --- /dev/null +++ b/docs/plugin_p0.md @@ -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/code-actions@0.1.0")` 在**编译后、实例化前**就能判断 + 组件有没有导出某个接口。安装时据此算出插件实现了哪些 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); + 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; + /// tree-sitter 查询语法;只返回被捕获的节点,最多 `limit` 个。 + query: func(source: string, range: option, limit: u32) -> result, error>; + errors: func(limit: u32) -> list; + } + record node { kind: string, range: range, text: string, parent-kind: option } + record capture { name: string, node: node } + /// 打开的文档用增量缓存树;未打开的文件现场解析(受 `project.read` 约束)。 + parse: func(path: string) -> result; + } + ``` + 不暴露节点句柄的游走 API(`parent`/`children` 逐个跨边界调用代价高);查询一次返回结果列表。 +- **插件提供语法树(export,P3)**:宿主没有 grammar 的语言(Solidity)。宿主不加载插件提供的原生 + grammar;插件在 Wasm 里自带解析器,导出 `syntax-provider`,按需返回**扁平结果**而不是树: + ```wit + interface syntax-provider { + highlights: func(text: string) -> list; // range + 语义类别(映射到 --syn-*) + outline: func(text: string) -> list; + folding: func(text: string) -> list; + } + ``` + 宿主把结果转换成现有的 `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; + symbols-named: func(name: string, kind: option, limit: u32) -> list; + find-files: func(query: string, limit: u32) -> list; + /// 插件自定义索引的查询,见下。 + lookup: func(index: string, key: string, limit: u32) -> list; + } + 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`;宿主在**后台**(复用 `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`。 diff --git a/docs/plugin_plan.md b/docs/plugin_plan.md new file mode 100644 index 000000000..180b40f5c --- /dev/null +++ b/docs/plugin_plan.md @@ -0,0 +1,601 @@ +# 插件系统重构计划 + +状态:P0–P8 完成(P4 release 实测在预算内),WIT 冻结为 `openidea:plugin@1.0.0`。涉及 `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; +} + +pub struct Prepared { + pub capabilities: Vec, // 带 LSP 的语言 → process: + pub commit: Box Result + Send>, +} +``` + +- 原子生效:全部 `prepare` 成功才开始 `commit`;任一 `commit` 失败,把已 commit 的 `Disposable` 逆序撤销。 +- 前后端一致:所有校验都在 Rust 侧完成,前端只做投影。投影抛错时记为缺陷,不做部分生效。 +- 干净卸载:贡献记在所属插件名下,禁用或卸载时统一 dispose。 +- 前向兼容:不认识的 `contributes.` 告警并跳过,不让整包失败。 +- 前端投影:`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:`、`secrets`、按键与输入事件 | 安装页单独列出并说明用途 | +| 高 | `process:`、`fs.read:<项目外路径>` | 单独弹窗,明确提示“等同于在本机执行任意代码” | + +`process:` 说明: + +- 它是完全的代码执行权限,参数无法有效限制(例如 `git -c core.sshCommand=...`),不把它包装成细粒度权限。 +- 二进制在授权时按绝对路径解析并写入账本;运行时路径或文件哈希变化要重新确认。不在运行时按 PATH 查找, + 防止仓库在 PATH 前面放同名程序冒充。 +- 声明也算能力:带 `language-servers.command` 的语言包同样需要 `process:` 授权,否则语言注册修好后就成了沙箱绕过口。 + +`net:` 由宿主的 HTTP 客户端实现,每一跳重定向都校验主机;不向插件暴露原始 socket。 + +默认用声明式视图树(tree、list、form、toolbar),由 `src/ui` 渲染,自动跟随主题和无障碍规范。 +iframe 只用于富内容:严格 CSP、仅 `postMessage`、不暴露 Tauri API。第三方代码不进主 WebView。 + +### 4.3 WIT API + +开发期版本号用 `openidea:plugin@0.2.0`,**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; } } +interface editor { resource text-editor { + apply-edits: func(version: u64, edits: list) -> result<_, error>; } } +interface syntax { resource tree { node-at, query, errors } parse: func(path) -> result; } +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); + is-cancelled: func() -> bool; + cancelled: func() -> pollable; } } +// 另有 ui、vcs、net、process、storage、secrets + +interface commands { run: func(id: string, ctx: borrow, arg: option) + -> result, 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:` 声明同样效果);`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:}}`, + 宿主发出前替换(这就是“本地代理”的做法);远程项目里由服务器按需向桌面端请求。 +- 权限失败返回 `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///` 加 active 指针,支持原子升级和回滚;数据放 `data//`。 +- 市场:静态索引(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, 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:`,整个会话有效、跨空闲回收。`on-schedule` 到点投递 `scheduled`。 +- **任务**:`tasks.begin` 进入前端后台任务列表(可取消,取消同时取消宿主侧的 fetch),打开任务的调用改用 + 60s 后台预算;任务不跨调用。 +- **存储**:`plugins/.data//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:` 授权、拒绝跨主机重定向、 + 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[]`,插件经 `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:plugin@1.0.0` + +验收:升级失败能自动回滚;命中吊销列表的插件被禁用;10 个官方插件只改 `engines.openidea` 和 WIT 包版本即可切到 1.0。 + +完成情况: + +- **包与签名**(`ide_extension::package`):`-.oiplugin` 是 zip(manifest、声明引用的文件、组件、 + 许可证文件、可选的 `SIGNATURE.json`);解包拒绝越界路径、链接和超限内容。签名是 Ed25519,签的是 + `openidea-plugin/1\n\n\n<摘要>`,摘要覆盖 manifest 与包内文件。`openidea-plugin keygen`、 + `pack --key --publisher`、`index`(由一批包生成索引,保留已有吊销和旧公钥)。 +- **信任与吊销**:静态索引(`index.json`,格式 1)登记发布者及其公钥(信任根;发布者 `acme` 只能发布 + `acme.*`)、插件条目(含摘要)和吊销(某些版本或全部版本;公钥可标为吊销)。签名不成立(被改过、 + 不是该发布者的公钥、越权前缀、公钥已吊销)的包拒绝安装;无签名包从磁盘安装时提示。宿主保存最近一次读到 + 的索引(`market-index.json`),启动时、每次刷新(桌面端每天一次)都重新评估:命中的插件停用并给出原因、 + 通知用户,`set_enabled` 无法绕过;解除吊销后自动恢复。 +- **版本目录与回滚**(`plugins::install`):`///` 加 `active`、`previous` 指针,新版本 + 写完后才原子替换指针;更新后**新版本代码首次启动失败**(实例化失败、`activate` 出错或 trap)时自动回滚到 + 被替换的版本、删掉失败的版本、恢复旧的授权记录并通知用户;加载即失败的更新在安装时就回滚。设置页可手动 + “回滚到 …”。旧的平铺安装在下次更新时迁入版本目录;中断的迁移由 `recover` 复原。 +- **市场页**:设置 › 插件 › Available 填入索引 URL(`https://…/index.json` 或本地路径)后列出市场插件, + 安装/更新时下载、对照索引条目核对摘要和发布者签名,再走同一个授权安装页;已安装插件显示“由 … 签名 / + 未签名”。没有配置市场时仍显示本机可用的包。 +- **冻结 WIT 1.0**:包名改为 `openidea:plugin@1.0.0`,宿主 `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:` 还是只给 `process:` | 影响能力模型 | 已定(P7):Kubernetes 只要 `process:kubectl`,kubeconfig 由 kubectl 自己读,插件不读项目外文件;`fs.read` 仍保留为高风险能力,暂无使用者 | +| 发布者密钥丢失或泄露 | 影响已发布版本的可信度 | 吊销列表 + 发布者重新登记,P8 写成流程文档 | +| Wasm 单包体积(尤其 TS SDK) | 影响分发和内存 | P4 纳入性能预算观测 | diff --git a/perf/budgets.json b/perf/budgets.json index 25b40729b..21ad2c6e5 100644 --- a/perf/budgets.json +++ b/perf/budgets.json @@ -1,5 +1,5 @@ { - "$comment": "Upper bounds, lower is better. ui: `.` 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: `.` 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 } } diff --git a/perf/rust.ts b/perf/rust.ts index a79889f52..345383960 100644 --- a/perf/rust.ts +++ b/perf/rust.ts @@ -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: { diff --git a/plugins/README.md b/plugins/README.md index fd1a745b7..c9d97c5fc 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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/plugin@0.1.0`). +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 `/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 "] -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/.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:` | the command runs (implied for every command) | +| `on-language:` | a file of that language is opened (`*`: any) | +| `on-file:` | a project file matching the glob is opened (`**`) | +| `on-view:` | one of the plugin's views is shown | +| `on-schedule:` | every `secs` seconds (at least 60): `Event::Scheduled` | +| `on-event:` | 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:` | high | `Project::run_process`, language servers, debug adapters, runs | +| `net:` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) | +| `secrets` | medium | `{{secret:}}` 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:` 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("")` — 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) -> Result, String> { - match id.as_str() { +impl Plugin for WordTools { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result, 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..`; 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/.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 _" 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 ``` diff --git a/plugins/hello/Cargo.lock b/plugins/hello/Cargo.lock index 2e617884f..2ba582e73 100644 --- a/plugins/hello/Cargo.lock +++ b/plugins/hello/Cargo.lock @@ -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" diff --git a/plugins/hello/Cargo.toml b/plugins/hello/Cargo.toml index 10ed4602c..28d8c501f 100644 --- a/plugins/hello/Cargo.toml +++ b/plugins/hello/Cargo.toml @@ -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" diff --git a/plugins/hello/dist/plugin.wasm b/plugins/hello/dist/plugin.wasm index 3cc43a851..5570638ca 100644 Binary files a/plugins/hello/dist/plugin.wasm and b/plugins/hello/dist/plugin.wasm differ diff --git a/plugins/hello/plugin.toml b/plugins/hello/plugin.toml index 458abb4b6..54e273038 100644 --- a/plugins/hello/plugin.toml +++ b/plugins/hello/plugin.toml @@ -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" diff --git a/plugins/hello/src/lib.rs b/plugins/hello/src/lib.rs index e1009fd76..da009057b 100644 --- a/plugins/hello/src/lib.rs +++ b/plugins/hello/src/lib.rs @@ -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(), ""); + } +} diff --git a/plugins/official/ai-completion/.gitignore b/plugins/official/ai-completion/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/ai-completion/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/ai-completion/Cargo.lock b/plugins/official/ai-completion/Cargo.lock new file mode 100644 index 000000000..e7bc7ab92 --- /dev/null +++ b/plugins/official/ai-completion/Cargo.lock @@ -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" diff --git a/plugins/official/ai-completion/Cargo.toml b/plugins/official/ai-completion/Cargo.toml new file mode 100644 index 000000000..255622aa5 --- /dev/null +++ b/plugins/official/ai-completion/Cargo.toml @@ -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" diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm new file mode 100644 index 000000000..ee5484557 Binary files /dev/null and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/plugin.toml b/plugins/official/ai-completion/plugin.toml new file mode 100644 index 000000000..8cf1c5630 --- /dev/null +++ b/plugins/official/ai-completion/plugin.toml @@ -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" diff --git a/plugins/official/ai-completion/src/lib.rs b/plugins/official/ai-completion/src/lib.rs new file mode 100644 index 000000000..a908831f9 --- /dev/null +++ b/plugins/official/ai-completion/src/lib.rs @@ -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"); + } +} diff --git a/plugins/official/checkstyle/.gitignore b/plugins/official/checkstyle/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/checkstyle/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/checkstyle/Cargo.lock b/plugins/official/checkstyle/Cargo.lock new file mode 100644 index 000000000..5c3d4f38a --- /dev/null +++ b/plugins/official/checkstyle/Cargo.lock @@ -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" diff --git a/plugins/official/checkstyle/Cargo.toml b/plugins/official/checkstyle/Cargo.toml new file mode 100644 index 000000000..2a04ad515 --- /dev/null +++ b/plugins/official/checkstyle/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-checkstyle" +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" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm new file mode 100644 index 000000000..13e6c1d64 Binary files /dev/null and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/plugin.toml b/plugins/official/checkstyle/plugin.toml new file mode 100644 index 000000000..444157b6b --- /dev/null +++ b/plugins/official/checkstyle/plugin.toml @@ -0,0 +1,33 @@ +id = "openidea.checkstyle" +name = "Checkstyle" +version = "0.1.0" +description = "Checks Java files with Checkstyle when you save them: problems in the editor and the Problems view, quick fixes for tabs, trailing spaces and missing final newlines." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: check on save (a newer save cancels the running check), Google style by default, quick fixes." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-event:file-saved", "on-command:*"] + +[capabilities] +# `process:checkstyle`: the `checkstyle` command line (e.g. `brew install checkstyle`). +required = ["project.read", "events", "storage", "process:checkstyle"] + +[[contributes.providers]] +kinds = ["code-actions"] +languages = ["java"] + +[[contributes.commands]] +id = "check" +title = "Checkstyle: Check Current File" +category = "Checkstyle" + +[[contributes.commands]] +id = "use-sun" +title = "Checkstyle: Use Sun Checks" +category = "Checkstyle" + +[[contributes.commands]] +id = "use-google" +title = "Checkstyle: Use Google Checks" +category = "Checkstyle" diff --git a/plugins/official/checkstyle/src/lib.rs b/plugins/official/checkstyle/src/lib.rs new file mode 100644 index 000000000..b4ef62b72 --- /dev/null +++ b/plugins/official/checkstyle/src/lib.rs @@ -0,0 +1,317 @@ +//! Checkstyle for OpenIDEA: runs the `checkstyle` command line on a Java file +//! when it is saved (as a cancellable task; a newer save cancels the running +//! check), publishes the problems as diagnostics, and offers quick fixes for +//! the ones it can fix textually. +//! +//! The configuration is the project's own when it has one (`checkstyle.xml`, +//! `config/checkstyle/checkstyle.xml` as Gradle and Maven projects keep it, +//! or `.checkstyle.xml`), else the Google or Sun checks chosen with the +//! commands. + +use openidea_plugin::host::{self, storage}; +use openidea_plugin::providers::{CodeAction, Document, Providers}; +use openidea_plugin::{ + Diagnostic, Error, Event, Invocation, NotificationLevel, Plugin, Position, Project, Range, Severity, TextEdit, + register_plugin, +}; +use std::collections::BTreeMap; + +/// Built into the Checkstyle jar. +const GOOGLE: &str = "/google_checks.xml"; +const SUN: &str = "/sun_checks.xml"; +/// Project configurations, by preference (project-relative; the check runs in the project root). +const PROJECT_CONFIGS: [&str; 3] = ["checkstyle.xml", "config/checkstyle/checkstyle.xml", ".checkstyle.xml"]; + +/// One reported problem. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Problem { + /// 0-based. + pub line: u32, + /// 0-based; `None` for the whole line. + pub column: Option<u32>, + pub severity: Severity, + pub message: String, + /// The check's simple name (`FileTabCharacter`). + pub check: String, +} + +#[derive(Default)] +pub struct Checkstyle { + /// Last results per file (for quick fixes; rebuilt by the next save). + problems: BTreeMap<String, Vec<Problem>>, +} + +fn unescape(s: &str) -> String { + s.replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace(" ", "\n") + .replace("&", "&") +} + +fn attr(tag: &str, name: &str) -> Option<String> { + let start = tag.find(&format!(" {name}=\""))? + name.len() + 3; + let end = tag[start..].find('"')? + start; + Some(unescape(&tag[start..end])) +} + +/// Problems per file from Checkstyle's `-f xml` report. +pub fn parse_report(xml: &str) -> BTreeMap<String, Vec<Problem>> { + let mut out: BTreeMap<String, Vec<Problem>> = BTreeMap::new(); + let mut file = None; + for tag in xml.split('<').skip(1) { + let tag = format!(" {}", tag.split('>').next().unwrap_or("")); + if tag.starts_with(" file ") { + file = attr(&tag, "name"); + if let Some(f) = &file { + out.entry(f.clone()).or_default(); + } + } else if tag.starts_with(" error ") { + let (Some(f), Some(line)) = (&file, attr(&tag, "line").and_then(|l| l.parse::<u32>().ok())) else { + continue; + }; + let severity = match attr(&tag, "severity").as_deref() { + Some("error") => Severity::Error, + Some("info") => Severity::Info, + Some("ignore") => continue, + _ => Severity::Warning, + }; + let check = attr(&tag, "source").unwrap_or_default(); + // Checks configured with an `id` report it after a `#` (Google checks: `RegexpSinglelineCheck#noTrailingWhitespace`). + let check = check.split('#').next().unwrap_or_default(); + let check = check.rsplit('.').next().unwrap_or(check).trim_end_matches("Check").to_owned(); + out.entry(f.clone()).or_default().push(Problem { + line: line.saturating_sub(1), + column: attr(&tag, "column").and_then(|c| c.parse::<u32>().ok()).map(|c| c.saturating_sub(1)), + severity, + message: attr(&tag, "message").unwrap_or_default(), + check, + }); + } + } + out +} + +fn diagnostic(p: &Problem, text: &str) -> Diagnostic { + let line_len = text.split('\n').nth(p.line as usize).map_or(0, |l| l.encode_utf16().count() as u32); + let start = p.column.unwrap_or(0).min(line_len); + let end = if p.column.is_some() && start < line_len { start + 1 } else { line_len }; + Diagnostic { + range: Range::new(Position::new(p.line, start), Position::new(p.line, end.max(start))), + severity: p.severity, + message: p.message.clone(), + code: Some(p.check.clone()), + } +} + +/// The project containing `path`. +fn project_of(path: &str) -> Option<Project> { + host::projects() + .ok()? + .into_iter() + .filter(|p| p.root().is_ok_and(|r| path.starts_with(&r))) + .max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0)) +} + +/// The configuration for files of `project`. +fn config_for(project: &Project) -> String { + PROJECT_CONFIGS + .iter() + .find(|c| project.read_file(c).is_ok()) + .map(|c| (*c).to_owned()) + .or_else(|| storage::get("config").ok().flatten()) + .unwrap_or_else(|| GOOGLE.into()) +} + +impl Checkstyle { + /// Checks `path` and publishes the result. `Err(Cancelled)` when a newer + /// save (or the user) cancelled it. + fn check(&mut self, path: &str) -> Result<usize, Error> { + let project = project_of(path).ok_or_else(|| Error::NotFound(format!("{path} is not in an open project")))?; + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + let task = host::begin_task(&format!("Checkstyle: {name}"), true); + let config = config_for(&project); + let args = vec!["-f".to_owned(), "xml".into(), "-c".into(), config, path.to_owned()]; + let out = project.run_process("checkstyle", &args, None)?; + if task.is_cancelled() { + return Err(Error::Cancelled); + } + // Exit status is the number of errors; only missing output is a failure. + if !out.stdout.contains("<checkstyle") { + return Err(Error::Failed(format!("checkstyle: {}", out.stderr.lines().next().unwrap_or("no report")))); + } + let report = parse_report(&out.stdout); + let problems = report + .into_iter() + .find(|(f, _)| f == path || path.ends_with(f.as_str())) + .map(|(_, p)| p) + .unwrap_or_default(); + let text = project.read_file(path).unwrap_or_default(); + let diagnostics: Vec<Diagnostic> = problems.iter().map(|p| diagnostic(p, &text)).collect(); + host::publish_diagnostics(path, &diagnostics)?; + let n = problems.len(); + self.problems.insert(path.to_owned(), problems); + Ok(n) + } +} + +impl Providers for Checkstyle { + fn code_actions(&mut self, doc: &Document, selection: Range) -> Vec<CodeAction> { + let Some(problems) = self.problems.get(&doc.path) else { return Vec::new() }; + let lines: Vec<&str> = doc.text.split('\n').collect(); + let mut out = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for p in problems.iter().filter(|p| p.line >= selection.start.line && p.line <= selection.end.line) { + let Some(line) = lines.get(p.line as usize) else { continue }; + let whole = Range::new(Position::new(p.line, 0), Position::new(p.line, line.encode_utf16().count() as u32)); + let (title, edit) = match p.check.as_str() { + "FileTabCharacter" => { + ("Replace tabs with spaces", TextEdit { range: whole, text: line.replace('\t', " ") }) + } + "RegexpSingleline" if p.message.to_lowercase().contains("trailing") => { + ("Remove trailing whitespace", TextEdit { range: whole, text: line.trim_end().to_owned() }) + } + "NewlineAtEndOfFile" => { + let last = lines.len() as u32 - 1; + let end = Position::new(last, lines[last as usize].encode_utf16().count() as u32); + ( + "Add a newline at the end of the file", + TextEdit { range: Range::new(end, end), text: "\n".into() }, + ) + } + _ => continue, + }; + if seen.insert((title, p.line)) { + out.push(CodeAction { + title: format!("Checkstyle: {title}"), + kind: Some("quickfix".into()), + preferred: true, + edits: vec![(doc.path.clone(), vec![edit])], + }); + } + } + out + } +} + +impl Plugin for Checkstyle { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + match id { + "check" => { + let editor = ctx.editor().ok_or("no file is open")?; + let n = self.check(&editor.path())?; + Ok(Some(format!("{n} problems"))) + } + "use-google" | "use-sun" => { + storage::set("config", if id == "use-sun" { SUN } else { GOOGLE })?; + Ok(None) + } + _ => Err(format!("unknown command {id}")), + } + } + + fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) { + let mut saved: Vec<&str> = events + .iter() + .filter_map(|e| match e { + Event::FileSaved(p) if p.ends_with(".java") => Some(p.as_str()), + _ => None, + }) + .collect(); + saved.sort_unstable(); + saved.dedup(); + for path in saved { + match self.check(path) { + Ok(_) => {} + Err(Error::Cancelled) => return, // a newer save takes over + Err(e) => { + host::notify(NotificationLevel::Warning, "Checkstyle", &e.to_string()); + return; + } + } + } + } +} + +register_plugin!(Checkstyle, providers); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::ProcessOutput; + use openidea_plugin::mock::MockHost; + + const REPORT: &str = r#"<?xml version="1.0" encoding="UTF-8"?> +<checkstyle version="10.17.0"> +<file name="/project/src/A.java"> +<error line="2" column="1" severity="warning" message="Line contains a tab character." source="com.puppycrawl.tools.checkstyle.checks.whitespace.FileTabCharacterCheck"/> +<error line="3" severity="error" message="Trailing whitespace is not allowed" source="com.puppycrawl.tools.checkstyle.checks.regexp.RegexpSinglelineCheck#noTrailingWhitespace"/> +</file> +</checkstyle>"#; + + #[test] + fn parses_reports() { + let r = parse_report(REPORT); + let p = &r["/project/src/A.java"]; + assert_eq!(p.len(), 2); + assert_eq!((p[0].line, p[0].column, p[0].check.as_str()), (1, Some(0), "FileTabCharacter")); + assert_eq!( + (p[1].severity, p[1].message.as_str(), p[1].check.as_str()), + (Severity::Error, "Trailing whitespace is not allowed", "RegexpSingleline") + ); + } + + #[test] + fn checks_saved_java_files_and_fixes() { + let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]); + let text = "class A {\n\tint x;\n void f() {} \n}"; + host.add_file("src/A.java", text); + host.on_process("checkstyle", |args, _| { + assert_eq!(&args[..4], ["-f", "xml", "-c", GOOGLE]); + Ok(ProcessOutput { status: Some(1), stdout: REPORT.into(), stderr: String::new() }) + }); + let mut p = Checkstyle::default(); + p.handle_events( + &[Event::FileSaved("/project/src/A.java".into()), Event::FileSaved("/project/notes.txt".into())], + &host.invocation(), + ); + assert_eq!(host.process_runs().len(), 1, "only Java files"); + assert_eq!(host.process_runs()[0].cwd.as_deref(), Some("/project")); + let d = &host.diagnostics()["/project/src/A.java"]; + assert_eq!(d.len(), 2); + assert_eq!(d[1].range, Range::new(Position::new(2, 0), Position::new(2, 16)), "whole line without a column"); + assert!(host.tasks()[0].ended); + + let doc = + Document { path: "/project/src/A.java".into(), language: "java".into(), version: 1, text: text.into() }; + let actions = p.code_actions(&doc, Range::new(Position::new(1, 0), Position::new(2, 0))); + let titles: Vec<_> = actions.iter().map(|a| a.title.as_str()).collect(); + assert_eq!(titles, ["Checkstyle: Replace tabs with spaces", "Checkstyle: Remove trailing whitespace"]); + assert_eq!(actions[1].edits[0].1[0].text, " void f() {}"); + } + + #[test] + fn the_projects_configuration_wins() { + let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]); + host.add_file("src/A.java", "class A {}"); + host.on_process("checkstyle", |_, _| { + Ok(ProcessOutput { status: Some(0), stdout: "<checkstyle/>".into(), stderr: String::new() }) + }); + let mut p = Checkstyle::default(); + p.run_command("use-sun", &host.invocation(), None).unwrap(); + p.handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation()); + host.add_file("config/checkstyle/checkstyle.xml", "<module name=\"Checker\"/>"); + p.handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation()); + let configs: Vec<String> = host.process_runs().iter().map(|r| r.args[3].clone()).collect(); + assert_eq!(configs, [SUN, "config/checkstyle/checkstyle.xml"]); + } + + #[test] + fn a_missing_checkstyle_is_reported_once() { + let host = MockHost::install().with_capabilities(&["project.read", "events", "storage"]); + host.add_file("src/A.java", "class A {}"); + Checkstyle::default().handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation()); + assert!(host.notifications()[0].body.contains("process:checkstyle")); + } +} diff --git a/plugins/official/csv-editor/Cargo.lock b/plugins/official/csv-editor/Cargo.lock new file mode 100644 index 000000000..e0f64ffba --- /dev/null +++ b/plugins/official/csv-editor/Cargo.lock @@ -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-csv-editor" +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" diff --git a/plugins/official/csv-editor/Cargo.toml b/plugins/official/csv-editor/Cargo.toml new file mode 100644 index 000000000..9e381e432 --- /dev/null +++ b/plugins/official/csv-editor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-csv-editor" +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" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm new file mode 100644 index 000000000..4667b33e6 Binary files /dev/null and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/plugin.toml b/plugins/official/csv-editor/plugin.toml new file mode 100644 index 000000000..06d920a50 --- /dev/null +++ b/plugins/official/csv-editor/plugin.toml @@ -0,0 +1,23 @@ +id = "openidea.csv-editor" +name = "CSV Editor" +version = "0.1.0" +description = "Edit CSV and TSV files as a table. Edits go to the file's text, so the text view follows and undo is shared." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: table editor for .csv and .tsv with cell editing, inserting and deleting rows; large files load the visible rows only." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-view:table"] + +[capabilities] +required = ["editor.read", "editor.write"] + +[[contributes.customEditors]] +id = "table" +title = "Table" +patterns = ["*.csv", "*.tsv"] +priority = "default" + +[contributes.settings] +title = "CSV Editor" +schema = "settings.schema.json" diff --git a/plugins/official/csv-editor/settings.schema.json b/plugins/official/csv-editor/settings.schema.json new file mode 100644 index 000000000..9df4a56ad --- /dev/null +++ b/plugins/official/csv-editor/settings.schema.json @@ -0,0 +1,17 @@ +{ + "properties": { + "firstRowIsHeader": { + "type": "boolean", + "title": "First row is the header", + "description": "Show the first record as column names instead of a row.", + "default": true + }, + "delimiter": { + "type": "string", + "title": "Delimiter", + "description": "auto: tab for .tsv files, else the most frequent of , ; | in the first line.", + "enum": ["auto", ",", ";", "|", "tab"], + "default": "auto" + } + } +} diff --git a/plugins/official/csv-editor/src/lib.rs b/plugins/official/csv-editor/src/lib.rs new file mode 100644 index 000000000..046635fd2 --- /dev/null +++ b/plugins/official/csv-editor/src/lib.rs @@ -0,0 +1,319 @@ +//! CSV Editor for OpenIDEA: a custom editor showing `.csv` / `.tsv` files as +//! a table. It never keeps its own copy of the data: every render parses the +//! document, and every edit is a versioned text edit of the document, so the +//! text view of the same file follows at once and Undo is one history. +//! +//! Large files: the IDE asks only for the rows it shows; the parse of the +//! last document version is kept, so scrolling doesn't parse again. + +use openidea_plugin::host; +use openidea_plugin::text::position; +use openidea_plugin::views::{Body, Button, Table, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Error, Invocation, Plugin, Range, TextEdit, register_plugin}; +use std::ops::Range as Span; + +/// One record: the byte spans of its fields and of the whole record (without +/// the line break). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Record { + pub fields: Vec<Span<usize>>, + pub span: Span<usize>, + /// Where the next record starts (after the line break), or the text's end. + pub next: usize, +} + +/// A parsed document. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Csv { + pub delimiter: char, + pub records: Vec<Record>, +} + +/// The field's text: quotes removed, `""` unescaped. +pub fn field_text(text: &str, span: &Span<usize>) -> String { + let raw = &text[span.clone()]; + match raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')) { + Some(inner) if raw.len() >= 2 => inner.replace("\"\"", "\""), + _ => raw.to_owned(), + } +} + +/// `value` as a field: quoted when it contains the delimiter, a quote or a line break. +pub fn quote(value: &str, delimiter: char) -> String { + if value.contains([delimiter, '"', '\n', '\r']) || value.starts_with(' ') || value.ends_with(' ') { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_owned() + } +} + +/// The delimiter: `tab` for .tsv, else the most frequent of `,;|\t` in the first line. +pub fn detect_delimiter(path: &str, text: &str, setting: &str) -> char { + match setting { + "," | ";" | "|" => return setting.chars().next().expect("non-empty"), + "tab" => return '\t', + _ => {} + } + if path.to_ascii_lowercase().ends_with(".tsv") { + return '\t'; + } + let first = text.lines().next().unwrap_or(""); + [',', ';', '\t', '|'] + .into_iter() + .max_by_key(|d| first.matches(*d).count()) + .filter(|d| first.contains(*d)) + .unwrap_or(',') +} + +/// RFC 4180 records (quoted fields may span lines); a trailing line break +/// doesn't start an empty record. +pub fn parse(text: &str, delimiter: char) -> Csv { + let bytes = text.as_bytes(); + let d = delimiter as u8; + let mut records = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let start = i; + let mut fields = Vec::new(); + loop { + let field_start = i; + if bytes.get(i) == Some(&b'"') { + i += 1; + while i < bytes.len() { + if bytes[i] == b'"' { + if bytes.get(i + 1) == Some(&b'"') { + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + } + while i < bytes.len() && bytes[i] != d && bytes[i] != b'\n' && bytes[i] != b'\r' { + i += 1; + } + fields.push(field_start..i); + if i < bytes.len() && bytes[i] == d { + i += 1; + continue; + } + break; + } + let end = i; + if bytes.get(i) == Some(&b'\r') { + i += 1; + } + if bytes.get(i) == Some(&b'\n') { + i += 1; + } + records.push(Record { fields, span: start..end, next: i }); + } + Csv { delimiter, records } +} + +#[derive(Default)] +pub struct CsvEditor { + /// (path, version) of the last parse, and the parse. + cache: Option<((String, u64), Csv)>, +} + +/// Rows the IDE gets when it asks for no viewport. +const DEFAULT_ROWS: u32 = 200; + +fn edit(text: &str, span: Span<usize>, new_text: String) -> TextEdit { + TextEdit { range: Range::new(position(text, span.start), position(text, span.end)), text: new_text } +} + +impl CsvEditor { + fn parsed(&mut self, path: &str, version: u64, text: &str) -> &Csv { + let key = (path.to_owned(), version); + if self.cache.as_ref().is_none_or(|(k, _)| *k != key) { + let setting = host::setting_string("delimiter").unwrap_or_else(|| "auto".into()); + let csv = parse(text, detect_delimiter(path, text, &setting)); + self.cache = Some((key, csv)); + } + &self.cache.as_ref().expect("just set").1 + } + + /// The edits for `event` on `text` (`header`: the first record is not a row). + pub fn edits_for(csv: &Csv, text: &str, header: bool, event: &ViewEvent) -> Result<Vec<TextEdit>, Error> { + let skip = usize::from(header && !csv.records.is_empty()); + let d = csv.delimiter; + let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(1).max(1); + let empty_record = d.to_string().repeat(width - 1); + let record = |row: u32| csv.records.get(row as usize + skip); + let insert = |before: u32| match record(before) { + Some(r) => vec![edit(text, r.span.start..r.span.start, format!("{empty_record}\n"))], + None => { + // Append; the last record may lack its line break. + let end = text.len(); + let sep = if text.is_empty() || text.ends_with('\n') { "" } else { "\n" }; + vec![edit(text, end..end, format!("{sep}{empty_record}\n"))] + } + }; + Ok(match event { + ViewEvent::Cell { row, column, value } => { + let r = record(*row).ok_or_else(|| Error::Invalid(format!("no row {row}")))?; + let column = *column as usize; + match r.fields.get(column) { + Some(span) => vec![edit(text, span.clone(), quote(value, d))], + // A short record: pad it with empty fields. + None => { + let pad = d.to_string().repeat(column + 1 - r.fields.len()); + vec![edit(text, r.span.end..r.span.end, format!("{pad}{}", quote(value, d)))] + } + } + } + ViewEvent::InsertRow(before) => insert(*before), + ViewEvent::Button(b) if b == "add-row" => insert(u32::MAX), + ViewEvent::DeleteRows(rows) => { + let mut rows: Vec<u32> = rows.clone(); + rows.sort_unstable(); + rows.dedup(); + rows.iter() + .filter_map(|row| record(*row)) + .map(|r| edit(text, r.span.start..r.next, String::new())) + .collect() + } + _ => Vec::new(), + }) + } +} + +impl Plugin for CsvEditor { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + Err(format!("unknown command {id}")) + } +} + +impl Views for CsvEditor { + fn render(&mut self, view: &str, viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error> { + if view != "table" { + return Err(Error::NotFound(view.into())); + } + let editor = ctx.editor().ok_or_else(|| Error::Invalid("the table editor needs a document".into()))?; + let doc = editor.document()?; + let path = editor.path(); + let header = host::setting_bool("firstRowIsHeader").unwrap_or(true); + let csv = self.parsed(&path, doc.version, &doc.text).clone(); + let skip = usize::from(header && !csv.records.is_empty()); + let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(0); + let columns: Vec<String> = (0..width) + .map(|c| match (header, csv.records.first().and_then(|r| r.fields.get(c))) { + (true, Some(span)) => field_text(&doc.text, span), + _ => format!("Column {}", c + 1), + }) + .collect(); + let total = (csv.records.len() - skip) as u32; + let range = Viewport::range(viewport, total, DEFAULT_ROWS); + let rows = range + .clone() + .map(|row| { + let r = &csv.records[row as usize + skip]; + (0..width).map(|c| r.fields.get(c).map(|s| field_text(&doc.text, s)).unwrap_or_default()).collect() + }) + .collect(); + let delimiter = match csv.delimiter { + '\t' => "tab".to_owned(), + d => format!("'{d}'"), + }; + Ok(View::new(Body::Table(Table { columns, total_rows: total, first_row: range.start, rows, editable: true })) + .button(Button::new("add-row", "Add Row").icon("plus")) + .status(&format!("{total} rows × {width} columns, delimiter {delimiter}"))) + } + + fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> { + if view != "table" { + return Err(Error::NotFound(view.into())); + } + let editor = ctx.editor().ok_or_else(|| Error::Invalid("the table editor needs a document".into()))?; + let doc = editor.document()?; + let path = editor.path(); + let header = host::setting_bool("firstRowIsHeader").unwrap_or(true); + let csv = self.parsed(&path, doc.version, &doc.text).clone(); + let edits = Self::edits_for(&csv, &doc.text, header, &event)?; + if !edits.is_empty() { + // Stale: the user typed in the text view meanwhile; the table renders again. + editor.apply_edits(doc.version, &edits)?; + } + Ok(()) + } +} + +register_plugin!(CsvEditor, views); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + const DATA: &str = "name,age,city\nAda,36,London\n\"Lovelace, A.\",\"say \"\"hi\"\"\",\"multi\nline\"\nBob,40\n"; + + #[test] + fn parses_quotes_line_breaks_and_short_records() { + let csv = parse(DATA, ','); + assert_eq!(csv.records.len(), 4); + let texts: Vec<String> = csv.records[2].fields.iter().map(|s| field_text(DATA, s)).collect(); + assert_eq!(texts, ["Lovelace, A.", "say \"hi\"", "multi\nline"]); + assert_eq!(csv.records[3].fields.len(), 2); + assert_eq!(parse("a,b\r\nc,d", ',').records.len(), 2); + assert_eq!(detect_delimiter("x.csv", "a;b;c\n", "auto"), ';'); + assert_eq!(detect_delimiter("x.tsv", "a,b\n", "auto"), '\t'); + assert_eq!(quote("a,b", ','), "\"a,b\""); + assert_eq!(quote("plain", ','), "plain"); + } + + #[test] + fn renders_the_visible_rows_with_the_header() { + let host = MockHost::install(); + host.open_editor("/project/people.csv", "csv", DATA, &[]); + let mut p = CsvEditor::default(); + let view = p.render("table", Some(Viewport { first_row: 1, rows: 1 }), &host.invocation()).unwrap(); + let Body::Table(t) = view.body else { panic!("a table") }; + assert_eq!(t.columns, ["name", "age", "city"]); + assert_eq!((t.total_rows, t.first_row), (3, 1)); + assert_eq!(t.rows, [vec!["Lovelace, A.", "say \"hi\"", "multi\nline"]]); + + host.set_setting("firstRowIsHeader", "false"); + let view = p.render("table", None, &host.invocation()).unwrap(); + let Body::Table(t) = view.body else { panic!("a table") }; + assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4)); + } + + #[test] + fn edits_cells_and_rows_through_the_document() { + let host = MockHost::install(); + host.open_editor("/project/people.csv", "csv", DATA, &[]); + let mut p = CsvEditor::default(); + let cell = |row, column, value: &str| ViewEvent::Cell { row, column, value: value.into() }; + p.handle("table", cell(0, 2, "Paris, FR"), &host.invocation()).unwrap(); + assert_eq!(host.editor_text().lines().nth(1), Some("Ada,36,\"Paris, FR\"")); + // A short record is padded. + p.handle("table", cell(2, 2, "Oslo"), &host.invocation()).unwrap(); + assert!(host.editor_text().ends_with("Bob,40,Oslo\n"), "{}", host.editor_text()); + p.handle("table", ViewEvent::InsertRow(0), &host.invocation()).unwrap(); + assert_eq!(host.editor_text().lines().nth(1), Some(",,")); + p.handle("table", ViewEvent::DeleteRows(vec![0, 1]), &host.invocation()).unwrap(); + assert!(host.editor_text().starts_with("name,age,city\n\"Lovelace"), "{}", host.editor_text()); + p.handle("table", ViewEvent::Button("add-row".into()), &host.invocation()).unwrap(); + assert!(host.editor_text().ends_with("Bob,40,Oslo\n,,\n"), "{}", host.editor_text()); + } + + #[test] + fn a_changed_document_is_not_overwritten() { + let host = MockHost::install(); + host.open_editor("/project/a.csv", "csv", "a,b\n1,2\n", &[]); + let mut p = CsvEditor::default(); + let csv = parse("a,b\n1,2\n", ','); + let edits = + CsvEditor::edits_for(&csv, "a,b\n1,2\n", true, &ViewEvent::Cell { row: 0, column: 0, value: "9".into() }); + let editor = host.invocation().editor().unwrap(); + host.type_in_editor("x"); + assert_eq!(editor.apply_edits(0, &edits.unwrap()), Err(Error::Stale)); + // The next event works on the current version. + p.handle("table", ViewEvent::Cell { row: 0, column: 1, value: "8".into() }, &host.invocation()).unwrap(); + assert!(host.editor_text().contains(",8"), "{}", host.editor_text()); + } +} diff --git a/plugins/official/git-toolbox/.gitignore b/plugins/official/git-toolbox/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/git-toolbox/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/git-toolbox/Cargo.lock b/plugins/official/git-toolbox/Cargo.lock new file mode 100644 index 000000000..9ad60b812 --- /dev/null +++ b/plugins/official/git-toolbox/Cargo.lock @@ -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-git-toolbox" +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" diff --git a/plugins/official/git-toolbox/Cargo.toml b/plugins/official/git-toolbox/Cargo.toml new file mode 100644 index 000000000..98e03dddc --- /dev/null +++ b/plugins/official/git-toolbox/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-git-toolbox" +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" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm new file mode 100644 index 000000000..e14440bf7 Binary files /dev/null and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/plugin.toml b/plugins/official/git-toolbox/plugin.toml new file mode 100644 index 000000000..ff5bbfce1 --- /dev/null +++ b/plugins/official/git-toolbox/plugin.toml @@ -0,0 +1,26 @@ +id = "openidea.git-toolbox" +name = "Git Toolbox" +version = "0.1.0" +description = "Fetches your repositories in the background and shows the branch with how far it is ahead of and behind its upstream in the status bar." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: auto-fetch every 5 minutes, ahead/behind in the status bar, Fetch Now." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +# Fetches on schedule (also after being reclaimed while idle) and refreshes +# the status bar when the focused editor or a repository changes. +activation = ["on-schedule:300", "on-event:editor-focused", "on-event:vcs-changed", "on-command:*"] + +[capabilities] +# `vcs.write` runs `git fetch` (only in trusted projects). +required = ["project.read", "vcs.read", "vcs.write", "events", "storage"] + +[[contributes.commands]] +id = "fetch" +title = "Git Toolbox: Fetch Now" +category = "Git Toolbox" + +[[contributes.commands]] +id = "refresh" +title = "Git Toolbox: Refresh Status" +category = "Git Toolbox" diff --git a/plugins/official/git-toolbox/src/lib.rs b/plugins/official/git-toolbox/src/lib.rs new file mode 100644 index 000000000..c5f36c805 --- /dev/null +++ b/plugins/official/git-toolbox/src/lib.rs @@ -0,0 +1,242 @@ +//! Git Toolbox for OpenIDEA: background fetch of every open project's +//! repository (on the plugin's `on-schedule`, which also fires after the +//! instance was reclaimed while idle), and the focused project's branch with +//! its ahead/behind counts in the status bar. + +use openidea_plugin::host::{self, storage}; +use openidea_plugin::{ + Error, Event, Invocation, NotificationLevel, Plugin, Project, RepositoryStatus, StatusItem, register_plugin, +}; + +const ITEM: &str = "branch"; +const LAST_FETCH: &str = "last-fetch:"; +/// Root of the project shown in the status bar. +const SHOWN: &str = "shown-root"; + +#[derive(Default)] +pub struct GitToolbox; + +fn now_secs() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// `main ↑1 ↓2`; a detached HEAD shows its commit. +pub fn label(s: &RepositoryStatus) -> String { + let mut out = match (&s.branch, &s.commit) { + (Some(b), _) => b.clone(), + (None, Some(c)) => c.chars().take(8).collect(), + (None, None) => "(no commits)".to_owned(), + }; + if s.upstream.is_some() { + if s.ahead > 0 { + out.push_str(&format!(" ↑{}", s.ahead)); + } + if s.behind > 0 { + out.push_str(&format!(" ↓{}", s.behind)); + } + } + if let Some(op) = &s.operation { + out.push_str(&format!(" ({op})")); + } + out +} + +fn ago(secs: u64) -> String { + match secs { + 0..60 => "just now".into(), + 60..3600 => format!("{} min ago", secs / 60), + _ => format!("{} h ago", secs / 3600), + } +} + +fn tooltip(s: &RepositoryStatus) -> String { + let mut lines = vec![s.root.clone()]; + match &s.upstream { + Some(up) => lines.push(format!("{} ahead, {} behind {up}", s.ahead, s.behind)), + None => lines.push("No upstream branch".into()), + } + if s.changed_files > 0 { + lines.push(format!("{} changed files", s.changed_files)); + } + let last = storage::get(&format!("{LAST_FETCH}{}", s.root)).ok().flatten().and_then(|v| v.parse::<u64>().ok()); + lines.push(match last { + Some(t) => format!("Fetched {}", ago(now_secs().saturating_sub(t))), + None => "Not fetched yet".into(), + }); + lines.push("Click to fetch now".into()); + lines.join("\n") +} + +/// Shows `project`'s repository in the status bar (or hides the item). +fn show(project: &Project) -> Result<(), Error> { + match project.vcs_status()? { + Some(s) => { + let _ = storage::set(SHOWN, &project.root()?); + host::set_status_item( + ITEM, + &StatusItem { text: label(&s), tooltip: Some(tooltip(&s)), command: Some("fetch".into()) }, + ); + } + None => host::remove_status_item(ITEM), + } + Ok(()) +} + +/// The project the status bar follows: the invoking window's, else the one +/// shown last, else the first open one. +fn current(ctx: &Invocation) -> Option<Project> { + if let Some(p) = ctx.project() { + return Some(p); + } + let projects = host::projects().ok()?; + let shown = storage::get(SHOWN).ok().flatten(); + let index = projects.iter().position(|p| p.root().ok() == shown).unwrap_or(0); + projects.into_iter().nth(index) +} + +/// Fetches `projects` in one cancellable task. Returns how many were fetched. +fn fetch(projects: &[Project]) -> Result<usize, Error> { + let task = host::begin_task("Fetching", true); + let total = projects.len() as u32; + let mut fetched = 0; + let mut last_error = None; + for (i, p) in projects.iter().enumerate() { + if task.is_cancelled() { + return Err(Error::Cancelled); + } + let root = p.root()?; + let name = root.rsplit(['/', '\\']).next().unwrap_or(&root).to_owned(); + task.report(i as u32, Some(total), Some(&name)); + if p.vcs_status()?.is_none() { + continue; + } + match p.vcs_fetch() { + Ok(()) => { + fetched += 1; + let _ = storage::set(&format!("{LAST_FETCH}{root}"), &now_secs().to_string()); + } + Err(Error::Cancelled) => return Err(Error::Cancelled), + Err(e) => { + host::log(openidea_plugin::LogLevel::Warn, &format!("fetch {root}: {e}")); + last_error = Some(e); + } + } + } + task.report(total, Some(total), None); + match last_error { + Some(e) if fetched == 0 => Err(e), + _ => Ok(fetched), + } +} + +impl Plugin for GitToolbox { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + let project = current(ctx).ok_or("no project is open")?; + match id { + "fetch" => { + let n = fetch(std::slice::from_ref(&project))?; + show(&project)?; + Ok((n == 0).then(|| "The project is not in a git repository.".to_owned())) + } + "refresh" => { + show(&project)?; + Ok(None) + } + _ => Err(format!("unknown command {id}")), + } + } + + fn handle_events(&mut self, events: &[Event], ctx: &Invocation) { + if events.iter().any(|e| *e == Event::Scheduled) { + let projects = host::projects().unwrap_or_default(); + if let Err(e) = fetch(&projects) + && e != Error::Cancelled + { + host::notify(NotificationLevel::Warning, "Git Toolbox: fetch failed", &e.to_string()); + } + } + if let Some(p) = current(ctx) { + let _ = show(&p); + } + } +} + +register_plugin!(GitToolbox); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + fn status(ahead: u32, behind: u32) -> RepositoryStatus { + RepositoryStatus { + root: "/project".into(), + branch: Some("main".into()), + commit: Some("0123456789abcdef".into()), + upstream: Some("origin/main".into()), + ahead, + behind, + ..RepositoryStatus::default() + } + } + + #[test] + fn labels() { + assert_eq!(label(&status(0, 0)), "main"); + assert_eq!(label(&status(1, 2)), "main ↑1 ↓2"); + let detached = RepositoryStatus { branch: None, ..status(0, 0) }; + assert_eq!(label(&detached), "01234567"); + let rebasing = RepositoryStatus { operation: Some("rebase".into()), ..status(0, 3) }; + assert_eq!(label(&rebasing), "main ↓3 (rebase)"); + } + + #[test] + fn scheduled_fetch_updates_the_status_bar() { + let host = MockHost::install(); + host.add_project("/other"); + host.set_vcs_status(Some(status(0, 4))); + GitToolbox.handle_events(&[Event::Scheduled], &host.invocation()); + assert_eq!(host.fetches(), 2, "every open project"); + let item = &host.status_items()["branch"]; + assert_eq!(item.text, "main ↓4"); + assert!(item.tooltip.as_deref().unwrap().contains("Fetched just now")); + assert_eq!(item.command.as_deref(), Some("fetch")); + let task = &host.tasks()[0]; + assert!(task.ended && task.reports.last() == Some(&(2, Some(2), None))); + } + + #[test] + fn focus_changes_only_refresh() { + let host = MockHost::install(); + host.set_vcs_status(Some(status(1, 0))); + GitToolbox.handle_events(&[Event::EditorFocused(Some("/project/a.rs".into()))], &host.invocation()); + assert_eq!(host.fetches(), 0); + assert_eq!(host.status_items()["branch"].text, "main ↑1"); + host.set_vcs_status(None); + GitToolbox.handle_events(&[Event::VcsChanged("/project".into())], &host.invocation()); + assert!(host.status_items().is_empty(), "not a repository any more"); + } + + #[test] + fn failures_and_cancellation() { + let host = MockHost::install(); + host.set_vcs_status(Some(status(0, 0))); + host.fail_fetch(Some(Error::Failed("no network".into()))); + GitToolbox.handle_events(&[Event::Scheduled], &host.invocation()); + assert_eq!(host.notifications()[0].body, "no network"); + let err = GitToolbox.run_command("fetch", &host.invocation(), None).unwrap_err(); + assert_eq!(err, "no network"); + host.fail_fetch(Some(Error::Cancelled)); + GitToolbox.handle_events(&[Event::Scheduled], &host.invocation()); + assert_eq!(host.notifications().len(), 1, "a cancelled fetch is not an error to report"); + } + + #[test] + fn without_vcs_write_it_still_shows_the_branch() { + let host = MockHost::install().with_capabilities(&["project.read", "vcs.read", "storage"]); + host.set_vcs_status(Some(status(0, 1))); + GitToolbox.handle_events(&[Event::Scheduled], &host.invocation()); + assert!(host.notifications()[0].body.contains("vcs.write")); + assert_eq!(host.status_items()["branch"].text, "main ↓1"); + } +} diff --git a/plugins/official/key-promoter/.gitignore b/plugins/official/key-promoter/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/key-promoter/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/key-promoter/Cargo.lock b/plugins/official/key-promoter/Cargo.lock new file mode 100644 index 000000000..089dc6102 --- /dev/null +++ b/plugins/official/key-promoter/Cargo.lock @@ -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-key-promoter" +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" diff --git a/plugins/official/key-promoter/Cargo.toml b/plugins/official/key-promoter/Cargo.toml new file mode 100644 index 000000000..9ecb09a77 --- /dev/null +++ b/plugins/official/key-promoter/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-key-promoter" +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" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm new file mode 100644 index 000000000..1ca08c5f4 Binary files /dev/null and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/plugin.toml b/plugins/official/key-promoter/plugin.toml new file mode 100644 index 000000000..10f032b4a --- /dev/null +++ b/plugins/official/key-promoter/plugin.toml @@ -0,0 +1,31 @@ +id = "openidea.key-promoter" +name = "Key Promoter" +version = "0.1.0" +description = "Shows the keyboard shortcut of an action you ran with the mouse, and counts how often you could have used it." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: shortcut tips, statistics, per-action \"don't show again\"." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +# Actions arrive in batches (a few seconds apart while the plugin is idle), +# so it is not woken for every click. +activation = ["on-event:action-performed", "on-command:*"] + +[capabilities] +# `events.input`: which actions you run and how (keyboard, menu, toolbar…). +required = ["events.input", "storage"] + +[[contributes.commands]] +id = "statistics" +title = "Key Promoter: Show Statistics" +category = "Key Promoter" + +[[contributes.commands]] +id = "reset" +title = "Key Promoter: Reset Statistics" +category = "Key Promoter" + +[[contributes.commands]] +id = "suppress-last" +title = "Key Promoter: Don't Show Tips for the Last Action" +category = "Key Promoter" diff --git a/plugins/official/key-promoter/src/lib.rs b/plugins/official/key-promoter/src/lib.rs new file mode 100644 index 000000000..97ce5d863 --- /dev/null +++ b/plugins/official/key-promoter/src/lib.rs @@ -0,0 +1,161 @@ +//! Key Promoter for OpenIDEA: when you run an action with the mouse (menu, +//! toolbar, context menu) that has a keyboard shortcut, it tells you the +//! shortcut. Counts persist in storage, so the tips survive restarts and the +//! instance being reclaimed while you work. + +use openidea_plugin::host::{self, storage}; +use openidea_plugin::{ActionSource, Event, Invocation, NotificationLevel, Plugin, register_plugin}; + +/// Tips for one action are shown on these counts, then every `REPEAT_EVERY`. +const FIRST_TIPS: [u32; 3] = [1, 3, 10]; +const REPEAT_EVERY: u32 = 25; +const COUNT: &str = "count:"; +const TITLE: &str = "title:"; +const SUPPRESSED: &str = "suppressed:"; +const LAST: &str = "last-action"; + +#[derive(Default)] +pub struct KeyPromoter; + +fn get_u32(key: &str) -> u32 { + storage::get(key).ok().flatten().and_then(|v| v.parse().ok()).unwrap_or(0) +} + +impl Plugin for KeyPromoter { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + match id { + "statistics" => { + let mut rows: Vec<(u32, String)> = storage::keys(COUNT)? + .into_iter() + .map(|k| { + let action = &k[COUNT.len()..]; + let title = storage::get(&format!("{TITLE}{action}")) + .ok() + .flatten() + .unwrap_or_else(|| action.to_owned()); + (get_u32(&k), title) + }) + .collect(); + rows.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + let body = if rows.is_empty() { + "No missed shortcuts yet.".to_owned() + } else { + rows.iter().take(15).map(|(n, t)| format!("{n}× {t}")).collect::<Vec<_>>().join("\n") + }; + host::notify(NotificationLevel::Info, "Key Promoter: missed shortcuts", &body); + Ok(Some(body)) + } + "reset" => { + for k in storage::keys("")? { + storage::delete(&k)?; + } + Ok(Some("Key Promoter statistics were reset.".into())) + } + "suppress-last" => { + let action = storage::get(LAST)?.ok_or("no tip was shown yet")?; + storage::set(&format!("{SUPPRESSED}{action}"), "1")?; + Ok(Some(format!("No more tips for {action}."))) + } + _ => Err(format!("unknown command {id}")), + } + } + + fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) { + // One tip per batch at most: the last action that deserves one. + let mut tip = None; + for e in events { + let Event::ActionPerformed(a) = e else { continue }; + if a.source == ActionSource::Keyboard { + continue; + } + let Some(shortcut) = &a.shortcut else { continue }; + if storage::get(&format!("{SUPPRESSED}{}", a.action)).ok().flatten().is_some() { + continue; + } + let key = format!("{COUNT}{}", a.action); + let n = get_u32(&key) + 1; + if storage::set(&key, &n.to_string()).is_err() { + return; // storage denied or full: nothing to count with + } + let _ = storage::set(&format!("{TITLE}{}", a.action), &a.title); + if FIRST_TIPS.contains(&n) || n % REPEAT_EVERY == 0 { + tip = Some((a.clone(), shortcut.clone(), n)); + } + } + if let Some((a, shortcut, n)) = tip { + let _ = storage::set(LAST, &a.action); + let times = if n == 1 { "once".to_owned() } else { format!("{n} times") }; + host::notify( + NotificationLevel::Info, + &format!("{}: {shortcut}", a.title), + &format!("You ran it with the mouse {times}. Press {shortcut} next time."), + ); + } + } +} + +register_plugin!(KeyPromoter); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::ActionPerformed; + use openidea_plugin::mock::MockHost; + + fn action(id: &str, source: ActionSource, shortcut: Option<&str>) -> Event { + Event::ActionPerformed(ActionPerformed { + action: id.into(), + title: format!("Title of {id}"), + source, + shortcut: shortcut.map(str::to_owned), + }) + } + + #[test] + fn tips_for_mouse_use_of_actions_with_shortcuts() { + let host = MockHost::install(); + let ctx = host.invocation(); + let mut p = KeyPromoter; + p.handle_events( + &[ + action("a", ActionSource::Keyboard, Some("Ctrl+A")), + action("b", ActionSource::Menu, None), + action("c", ActionSource::Toolbar, Some("Ctrl+C")), + ], + &ctx, + ); + let notes = host.notifications(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].title, "Title of c: Ctrl+C"); + assert!(notes[0].body.contains("once")); + // Second use: counted, no tip (next at 3). + p.handle_events(&[action("c", ActionSource::Menu, Some("Ctrl+C"))], &ctx); + assert_eq!(host.notifications().len(), 1); + p.handle_events(&[action("c", ActionSource::Menu, Some("Ctrl+C"))], &ctx); + assert!(host.notifications()[1].body.contains("3 times")); + assert_eq!(host.storage()["count:c"], "3"); + } + + #[test] + fn statistics_survive_a_new_instance_and_can_be_suppressed() { + let host = MockHost::install(); + let ctx = host.invocation(); + KeyPromoter.handle_events(&[action("x", ActionSource::Menu, Some("F2"))], &ctx); + // A fresh instance (after idle reclaim) reads the counts back. + let stats = KeyPromoter.run_command("statistics", &ctx, None).unwrap().unwrap(); + assert_eq!(stats, "1× Title of x"); + KeyPromoter.run_command("suppress-last", &ctx, None).unwrap(); + KeyPromoter.handle_events(&vec![action("x", ActionSource::Menu, Some("F2")); 2], &ctx); + assert_eq!(host.notifications().len(), 2, "the tip and the statistics; none after suppressing"); + KeyPromoter.run_command("reset", &ctx, None).unwrap(); + assert!(host.storage().is_empty()); + } + + #[test] + fn without_storage_it_stays_quiet() { + let host = MockHost::install().with_capabilities(&["events.input"]); + KeyPromoter.handle_events(&[action("x", ActionSource::Menu, Some("F2"))], &host.invocation()); + assert!(host.notifications().is_empty()); + assert!(KeyPromoter.run_command("statistics", &host.invocation(), None).unwrap_err().contains("storage")); + } +} diff --git a/plugins/official/kubernetes/Cargo.lock b/plugins/official/kubernetes/Cargo.lock new file mode 100644 index 000000000..f2b67e04c --- /dev/null +++ b/plugins/official/kubernetes/Cargo.lock @@ -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-kubernetes" +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" diff --git a/plugins/official/kubernetes/Cargo.toml b/plugins/official/kubernetes/Cargo.toml new file mode 100644 index 000000000..85b529920 --- /dev/null +++ b/plugins/official/kubernetes/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "openidea-plugin-openidea-kubernetes" +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" diff --git a/plugins/official/kubernetes/dist/plugin.wasm b/plugins/official/kubernetes/dist/plugin.wasm new file mode 100644 index 000000000..acae4b47d Binary files /dev/null and b/plugins/official/kubernetes/dist/plugin.wasm differ diff --git a/plugins/official/kubernetes/plugin.toml b/plugins/official/kubernetes/plugin.toml new file mode 100644 index 000000000..786904bc1 --- /dev/null +++ b/plugins/official/kubernetes/plugin.toml @@ -0,0 +1,44 @@ +id = "openidea.kubernetes" +name = "Kubernetes" +version = "0.1.0" +description = "Browse your Kubernetes cluster — namespaces, pods, deployments and services — follow pod logs and open a shell in a pod. Uses your kubectl and its configuration." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: cluster tree, pod logs and shells in the terminal, context and namespace settings." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-view:cluster", "on-command:*"] + +# kubectl reads ~/.kube/config (outside the project): that access is part of +# the `process:kubectl` grant; the plugin itself reads no files. +[capabilities] +required = ["process:kubectl"] + +[[contributes.views]] +id = "cluster" +title = "Kubernetes" +icon = "toolwindow-services" +location = "right" + +[[contributes.commands]] +id = "refresh" +title = "Kubernetes: Refresh" +category = "Kubernetes" + +[[contributes.commands]] +id = "logs" +title = "Kubernetes: Follow Logs of Selected Pod" +category = "Kubernetes" + +[[contributes.commands]] +id = "shell" +title = "Kubernetes: Open Shell in Selected Pod" +category = "Kubernetes" + +[[contributes.menus]] +menu = "tools" +command = "refresh" + +[contributes.settings] +title = "Kubernetes" +schema = "settings.schema.json" diff --git a/plugins/official/kubernetes/settings.schema.json b/plugins/official/kubernetes/settings.schema.json new file mode 100644 index 000000000..49d969126 --- /dev/null +++ b/plugins/official/kubernetes/settings.schema.json @@ -0,0 +1,27 @@ +{ + "properties": { + "context": { + "type": "string", + "title": "Context", + "description": "kubectl context to use; empty for the current one.", + "default": "" + }, + "namespaces": { + "type": "string", + "title": "Namespaces", + "description": "Comma-separated namespaces to show; empty for all.", + "default": "" + }, + "tailLines": { + "type": "integer", + "title": "Log lines to show first", + "default": 200 + }, + "shell": { + "type": "string", + "title": "Shell in pods", + "enum": ["sh", "bash"], + "default": "sh" + } + } +} diff --git a/plugins/official/kubernetes/src/lib.rs b/plugins/official/kubernetes/src/lib.rs new file mode 100644 index 000000000..9e41bf1b5 --- /dev/null +++ b/plugins/official/kubernetes/src/lib.rs @@ -0,0 +1,369 @@ +//! Kubernetes for OpenIDEA: a tool window with the cluster as a tree +//! (namespaces → pods, deployments, services), loaded level by level as you +//! expand it. Pod logs and shells open in the IDE's terminal (`kubectl logs +//! -f`, `kubectl exec -it`), which streams them; everything goes through the +//! user's own `kubectl` (pinned by `process:kubectl`) and its configuration. +//! In a remote project it is the remote machine's `kubectl`. + +use openidea_plugin::host; +use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Error, Invocation, Plugin, register_plugin}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +pub const VIEW: &str = "cluster"; +const KINDS: [(&str, &str, &str); 3] = + [("pods", "Pods", "pod"), ("deployments", "Deployments", "deployment"), ("services", "Services", "service")]; + +/// One listed object: its name and a short status. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Object { + pub name: String, + pub status: String, +} + +#[derive(Default)] +pub struct Kubernetes { + /// Namespaces, once listed. + namespaces: Option<Vec<String>>, + /// `namespace/kind` → objects, once listed. + objects: BTreeMap<String, Vec<Object>>, + expanded: BTreeSet<String>, + selected: Option<String>, + /// The last kubectl failure (shown instead of the tree). + error: Option<String>, +} + +fn kubectl(args: &[&str]) -> Result<Value, Error> { + let mut all: Vec<String> = Vec::new(); + if let Some(context) = host::setting_string("context").filter(|c| !c.is_empty()) { + all.push("--context".into()); + all.push(context); + } + all.extend(args.iter().map(|a| (*a).to_owned())); + all.extend(["-o".into(), "json".into()]); + let out = host::run_process("kubectl", &all, None)?; + if out.status != Some(0) { + let reason = out.stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or("kubectl failed"); + return Err(Error::Failed(reason.to_owned())); + } + serde_json::from_str(&out.stdout).map_err(|e| Error::Failed(format!("kubectl output: {e}"))) +} + +fn items(list: &Value) -> impl Iterator<Item = &Value> { + list.get("items").and_then(Value::as_array).into_iter().flatten() +} + +fn name(item: &Value) -> String { + item.pointer("/metadata/name").and_then(Value::as_str).unwrap_or("?").to_owned() +} + +/// `Running 2/2`, `CrashLoopBackOff`, `3/3 ready`, `ClusterIP 10.0.0.1`. +pub fn status_of(kind: &str, item: &Value) -> String { + let s = |p: &str| item.pointer(p).and_then(Value::as_str).unwrap_or("").to_owned(); + let n = |p: &str| item.pointer(p).and_then(Value::as_u64).unwrap_or(0); + match kind { + "pods" => { + let statuses = item.pointer("/status/containerStatuses").and_then(Value::as_array); + let waiting = + statuses.into_iter().flatten().find_map(|c| c.pointer("/state/waiting/reason").and_then(Value::as_str)); + let ready = statuses.into_iter().flatten().filter(|c| c.get("ready") == Some(&Value::Bool(true))).count(); + let total = statuses.map_or(0, Vec::len); + match waiting { + Some(reason) => reason.to_owned(), + None => format!("{} {ready}/{total}", s("/status/phase")), + } + } + "deployments" => format!("{}/{} ready", n("/status/readyReplicas"), n("/spec/replicas")), + "services" => format!("{} {}", s("/spec/type"), s("/spec/clusterIP")).trim().to_owned(), + _ => String::new(), + } +} + +impl Kubernetes { + fn namespaces(&mut self) -> Result<Vec<String>, Error> { + if let Some(ns) = &self.namespaces { + return Ok(ns.clone()); + } + let wanted: Vec<String> = host::setting_string("namespaces") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect(); + let ns = if wanted.is_empty() { + let mut all: Vec<String> = items(&kubectl(&["get", "namespaces"])?).map(name).collect(); + all.sort(); + all + } else { + wanted + }; + self.namespaces = Some(ns.clone()); + Ok(ns) + } + + fn objects(&mut self, namespace: &str, kind: &str) -> Result<Vec<Object>, Error> { + let key = format!("{namespace}/{kind}"); + if let Some(o) = self.objects.get(&key) { + return Ok(o.clone()); + } + let list = kubectl(&["get", kind, "-n", namespace])?; + let mut objects: Vec<Object> = + items(&list).map(|i| Object { name: name(i), status: status_of(kind, i) }).collect(); + objects.sort_by(|a, b| a.name.cmp(&b.name)); + self.objects.insert(key, objects.clone()); + Ok(objects) + } + + fn refresh(&mut self) { + self.namespaces = None; + self.objects.clear(); + self.error = None; + host::refresh_view(VIEW); + } + + /// `ns/pods/name` of the selected pod. + fn selected_pod(&self) -> Option<(String, String)> { + let key = self.selected.as_deref()?; + let mut parts = key.splitn(3, '/'); + let (ns, kind, name) = (parts.next()?, parts.next()?, parts.next()?); + (kind == "pods").then(|| (ns.to_owned(), name.to_owned())) + } + + fn open_logs(&self) -> Result<(), Error> { + let (ns, pod) = self.selected_pod().ok_or_else(|| Error::Invalid("select a pod first".into()))?; + let tail = host::setting_number("tailLines").map_or(200, |n| n.max(0.0) as u64); + let mut args = self.context_args(); + args.extend(["logs".into(), "-f".into(), format!("--tail={tail}"), "-n".into(), ns, pod.clone()]); + host::open_terminal(&format!("logs: {pod}"), "kubectl", &args) + } + + fn open_shell(&self) -> Result<(), Error> { + let (ns, pod) = self.selected_pod().ok_or_else(|| Error::Invalid("select a pod first".into()))?; + let shell = host::setting_string("shell").unwrap_or_else(|| "sh".into()); + let mut args = self.context_args(); + args.extend(["exec".into(), "-it".into(), "-n".into(), ns, pod.clone(), "--".into(), shell]); + host::open_terminal(&format!("shell: {pod}"), "kubectl", &args) + } + + fn context_args(&self) -> Vec<String> { + match host::setting_string("context").filter(|c| !c.is_empty()) { + Some(c) => vec!["--context".into(), c], + None => Vec::new(), + } + } + + fn tree(&mut self) -> Result<Vec<TreeItem>, Error> { + let mut out = Vec::new(); + for ns in self.namespaces()? { + let open = self.expanded.contains(&ns); + out.push(TreeItem::new(&ns, &ns).icon("folder").expandable(open)); + if !open { + continue; + } + for (kind, label, icon) in KINDS { + let key = format!("{ns}/{kind}"); + let open_kind = self.expanded.contains(&key); + let mut group = TreeItem::new(&key, label).parent(&ns).icon("folder").expandable(open_kind); + if open_kind { + let objects = self.objects(&ns, kind)?; + group = group.detail(&objects.len().to_string()); + out.push(group); + for o in objects { + out.push( + TreeItem::new(&format!("{key}/{}", o.name), &o.name) + .parent(&key) + .icon(icon) + .detail(&o.status), + ); + } + } else { + out.push(group); + } + } + } + Ok(out) + } +} + +impl Plugin for Kubernetes { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + match id { + "refresh" => self.refresh(), + "logs" => self.open_logs()?, + "shell" => self.open_shell()?, + _ => return Err(format!("unknown command {id}")), + } + Ok(None) + } +} + +impl Views for Kubernetes { + fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + let pod = self.selected_pod().is_some(); + let toolbar = |v: View| { + v.button(Button::new("refresh", "Refresh").icon("refresh")) + .button(Button::new("logs", "Follow Logs").icon("file-log").enabled(pod)) + .button(Button::new("shell", "Open Shell").icon("terminal").enabled(pod)) + }; + if let Some(e) = &self.error { + return Ok(toolbar(View::message(&format!("kubectl: {e}")))); + } + match self.tree() { + Ok(items) => { + let context = host::setting_string("context") + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| "current context".into()); + let namespaces = self.namespaces.as_ref().map_or(0, Vec::len); + Ok(toolbar(View::new(Body::Tree(items))).status(&format!("{context} · {namespaces} namespaces"))) + } + Err(Error::Denied(cap)) => { + Ok(toolbar(View::message(&format!("Allow {cap} in Settings › Plugins to browse the cluster.")))) + } + Err(e) => { + self.error = Some(e.to_string()); + Ok(toolbar(View::message(&format!("kubectl: {e}")))) + } + } + } + + fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + match event { + ViewEvent::Expand(key) => { + self.expanded.insert(key); + } + ViewEvent::Collapse(key) => { + self.expanded.remove(&key); + } + ViewEvent::Select(key) => { + self.selected = Some(key); + } + ViewEvent::Activate(key) => { + self.selected = Some(key); + return if self.selected_pod().is_some() { self.open_logs() } else { Ok(()) }; + } + ViewEvent::Button(b) => match b.as_str() { + "refresh" => { + self.refresh(); + return Ok(()); + } + "logs" => return self.open_logs(), + "shell" => return self.open_shell(), + _ => return Ok(()), + }, + _ => return Ok(()), + } + host::refresh_view(VIEW); + Ok(()) + } +} + +register_plugin!(Kubernetes, views); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::ProcessOutput; + use openidea_plugin::mock::MockHost; + + fn ok(stdout: &str) -> Result<ProcessOutput, Error> { + Ok(ProcessOutput { status: Some(0), stdout: stdout.into(), stderr: String::new() }) + } + + fn cluster(host: &MockHost) { + host.on_process("kubectl", |args, _| { + let a: Vec<&str> = args.iter().map(String::as_str).collect(); + match a.as_slice() { + ["get", "namespaces", ..] => ok(r#"{"items":[{"metadata":{"name":"kube-system"}},{"metadata":{"name":"default"}}]}"#), + ["get", "pods", "-n", "default", ..] => ok(r#"{"items":[ + {"metadata":{"name":"web-1"},"status":{"phase":"Running","containerStatuses":[{"ready":true},{"ready":false}]}}, + {"metadata":{"name":"job-1"},"status":{"phase":"Pending","containerStatuses":[{"ready":false,"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}} + ]}"#), + ["get", kind, "-n", _, ..] if *kind == "deployments" || *kind == "services" => ok(r#"{"items":[]}"#), + other => panic!("unexpected kubectl {other:?}"), + } + }); + } + + fn tree(p: &mut Kubernetes, host: &MockHost) -> Vec<TreeItem> { + match p.render(VIEW, None, &host.invocation()).unwrap().body { + Body::Tree(items) => items, + other => panic!("{other:?}"), + } + } + + #[test] + fn lists_namespaces_then_objects_when_expanded() { + let host = MockHost::install().with_capabilities(&["process:kubectl"]); + cluster(&host); + let mut p = Kubernetes::default(); + let labels = |items: &[TreeItem]| items.iter().map(|i| i.label.clone()).collect::<Vec<_>>(); + assert_eq!(labels(&tree(&mut p, &host)), ["default", "kube-system"]); + p.handle(VIEW, ViewEvent::Expand("default".into()), &host.invocation()).unwrap(); + p.handle(VIEW, ViewEvent::Expand("default/pods".into()), &host.invocation()).unwrap(); + let items = tree(&mut p, &host); + let pods: Vec<(String, Option<String>)> = items + .iter() + .filter(|i| i.parent.as_deref() == Some("default/pods")) + .map(|i| (i.label.clone(), i.detail.clone())) + .collect(); + assert_eq!( + pods, + [("job-1".into(), Some("CrashLoopBackOff".into())), ("web-1".into(), Some("Running 1/2".into()))] + ); + // Cached until Refresh. + let runs = host.process_runs().len(); + tree(&mut p, &host); + assert_eq!(host.process_runs().len(), runs); + p.handle(VIEW, ViewEvent::Button("refresh".into()), &host.invocation()).unwrap(); + tree(&mut p, &host); + assert!(host.process_runs().len() > runs); + } + + #[test] + fn logs_and_shells_open_in_the_terminal() { + let host = MockHost::install().with_capabilities(&["process:kubectl"]); + cluster(&host); + host.set_setting("context", "\"prod\""); + host.set_setting("tailLines", "50"); + let mut p = Kubernetes::default(); + p.handle(VIEW, ViewEvent::Activate("default/pods/web-1".into()), &host.invocation()).unwrap(); + p.handle(VIEW, ViewEvent::Button("shell".into()), &host.invocation()).unwrap(); + let t = host.terminals(); + assert_eq!(t[0].title, "logs: web-1"); + assert_eq!(t[0].args, ["--context", "prod", "logs", "-f", "--tail=50", "-n", "default", "web-1"]); + assert_eq!(t[1].args, ["--context", "prod", "exec", "-it", "-n", "default", "web-1", "--", "sh"]); + // A namespace is not a pod. + p.handle(VIEW, ViewEvent::Select("default".into()), &host.invocation()).unwrap(); + assert!(p.run_command("logs", &host.invocation(), None).is_err()); + } + + #[test] + fn without_kubectl_the_view_explains_what_is_missing() { + let host = MockHost::install().with_capabilities(&[]); + let mut p = Kubernetes::default(); + let view = p.render(VIEW, None, &host.invocation()).unwrap(); + assert_eq!( + view.body, + Body::Message("Allow process:kubectl in Settings › Plugins to browse the cluster.".into()) + ); + + let host = MockHost::install().with_capabilities(&["process:kubectl"]); + host.on_process("kubectl", |_, _| { + Ok(ProcessOutput { + status: Some(1), + stdout: String::new(), + stderr: "The connection to the server was refused".into(), + }) + }); + let mut p = Kubernetes::default(); + let view = p.render(VIEW, None, &host.invocation()).unwrap(); + assert_eq!(view.body, Body::Message("kubectl: The connection to the server was refused".into())); + } +} diff --git a/plugins/official/material-icons/generate.py b/plugins/official/material-icons/generate.py new file mode 100644 index 000000000..d1f024ed5 --- /dev/null +++ b/plugins/official/material-icons/generate.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Generates icons/*.svg and icons/theme.json of the Material Icons plugin. + +Flat, original icons on the Material colour palette: language files are +coloured badges with a short label, well-known files get glyphs, folders are +tinted by role. Edit the tables below and run `python3 generate.py`, then +`openidea-plugin check .`. Not part of the package (plugin.toml does not +reference it), so it is not installed. +""" + +import json +import pathlib + +HERE = pathlib.Path(__file__).resolve().parent +OUT = HERE / "icons" +FONT = "system-ui, -apple-system, Segoe UI, Roboto, sans-serif" + + +def svg(body: str) -> str: + return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">{body}</svg>\n' + + +def badge(color: str, label: str, fg: str = "#fff") -> str: + size = {1: 9.5, 2: 7.4, 3: 5.6}[len(label)] + y = 8 + size * 0.36 + label = label.replace("&", "&").replace("<", "<").replace(">", ">") + return svg( + f'<rect x="1" y="1" width="14" height="14" rx="3" fill="{color}"/>' + f'<text x="8" y="{y:.2f}" fill="{fg}" font-family="{FONT}" font-size="{size}" ' + f'font-weight="700" text-anchor="middle" letter-spacing="-0.2">{label}</text>' + ) + + +def doc(color: str, inner: str = "") -> str: + return svg( + f'<path fill="{color}" d="M4 1h5.6L13 4.4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>' + f'<path fill="#fff" fill-opacity=".45" d="M9.6 1v3.4H13z"/>{inner}' + ) + + +def folder(color: str, open_: bool = False) -> str: + if open_: + return svg( + f'<path fill="{color}" d="M1.5 2.5h4.6L7.6 4h6.4a1 1 0 0 1 1 1v1.5H4.2L2 13.5H1.5a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/>' + f'<path fill="{color}" fill-opacity=".75" d="M4.2 7h11.3l-2.2 6.5H2z"/>' + ) + return svg( + f'<path fill="{color}" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/>' + ) + + +def stroke(d: str, color: str = "#fff", width: float = 1.4) -> str: + return ( + f'<path d="{d}" fill="none" stroke="{color}" stroke-width="{width}" ' + f'stroke-linecap="round" stroke-linejoin="round"/>' + ) + + +def gear(color: str) -> str: + teeth = "".join( + f'<rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate({a} 8 8)"/>' + for a in range(0, 360, 45) + ) + return svg(f'<g fill="{color}">{teeth}</g><circle cx="8" cy="8" r="3.6" fill="none" stroke="{color}" stroke-width="2.8"/>') + + +def atom(color: str) -> str: + rings = "".join( + f'<ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate({a} 8 8)"/>' for a in (0, 60, 120) + ) + return svg(f'<g fill="none" stroke="{color}" stroke-width="1.1">{rings}</g><circle cx="8" cy="8" r="1.4" fill="{color}"/>') + + +def package(color: str) -> str: + return svg( + f'<path fill="{color}" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/>' + '<path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/>' + '<path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/>' + ) + + +GLYPHS = { + "file": doc("#90a4ae"), + "text": doc("#90a4ae", stroke("M5 7.5h6M5 10h6M5 12.5h4", width=1.1)), + "markdown": svg( + '<rect x="0.5" y="3" width="15" height="10" rx="1.6" fill="#42a5f5"/>' + + stroke("M3 10.5v-5l2 2.5 2-2.5v5", width=1.3) + + stroke("M11 5.5v5M9.2 8.8l1.8 1.8 1.8-1.8", width=1.3) + ), + "image": svg( + '<rect x="1" y="2.5" width="14" height="11" rx="1.5" fill="#26a69a"/>' + '<circle cx="5" cy="6" r="1.5" fill="#fff"/>' + '<path fill="#fff" d="M2.5 12l3.5-4 2.5 2.6L11 7.4l3 4.6z"/>' + ), + "archive": svg( + '<rect x="2" y="1" width="12" height="14" rx="1.5" fill="#af8e5b"/>' + '<path fill="#fff" d="M7 1h2v1.6H7zm0 3.2h2v1.6H7zM6.4 7.6h3.2v4.4H6.4z"/>' + '<rect x="7.2" y="9.8" width="1.6" height="1.2" fill="#af8e5b"/>' + ), + "lock": svg( + '<rect x="3" y="7" width="10" height="8" rx="1.5" fill="#90a4ae"/>' + + stroke("M5.5 7V5a2.5 2.5 0 0 1 5 0v2", "#90a4ae", 1.8) + + '<circle cx="8" cy="11" r="1.2" fill="#fff"/>' + ), + "git": svg( + '<path fill="#f4511e" d="M8 .6l7.4 7.4L8 15.4.6 8z"/>' + + stroke("M6.3 5v6M6.3 6.4c0 1.8 3.7.4 3.7 2.1", width=1.1) + + '<g fill="#fff"><circle cx="6.3" cy="5" r="1.1"/><circle cx="6.3" cy="11" r="1.1"/><circle cx="10" cy="8.6" r="1.1"/></g>' + ), + "settings": gear("#78909c"), + "env": gear("#fbc02d"), + "readme": svg( + '<circle cx="8" cy="8" r="7" fill="#42a5f5"/>' + '<rect x="7.1" y="6.8" width="1.8" height="5.2" rx=".6" fill="#fff"/><circle cx="8" cy="4.6" r="1.1" fill="#fff"/>' + ), + "license": svg( + '<rect x="1.5" y="2" width="13" height="9.5" rx="1" fill="#ffca28"/>' + + stroke("M3.6 4.6h6.4M3.6 6.8h4", "#fff", 1.1) + + '<path fill="#ef6c00" d="M9.6 11.4l-.7 3.4 2.1-1.1 2.1 1.1-.7-3.4z"/><circle cx="11" cy="10" r="2.3" fill="#ef6c00"/>' + ), + "shell": svg( + '<rect x="1" y="2" width="14" height="12" rx="2" fill="#4caf50"/>' + + stroke("M4 5.5l2.5 2.5L4 10.5", width=1.5) + + stroke("M8 10.8h4", width=1.5) + ), + "database": svg( + '<path fill="#ffca28" d="M2.5 3.5v9c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2v-9z"/>' + '<ellipse cx="8" cy="3.5" rx="5.5" ry="2" fill="#ffe082"/>' + + stroke("M2.5 7.5c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2", "#ffe082", 0.9) + ), + "html": svg('<rect x="1" y="1" width="14" height="14" rx="3" fill="#e44d26"/>' + stroke("M6 5L3.5 8 6 11M10 5l2.5 3L10 11")), + "xml": svg('<rect x="1" y="1" width="14" height="14" rx="3" fill="#ff9800"/>' + stroke("M5.5 5L3 8l2.5 3M10.5 5L13 8l-2.5 3M9 4.5l-2 7", width=1.2)), + "json": badge("#fbc02d", "{}", "#5d4037"), + "react": atom("#00bcd4"), + "react-ts": atom("#0288d1"), + "docker": svg( + '<rect x="1" y="1" width="14" height="14" rx="3" fill="#0288d1"/>' + '<g fill="#fff"><rect x="3" y="7" width="2.2" height="2"/><rect x="5.6" y="7" width="2.2" height="2"/>' + '<rect x="8.2" y="7" width="2.2" height="2"/><rect x="5.6" y="4.6" width="2.2" height="2"/>' + '<rect x="8.2" y="4.6" width="2.2" height="2"/></g>' + '<path fill="#fff" d="M2.6 9.6h10.6c.4-.8 1-1.2 1.6-1.2-.3 3-2.6 4.6-6.4 4.6-3.2 0-5.2-1.2-5.8-3.4z"/>' + ), + "npm": package("#cb3837"), + "cargo": package("#ff7043"), + "gomod": package("#00acc1"), + "pnpm": package("#f9ad00"), + "test": svg( + '<rect x="1" y="1" width="14" height="14" rx="3" fill="#8bc34a"/>' + stroke("M4.2 8.2l2.4 2.4 5.2-5.2", width=1.7) + ), + "vite": badge("#9575cd", "V"), + "tsconfig": badge("#0288d1", "TS", "#bbdefb"), + "makefile": badge("#ef5350", "M"), + "editorconfig": gear("#90a4ae"), + "font": badge("#ef5350", "Aa"), + "pdf": badge("#e53935", "PDF"), + "svg": badge("#ffb300", "SVG", "#4e342e"), + "audio": svg('<rect x="1" y="1" width="14" height="14" rx="3" fill="#ec407a"/><path fill="#fff" d="M6 4.5l6-1.5v7.3a1.8 1.8 0 1 1-1.2-1.7V5.4L7.2 6.3v5.4a1.8 1.8 0 1 1-1.2-1.7z"/>'), + "video": svg('<rect x="1" y="2.5" width="14" height="11" rx="2" fill="#ff7043"/><path fill="#fff" d="M6.4 5.5v5l4-2.5z"/>'), +} + +# extension → (icon id, colour, label[, label colour]) +LANGUAGES = { + "rust": ("#ff7043", "RS", ["rs"]), + "typescript": ("#0288d1", "TS", ["ts", "mts", "cts"]), + "typescript-def": ("#0277bd", "D", ["d.ts", "d.mts", "d.cts"]), + "javascript": ("#fbc02d", "JS", ["js", "mjs", "cjs"], "#3e2723"), + "python": ("#3572a5", "PY", ["py", "pyi", "pyw"], "#ffd54f"), + "go": ("#00acc1", "GO", ["go"]), + "java": ("#e53935", "J", ["java"]), + "class": ("#8d6e63", "CL", ["class", "jar"]), + "kotlin": ("#7e57c2", "KT", ["kt", "kts"]), + "groovy": ("#4db6ac", "GR", ["groovy", "gradle"]), + "scala": ("#d32f2f", "SC", ["scala", "sc", "sbt"]), + "c": ("#5c6bc0", "C", ["c"]), + "cpp": ("#1e88e5", "C++", ["cpp", "cc", "cxx", "c++"]), + "header": ("#9575cd", "H", ["h", "hpp", "hh", "hxx", "inl"]), + "csharp": ("#388e3c", "C#", ["cs", "csx"]), + "fsharp": ("#378bba", "F#", ["fs", "fsi", "fsx"]), + "swift": ("#ff5722", "SW", ["swift"]), + "objc": ("#607d8b", "OC", ["m", "mm"]), + "ruby": ("#c62828", "RB", ["rb", "erb", "rake", "gemspec"]), + "php": ("#7986cb", "PHP", ["php"]), + "perl": ("#5c6bc0", "PL", ["pl", "pm"]), + "lua": ("#3949ab", "LUA", ["lua"]), + "r": ("#1976d2", "R", ["r", "rmd"]), + "julia": ("#9558b2", "JL", ["jl"]), + "dart": ("#40c4ff", "D", ["dart"], "#01579b"), + "elixir": ("#7e57c2", "EX", ["ex", "exs", "heex"]), + "erlang": ("#c2185b", "ER", ["erl", "hrl"]), + "haskell": ("#7e57c2", "HS", ["hs", "lhs"]), + "ocaml": ("#ef6c00", "ML", ["ml", "mli"]), + "clojure": ("#64dd17", "CLJ", ["clj", "cljs", "cljc", "edn"], "#1b5e20"), + "zig": ("#f9a825", "Z", ["zig", "zon"], "#3e2723"), + "nim": ("#ffc107", "NIM", ["nim"], "#3e2723"), + "solidity": ("#546e7a", "SOL", ["sol"]), + "vue": ("#41b883", "V", ["vue"]), + "svelte": ("#ff3e00", "S", ["svelte"]), + "astro": ("#ff5d01", "A", ["astro"]), + "css": ("#42a5f5", "#", ["css"]), + "sass": ("#ec407a", "#", ["scss", "sass"]), + "less": ("#1565c0", "LS", ["less"]), + "yaml": ("#e53935", "YML", ["yaml", "yml"]), + "toml": ("#8d6e63", "TML", ["toml"]), + "ini": ("#78909c", "INI", ["ini", "cfg", "conf", "properties"]), + "csv": ("#43a047", "CSV", ["csv", "tsv"]), + "graphql": ("#e535ab", "GQ", ["graphql", "gql"]), + "proto": ("#26a69a", "PB", ["proto"]), + "wasm": ("#654ff0", "WA", ["wasm", "wat"]), + "nix": ("#5277c3", "NIX", ["nix"]), + "terraform": ("#7b42bc", "TF", ["tf", "tfvars", "hcl"]), + "powershell": ("#0277bd", "PS", ["ps1", "psm1", "psd1"]), + "batch": ("#546e7a", "BAT", ["bat", "cmd"]), + "tex": ("#008080", "TEX", ["tex", "sty", "bib"]), + "vim": ("#43a047", "VI", ["vim"]), + "diff": ("#66bb6a", "+-", ["diff", "patch"]), + "log": ("#78909c", "LOG", ["log"]), + "notebook": ("#f57c00", "NB", ["ipynb"]), + "http": ("#3f51b5", "API", ["http", "rest"]), +} + +GLYPH_EXTENSIONS = { + "text": ["txt", "text"], + "markdown": ["md", "markdown", "mdx"], + "image": ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "avif", "tif", "tiff", "icns"], + "svg": ["svg"], + "pdf": ["pdf"], + "archive": ["zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar", "zst"], + "lock": ["lock"], + "shell": ["sh", "bash", "zsh", "fish"], + "database": ["sql", "sqlite", "db", "prisma"], + "html": ["html", "htm", "xhtml"], + "xml": ["xml", "xsd", "xsl", "plist", "iml"], + "json": ["json", "jsonc", "json5"], + "react": ["jsx"], + "react-ts": ["tsx"], + "test": [f"{kind}.{ext}" for kind in ("test", "spec") for ext in ("ts", "tsx", "js", "jsx", "mjs", "mts")] + + ["test.rs", "test.py"], + "font": ["ttf", "otf", "woff", "woff2"], + "audio": ["mp3", "wav", "ogg", "flac", "m4a"], + "video": ["mp4", "mov", "webm", "mkv", "avi"], + "env": ["env"], +} + +FILE_NAMES = { + "readme": ["readme", "readme.md", "readme.txt", "readme.rst"], + "license": ["license", "licence", "license.md", "licence.md", "license.txt", "copying", "notice"], + "git": [".gitignore", ".gitattributes", ".gitmodules", ".gitkeep", ".mailmap"], + "docker": ["dockerfile", "containerfile", ".dockerignore", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"], + "npm": ["package.json", ".npmrc", ".npmignore"], + "cargo": ["cargo.toml", "rust-toolchain", "rust-toolchain.toml"], + "gomod": ["go.mod", "go.sum", "go.work"], + "pnpm": ["pnpm-workspace.yaml"], + "lock": ["cargo.lock", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "poetry.lock", "uv.lock", "gemfile.lock", "composer.lock"], + "env": [".env", ".env.local", ".env.development", ".env.production", ".env.example"], + "tsconfig": ["tsconfig.json", "jsconfig.json", "tsconfig.base.json"], + "vite": ["vite.config.ts", "vite.config.js", "vite.config.mts", "vitest.config.ts", "vitest.config.mts"], + "makefile": ["makefile", "gnumakefile", "justfile", "cmakelists.txt"], + "editorconfig": [".editorconfig", ".prettierrc", ".prettierignore", ".oxlintrc.json", ".eslintrc", ".eslintrc.json", "mise.toml"], + "settings": [".clippy.toml", "clippy.toml", "rustfmt.toml", ".rustfmt.toml", "deny.toml"], +} + +# folder name → (icon id, colour) +FOLDERS = { + "folder-src": ("#4caf50", ["src", "source", "sources", "lib", "libs", "pkg", "internal", "crates", "packages"]), + "folder-test": ("#8bc34a", ["test", "tests", "__tests__", "spec", "specs", "e2e", "fixtures", "testdata"]), + "folder-docs": ("#42a5f5", ["doc", "docs", "documentation", "wiki"]), + "folder-dist": ("#e57373", ["dist", "build", "out", "target", "bin", "obj", "release", "coverage"]), + "folder-deps": ("#9ccc65", ["node_modules", "vendor", ".venv", "venv", "third_party", "deps"]), + "folder-git": ("#ff7043", [".git", ".github", ".gitlab", ".husky"]), + "folder-config": ("#78909c", ["config", "configs", ".config", ".vscode", ".idea", ".cargo", "settings", "etc"]), + "folder-assets": ("#26a69a", ["assets", "static", "public", "images", "img", "icons", "fonts", "media", "resources", "res"]), + "folder-scripts": ("#ffb74d", ["scripts", "script", "tools", "tasks", "ci"]), + "folder-components": ("#ffca28", ["components", "ui", "widgets", "views", "pages", "features"]), + "folder-styles": ("#ec407a", ["styles", "css", "scss", "theme", "themes"]), + "folder-plugins": ("#ab47bc", ["plugins", "extensions", "addons", "sdk"]), + "folder-i18n": ("#5c6bc0", ["i18n", "locales", "locale", "lang", "translations"]), + "folder-api": ("#26c6da", ["api", "server", "routes", "handlers", "rpc"]), + "folder-db": ("#ffa726", ["db", "database", "migrations", "sql", "schema"]), +} + + +def main() -> None: + OUT.mkdir(exist_ok=True) + for old in OUT.glob("*.svg"): + old.unlink() + icons: dict[str, str] = {} + + def add(name: str, content: str) -> None: + assert name not in icons, f"duplicate icon {name}" + icons[name] = content + (OUT / f"{name}.svg").write_text(content) + + for name, content in GLYPHS.items(): + add(name, content) + add("folder", folder("#90a4ae")) + add("folder-open", folder("#90a4ae", open_=True)) + + extensions: dict[str, str] = {} + + def map_ext(ext: str, icon: str) -> None: + assert ext not in extensions, f"extension {ext} mapped twice" + extensions[ext] = icon + + for name, (color, label, exts, *fg) in LANGUAGES.items(): + add(name, badge(color, label, *fg)) + for ext in exts: + map_ext(ext, name) + for icon, exts in GLYPH_EXTENSIONS.items(): + for ext in exts: + map_ext(ext, icon) + + file_names = {n: icon for icon, names in FILE_NAMES.items() for n in names} + folder_names: dict[str, str] = {} + for icon, (color, names) in FOLDERS.items(): + add(icon, folder(color)) + for n in names: + assert n not in folder_names, f"folder {n} mapped twice" + folder_names[n] = icon + + for mapping in (extensions, file_names, folder_names): + for key, icon in mapping.items(): + assert icon in icons, f"{key} → unknown icon {icon}" + + theme = { + "iconDefinitions": {name: f"icons/{name}.svg" for name in sorted(icons)}, + "file": "file", + "folder": "folder", + "folderExpanded": "folder-open", + "fileExtensions": dict(sorted(extensions.items())), + "fileNames": dict(sorted(file_names.items())), + "folderNames": dict(sorted(folder_names.items())), + } + (OUT / "theme.json").write_text(json.dumps(theme, indent=2) + "\n") + print(f"{len(icons)} icons, {len(extensions)} extensions, {len(file_names)} file names, {len(folder_names)} folders") + + +if __name__ == "__main__": + main() diff --git a/plugins/official/material-icons/icons/archive.svg b/plugins/official/material-icons/icons/archive.svg new file mode 100644 index 000000000..871bd0649 --- /dev/null +++ b/plugins/official/material-icons/icons/archive.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="2" y="1" width="12" height="14" rx="1.5" fill="#af8e5b"/><path fill="#fff" d="M7 1h2v1.6H7zm0 3.2h2v1.6H7zM6.4 7.6h3.2v4.4H6.4z"/><rect x="7.2" y="9.8" width="1.6" height="1.2" fill="#af8e5b"/></svg> diff --git a/plugins/official/material-icons/icons/astro.svg b/plugins/official/material-icons/icons/astro.svg new file mode 100644 index 000000000..7bdb6e1e2 --- /dev/null +++ b/plugins/official/material-icons/icons/astro.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff5d01"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">A</text></svg> diff --git a/plugins/official/material-icons/icons/audio.svg b/plugins/official/material-icons/icons/audio.svg new file mode 100644 index 000000000..515294d82 --- /dev/null +++ b/plugins/official/material-icons/icons/audio.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ec407a"/><path fill="#fff" d="M6 4.5l6-1.5v7.3a1.8 1.8 0 1 1-1.2-1.7V5.4L7.2 6.3v5.4a1.8 1.8 0 1 1-1.2-1.7z"/></svg> diff --git a/plugins/official/material-icons/icons/batch.svg b/plugins/official/material-icons/icons/batch.svg new file mode 100644 index 000000000..6d1779b51 --- /dev/null +++ b/plugins/official/material-icons/icons/batch.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#546e7a"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">BAT</text></svg> diff --git a/plugins/official/material-icons/icons/c.svg b/plugins/official/material-icons/icons/c.svg new file mode 100644 index 000000000..408c3ee98 --- /dev/null +++ b/plugins/official/material-icons/icons/c.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#5c6bc0"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">C</text></svg> diff --git a/plugins/official/material-icons/icons/cargo.svg b/plugins/official/material-icons/icons/cargo.svg new file mode 100644 index 000000000..47b71a700 --- /dev/null +++ b/plugins/official/material-icons/icons/cargo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/><path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/><path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/></svg> diff --git a/plugins/official/material-icons/icons/class.svg b/plugins/official/material-icons/icons/class.svg new file mode 100644 index 000000000..f0ac74afd --- /dev/null +++ b/plugins/official/material-icons/icons/class.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#8d6e63"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">CL</text></svg> diff --git a/plugins/official/material-icons/icons/clojure.svg b/plugins/official/material-icons/icons/clojure.svg new file mode 100644 index 000000000..28c9b47e2 --- /dev/null +++ b/plugins/official/material-icons/icons/clojure.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#64dd17"/><text x="8" y="10.02" fill="#1b5e20" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">CLJ</text></svg> diff --git a/plugins/official/material-icons/icons/cpp.svg b/plugins/official/material-icons/icons/cpp.svg new file mode 100644 index 000000000..1a7fd7436 --- /dev/null +++ b/plugins/official/material-icons/icons/cpp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#1e88e5"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">C++</text></svg> diff --git a/plugins/official/material-icons/icons/csharp.svg b/plugins/official/material-icons/icons/csharp.svg new file mode 100644 index 000000000..0559e5080 --- /dev/null +++ b/plugins/official/material-icons/icons/csharp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#388e3c"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">C#</text></svg> diff --git a/plugins/official/material-icons/icons/css.svg b/plugins/official/material-icons/icons/css.svg new file mode 100644 index 000000000..deb851eed --- /dev/null +++ b/plugins/official/material-icons/icons/css.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#42a5f5"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">#</text></svg> diff --git a/plugins/official/material-icons/icons/csv.svg b/plugins/official/material-icons/icons/csv.svg new file mode 100644 index 000000000..be856f62a --- /dev/null +++ b/plugins/official/material-icons/icons/csv.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#43a047"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">CSV</text></svg> diff --git a/plugins/official/material-icons/icons/dart.svg b/plugins/official/material-icons/icons/dart.svg new file mode 100644 index 000000000..e6b1b6d11 --- /dev/null +++ b/plugins/official/material-icons/icons/dart.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#40c4ff"/><text x="8" y="11.42" fill="#01579b" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">D</text></svg> diff --git a/plugins/official/material-icons/icons/database.svg b/plugins/official/material-icons/icons/database.svg new file mode 100644 index 000000000..6b2e64bdb --- /dev/null +++ b/plugins/official/material-icons/icons/database.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2.5 3.5v9c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2v-9z"/><ellipse cx="8" cy="3.5" rx="5.5" ry="2" fill="#ffe082"/><path d="M2.5 7.5c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2" fill="none" stroke="#ffe082" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/diff.svg b/plugins/official/material-icons/icons/diff.svg new file mode 100644 index 000000000..387aeaa6c --- /dev/null +++ b/plugins/official/material-icons/icons/diff.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#66bb6a"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">+-</text></svg> diff --git a/plugins/official/material-icons/icons/docker.svg b/plugins/official/material-icons/icons/docker.svg new file mode 100644 index 000000000..a5d9eeb42 --- /dev/null +++ b/plugins/official/material-icons/icons/docker.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0288d1"/><g fill="#fff"><rect x="3" y="7" width="2.2" height="2"/><rect x="5.6" y="7" width="2.2" height="2"/><rect x="8.2" y="7" width="2.2" height="2"/><rect x="5.6" y="4.6" width="2.2" height="2"/><rect x="8.2" y="4.6" width="2.2" height="2"/></g><path fill="#fff" d="M2.6 9.6h10.6c.4-.8 1-1.2 1.6-1.2-.3 3-2.6 4.6-6.4 4.6-3.2 0-5.2-1.2-5.8-3.4z"/></svg> diff --git a/plugins/official/material-icons/icons/editorconfig.svg b/plugins/official/material-icons/icons/editorconfig.svg new file mode 100644 index 000000000..ddbdbebac --- /dev/null +++ b/plugins/official/material-icons/icons/editorconfig.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="#90a4ae"><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(0 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(45 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(90 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(135 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(180 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(225 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(270 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(315 8 8)"/></g><circle cx="8" cy="8" r="3.6" fill="none" stroke="#90a4ae" stroke-width="2.8"/></svg> diff --git a/plugins/official/material-icons/icons/elixir.svg b/plugins/official/material-icons/icons/elixir.svg new file mode 100644 index 000000000..3e13bbf97 --- /dev/null +++ b/plugins/official/material-icons/icons/elixir.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7e57c2"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">EX</text></svg> diff --git a/plugins/official/material-icons/icons/env.svg b/plugins/official/material-icons/icons/env.svg new file mode 100644 index 000000000..cde14e40e --- /dev/null +++ b/plugins/official/material-icons/icons/env.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="#fbc02d"><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(0 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(45 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(90 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(135 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(180 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(225 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(270 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(315 8 8)"/></g><circle cx="8" cy="8" r="3.6" fill="none" stroke="#fbc02d" stroke-width="2.8"/></svg> diff --git a/plugins/official/material-icons/icons/erlang.svg b/plugins/official/material-icons/icons/erlang.svg new file mode 100644 index 000000000..50e2c0a05 --- /dev/null +++ b/plugins/official/material-icons/icons/erlang.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#c2185b"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">ER</text></svg> diff --git a/plugins/official/material-icons/icons/file.svg b/plugins/official/material-icons/icons/file.svg new file mode 100644 index 000000000..7a89750ac --- /dev/null +++ b/plugins/official/material-icons/icons/file.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1h5.6L13 4.4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/><path fill="#fff" fill-opacity=".45" d="M9.6 1v3.4H13z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-api.svg b/plugins/official/material-icons/icons/folder-api.svg new file mode 100644 index 000000000..c24ee8939 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-api.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26c6da" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-assets.svg b/plugins/official/material-icons/icons/folder-assets.svg new file mode 100644 index 000000000..6230bf1f7 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-assets.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-components.svg b/plugins/official/material-icons/icons/folder-components.svg new file mode 100644 index 000000000..9b63a7f45 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-components.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-config.svg b/plugins/official/material-icons/icons/folder-config.svg new file mode 100644 index 000000000..41d451d9b --- /dev/null +++ b/plugins/official/material-icons/icons/folder-config.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#78909c" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-db.svg b/plugins/official/material-icons/icons/folder-db.svg new file mode 100644 index 000000000..ccaab633b --- /dev/null +++ b/plugins/official/material-icons/icons/folder-db.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffa726" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-deps.svg b/plugins/official/material-icons/icons/folder-deps.svg new file mode 100644 index 000000000..0ec809976 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-deps.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#9ccc65" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-dist.svg b/plugins/official/material-icons/icons/folder-dist.svg new file mode 100644 index 000000000..69f3fb5be --- /dev/null +++ b/plugins/official/material-icons/icons/folder-dist.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#e57373" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-docs.svg b/plugins/official/material-icons/icons/folder-docs.svg new file mode 100644 index 000000000..f3a28601f --- /dev/null +++ b/plugins/official/material-icons/icons/folder-docs.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#42a5f5" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-git.svg b/plugins/official/material-icons/icons/folder-git.svg new file mode 100644 index 000000000..ba7bd3942 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-git.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-i18n.svg b/plugins/official/material-icons/icons/folder-i18n.svg new file mode 100644 index 000000000..6118c98e3 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-i18n.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#5c6bc0" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-open.svg b/plugins/official/material-icons/icons/folder-open.svg new file mode 100644 index 000000000..0accab518 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-open.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 2.5h4.6L7.6 4h6.4a1 1 0 0 1 1 1v1.5H4.2L2 13.5H1.5a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path fill="#90a4ae" fill-opacity=".75" d="M4.2 7h11.3l-2.2 6.5H2z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-plugins.svg b/plugins/official/material-icons/icons/folder-plugins.svg new file mode 100644 index 000000000..c75c82fde --- /dev/null +++ b/plugins/official/material-icons/icons/folder-plugins.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ab47bc" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-scripts.svg b/plugins/official/material-icons/icons/folder-scripts.svg new file mode 100644 index 000000000..e8a7cc798 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-scripts.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffb74d" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-src.svg b/plugins/official/material-icons/icons/folder-src.svg new file mode 100644 index 000000000..26336c369 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-src.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#4caf50" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-styles.svg b/plugins/official/material-icons/icons/folder-styles.svg new file mode 100644 index 000000000..4ac2a7ab9 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-styles.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ec407a" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-test.svg b/plugins/official/material-icons/icons/folder-test.svg new file mode 100644 index 000000000..29b2cbcf4 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-test.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#8bc34a" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder.svg b/plugins/official/material-icons/icons/folder.svg new file mode 100644 index 000000000..4002def3a --- /dev/null +++ b/plugins/official/material-icons/icons/folder.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/font.svg b/plugins/official/material-icons/icons/font.svg new file mode 100644 index 000000000..d7e454a4d --- /dev/null +++ b/plugins/official/material-icons/icons/font.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ef5350"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">Aa</text></svg> diff --git a/plugins/official/material-icons/icons/fsharp.svg b/plugins/official/material-icons/icons/fsharp.svg new file mode 100644 index 000000000..cbf21c7b5 --- /dev/null +++ b/plugins/official/material-icons/icons/fsharp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#378bba"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">F#</text></svg> diff --git a/plugins/official/material-icons/icons/git.svg b/plugins/official/material-icons/icons/git.svg new file mode 100644 index 000000000..02ff7956f --- /dev/null +++ b/plugins/official/material-icons/icons/git.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#f4511e" d="M8 .6l7.4 7.4L8 15.4.6 8z"/><path d="M6.3 5v6M6.3 6.4c0 1.8 3.7.4 3.7 2.1" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/><g fill="#fff"><circle cx="6.3" cy="5" r="1.1"/><circle cx="6.3" cy="11" r="1.1"/><circle cx="10" cy="8.6" r="1.1"/></g></svg> diff --git a/plugins/official/material-icons/icons/go.svg b/plugins/official/material-icons/icons/go.svg new file mode 100644 index 000000000..4525fef3d --- /dev/null +++ b/plugins/official/material-icons/icons/go.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#00acc1"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">GO</text></svg> diff --git a/plugins/official/material-icons/icons/gomod.svg b/plugins/official/material-icons/icons/gomod.svg new file mode 100644 index 000000000..a0a8000ce --- /dev/null +++ b/plugins/official/material-icons/icons/gomod.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#00acc1" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/><path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/><path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/></svg> diff --git a/plugins/official/material-icons/icons/graphql.svg b/plugins/official/material-icons/icons/graphql.svg new file mode 100644 index 000000000..7126760d2 --- /dev/null +++ b/plugins/official/material-icons/icons/graphql.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e535ab"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">GQ</text></svg> diff --git a/plugins/official/material-icons/icons/groovy.svg b/plugins/official/material-icons/icons/groovy.svg new file mode 100644 index 000000000..8967bb44f --- /dev/null +++ b/plugins/official/material-icons/icons/groovy.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#4db6ac"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">GR</text></svg> diff --git a/plugins/official/material-icons/icons/haskell.svg b/plugins/official/material-icons/icons/haskell.svg new file mode 100644 index 000000000..f20b1be38 --- /dev/null +++ b/plugins/official/material-icons/icons/haskell.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7e57c2"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">HS</text></svg> diff --git a/plugins/official/material-icons/icons/header.svg b/plugins/official/material-icons/icons/header.svg new file mode 100644 index 000000000..cf7e9e785 --- /dev/null +++ b/plugins/official/material-icons/icons/header.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#9575cd"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">H</text></svg> diff --git a/plugins/official/material-icons/icons/html.svg b/plugins/official/material-icons/icons/html.svg new file mode 100644 index 000000000..40dec7249 --- /dev/null +++ b/plugins/official/material-icons/icons/html.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e44d26"/><path d="M6 5L3.5 8 6 11M10 5l2.5 3L10 11" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/http.svg b/plugins/official/material-icons/icons/http.svg new file mode 100644 index 000000000..c4a7ed510 --- /dev/null +++ b/plugins/official/material-icons/icons/http.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#3f51b5"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">API</text></svg> diff --git a/plugins/official/material-icons/icons/image.svg b/plugins/official/material-icons/icons/image.svg new file mode 100644 index 000000000..8f67a7abf --- /dev/null +++ b/plugins/official/material-icons/icons/image.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="1.5" fill="#26a69a"/><circle cx="5" cy="6" r="1.5" fill="#fff"/><path fill="#fff" d="M2.5 12l3.5-4 2.5 2.6L11 7.4l3 4.6z"/></svg> diff --git a/plugins/official/material-icons/icons/ini.svg b/plugins/official/material-icons/icons/ini.svg new file mode 100644 index 000000000..a70903c4f --- /dev/null +++ b/plugins/official/material-icons/icons/ini.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#78909c"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">INI</text></svg> diff --git a/plugins/official/material-icons/icons/java.svg b/plugins/official/material-icons/icons/java.svg new file mode 100644 index 000000000..6a72019b2 --- /dev/null +++ b/plugins/official/material-icons/icons/java.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e53935"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">J</text></svg> diff --git a/plugins/official/material-icons/icons/javascript.svg b/plugins/official/material-icons/icons/javascript.svg new file mode 100644 index 000000000..e43c32ab9 --- /dev/null +++ b/plugins/official/material-icons/icons/javascript.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#fbc02d"/><text x="8" y="10.66" fill="#3e2723" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">JS</text></svg> diff --git a/plugins/official/material-icons/icons/json.svg b/plugins/official/material-icons/icons/json.svg new file mode 100644 index 000000000..841dce8a1 --- /dev/null +++ b/plugins/official/material-icons/icons/json.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#fbc02d"/><text x="8" y="10.66" fill="#5d4037" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">{}</text></svg> diff --git a/plugins/official/material-icons/icons/julia.svg b/plugins/official/material-icons/icons/julia.svg new file mode 100644 index 000000000..4fb97b14c --- /dev/null +++ b/plugins/official/material-icons/icons/julia.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#9558b2"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">JL</text></svg> diff --git a/plugins/official/material-icons/icons/kotlin.svg b/plugins/official/material-icons/icons/kotlin.svg new file mode 100644 index 000000000..af61a9557 --- /dev/null +++ b/plugins/official/material-icons/icons/kotlin.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7e57c2"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">KT</text></svg> diff --git a/plugins/official/material-icons/icons/less.svg b/plugins/official/material-icons/icons/less.svg new file mode 100644 index 000000000..a6534d17a --- /dev/null +++ b/plugins/official/material-icons/icons/less.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#1565c0"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">LS</text></svg> diff --git a/plugins/official/material-icons/icons/license.svg b/plugins/official/material-icons/icons/license.svg new file mode 100644 index 000000000..a58c14bb3 --- /dev/null +++ b/plugins/official/material-icons/icons/license.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1.5" y="2" width="13" height="9.5" rx="1" fill="#ffca28"/><path d="M3.6 4.6h6.4M3.6 6.8h4" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/><path fill="#ef6c00" d="M9.6 11.4l-.7 3.4 2.1-1.1 2.1 1.1-.7-3.4z"/><circle cx="11" cy="10" r="2.3" fill="#ef6c00"/></svg> diff --git a/plugins/official/material-icons/icons/lock.svg b/plugins/official/material-icons/icons/lock.svg new file mode 100644 index 000000000..1ee46f47c --- /dev/null +++ b/plugins/official/material-icons/icons/lock.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="7" width="10" height="8" rx="1.5" fill="#90a4ae"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" fill="none" stroke="#90a4ae" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="8" cy="11" r="1.2" fill="#fff"/></svg> diff --git a/plugins/official/material-icons/icons/log.svg b/plugins/official/material-icons/icons/log.svg new file mode 100644 index 000000000..0a8efff5f --- /dev/null +++ b/plugins/official/material-icons/icons/log.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#78909c"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">LOG</text></svg> diff --git a/plugins/official/material-icons/icons/lua.svg b/plugins/official/material-icons/icons/lua.svg new file mode 100644 index 000000000..018aa5799 --- /dev/null +++ b/plugins/official/material-icons/icons/lua.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#3949ab"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">LUA</text></svg> diff --git a/plugins/official/material-icons/icons/makefile.svg b/plugins/official/material-icons/icons/makefile.svg new file mode 100644 index 000000000..7fe3ad1fc --- /dev/null +++ b/plugins/official/material-icons/icons/makefile.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ef5350"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">M</text></svg> diff --git a/plugins/official/material-icons/icons/markdown.svg b/plugins/official/material-icons/icons/markdown.svg new file mode 100644 index 000000000..78c8400ea --- /dev/null +++ b/plugins/official/material-icons/icons/markdown.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="0.5" y="3" width="15" height="10" rx="1.6" fill="#42a5f5"/><path d="M3 10.5v-5l2 2.5 2-2.5v5" fill="none" stroke="#fff" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/><path d="M11 5.5v5M9.2 8.8l1.8 1.8 1.8-1.8" fill="none" stroke="#fff" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/nim.svg b/plugins/official/material-icons/icons/nim.svg new file mode 100644 index 000000000..defeaf39f --- /dev/null +++ b/plugins/official/material-icons/icons/nim.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ffc107"/><text x="8" y="10.02" fill="#3e2723" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">NIM</text></svg> diff --git a/plugins/official/material-icons/icons/nix.svg b/plugins/official/material-icons/icons/nix.svg new file mode 100644 index 000000000..dace2f37d --- /dev/null +++ b/plugins/official/material-icons/icons/nix.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#5277c3"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">NIX</text></svg> diff --git a/plugins/official/material-icons/icons/notebook.svg b/plugins/official/material-icons/icons/notebook.svg new file mode 100644 index 000000000..67b85bf8a --- /dev/null +++ b/plugins/official/material-icons/icons/notebook.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#f57c00"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">NB</text></svg> diff --git a/plugins/official/material-icons/icons/npm.svg b/plugins/official/material-icons/icons/npm.svg new file mode 100644 index 000000000..aec46265c --- /dev/null +++ b/plugins/official/material-icons/icons/npm.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cb3837" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/><path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/><path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/></svg> diff --git a/plugins/official/material-icons/icons/objc.svg b/plugins/official/material-icons/icons/objc.svg new file mode 100644 index 000000000..429ce16b0 --- /dev/null +++ b/plugins/official/material-icons/icons/objc.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#607d8b"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">OC</text></svg> diff --git a/plugins/official/material-icons/icons/ocaml.svg b/plugins/official/material-icons/icons/ocaml.svg new file mode 100644 index 000000000..4be3ebdbf --- /dev/null +++ b/plugins/official/material-icons/icons/ocaml.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ef6c00"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">ML</text></svg> diff --git a/plugins/official/material-icons/icons/pdf.svg b/plugins/official/material-icons/icons/pdf.svg new file mode 100644 index 000000000..777d161ad --- /dev/null +++ b/plugins/official/material-icons/icons/pdf.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e53935"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PDF</text></svg> diff --git a/plugins/official/material-icons/icons/perl.svg b/plugins/official/material-icons/icons/perl.svg new file mode 100644 index 000000000..82516150b --- /dev/null +++ b/plugins/official/material-icons/icons/perl.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#5c6bc0"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PL</text></svg> diff --git a/plugins/official/material-icons/icons/php.svg b/plugins/official/material-icons/icons/php.svg new file mode 100644 index 000000000..20d1ae8e2 --- /dev/null +++ b/plugins/official/material-icons/icons/php.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7986cb"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PHP</text></svg> diff --git a/plugins/official/material-icons/icons/pnpm.svg b/plugins/official/material-icons/icons/pnpm.svg new file mode 100644 index 000000000..e581f13c9 --- /dev/null +++ b/plugins/official/material-icons/icons/pnpm.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#f9ad00" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/><path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/><path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/></svg> diff --git a/plugins/official/material-icons/icons/powershell.svg b/plugins/official/material-icons/icons/powershell.svg new file mode 100644 index 000000000..35fe8ce0b --- /dev/null +++ b/plugins/official/material-icons/icons/powershell.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0277bd"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PS</text></svg> diff --git a/plugins/official/material-icons/icons/proto.svg b/plugins/official/material-icons/icons/proto.svg new file mode 100644 index 000000000..49b84d9e7 --- /dev/null +++ b/plugins/official/material-icons/icons/proto.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#26a69a"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PB</text></svg> diff --git a/plugins/official/material-icons/icons/python.svg b/plugins/official/material-icons/icons/python.svg new file mode 100644 index 000000000..a22894cce --- /dev/null +++ b/plugins/official/material-icons/icons/python.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#3572a5"/><text x="8" y="10.66" fill="#ffd54f" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PY</text></svg> diff --git a/plugins/official/material-icons/icons/r.svg b/plugins/official/material-icons/icons/r.svg new file mode 100644 index 000000000..316127087 --- /dev/null +++ b/plugins/official/material-icons/icons/r.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#1976d2"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">R</text></svg> diff --git a/plugins/official/material-icons/icons/react-ts.svg b/plugins/official/material-icons/icons/react-ts.svg new file mode 100644 index 000000000..30c7b00ea --- /dev/null +++ b/plugins/official/material-icons/icons/react-ts.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="none" stroke="#0288d1" stroke-width="1.1"><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(0 8 8)"/><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(60 8 8)"/><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(120 8 8)"/></g><circle cx="8" cy="8" r="1.4" fill="#0288d1"/></svg> diff --git a/plugins/official/material-icons/icons/react.svg b/plugins/official/material-icons/icons/react.svg new file mode 100644 index 000000000..3c8af66c1 --- /dev/null +++ b/plugins/official/material-icons/icons/react.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="none" stroke="#00bcd4" stroke-width="1.1"><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(0 8 8)"/><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(60 8 8)"/><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(120 8 8)"/></g><circle cx="8" cy="8" r="1.4" fill="#00bcd4"/></svg> diff --git a/plugins/official/material-icons/icons/readme.svg b/plugins/official/material-icons/icons/readme.svg new file mode 100644 index 000000000..39c5652c1 --- /dev/null +++ b/plugins/official/material-icons/icons/readme.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="#42a5f5"/><rect x="7.1" y="6.8" width="1.8" height="5.2" rx=".6" fill="#fff"/><circle cx="8" cy="4.6" r="1.1" fill="#fff"/></svg> diff --git a/plugins/official/material-icons/icons/ruby.svg b/plugins/official/material-icons/icons/ruby.svg new file mode 100644 index 000000000..ed8beec63 --- /dev/null +++ b/plugins/official/material-icons/icons/ruby.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#c62828"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">RB</text></svg> diff --git a/plugins/official/material-icons/icons/rust.svg b/plugins/official/material-icons/icons/rust.svg new file mode 100644 index 000000000..3bd8a2bfc --- /dev/null +++ b/plugins/official/material-icons/icons/rust.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff7043"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">RS</text></svg> diff --git a/plugins/official/material-icons/icons/sass.svg b/plugins/official/material-icons/icons/sass.svg new file mode 100644 index 000000000..13bd12f62 --- /dev/null +++ b/plugins/official/material-icons/icons/sass.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ec407a"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">#</text></svg> diff --git a/plugins/official/material-icons/icons/scala.svg b/plugins/official/material-icons/icons/scala.svg new file mode 100644 index 000000000..34cc32383 --- /dev/null +++ b/plugins/official/material-icons/icons/scala.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#d32f2f"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">SC</text></svg> diff --git a/plugins/official/material-icons/icons/settings.svg b/plugins/official/material-icons/icons/settings.svg new file mode 100644 index 000000000..85a771c23 --- /dev/null +++ b/plugins/official/material-icons/icons/settings.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="#78909c"><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(0 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(45 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(90 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(135 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(180 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(225 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(270 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(315 8 8)"/></g><circle cx="8" cy="8" r="3.6" fill="none" stroke="#78909c" stroke-width="2.8"/></svg> diff --git a/plugins/official/material-icons/icons/shell.svg b/plugins/official/material-icons/icons/shell.svg new file mode 100644 index 000000000..bf37b0976 --- /dev/null +++ b/plugins/official/material-icons/icons/shell.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2" width="14" height="12" rx="2" fill="#4caf50"/><path d="M4 5.5l2.5 2.5L4 10.5" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 10.8h4" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/solidity.svg b/plugins/official/material-icons/icons/solidity.svg new file mode 100644 index 000000000..d6d6508f9 --- /dev/null +++ b/plugins/official/material-icons/icons/solidity.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#546e7a"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">SOL</text></svg> diff --git a/plugins/official/material-icons/icons/svelte.svg b/plugins/official/material-icons/icons/svelte.svg new file mode 100644 index 000000000..152e614f9 --- /dev/null +++ b/plugins/official/material-icons/icons/svelte.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff3e00"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">S</text></svg> diff --git a/plugins/official/material-icons/icons/svg.svg b/plugins/official/material-icons/icons/svg.svg new file mode 100644 index 000000000..1115a97fd --- /dev/null +++ b/plugins/official/material-icons/icons/svg.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ffb300"/><text x="8" y="10.02" fill="#4e342e" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">SVG</text></svg> diff --git a/plugins/official/material-icons/icons/swift.svg b/plugins/official/material-icons/icons/swift.svg new file mode 100644 index 000000000..1d6d44619 --- /dev/null +++ b/plugins/official/material-icons/icons/swift.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff5722"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">SW</text></svg> diff --git a/plugins/official/material-icons/icons/terraform.svg b/plugins/official/material-icons/icons/terraform.svg new file mode 100644 index 000000000..5ff38d03d --- /dev/null +++ b/plugins/official/material-icons/icons/terraform.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7b42bc"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TF</text></svg> diff --git a/plugins/official/material-icons/icons/test.svg b/plugins/official/material-icons/icons/test.svg new file mode 100644 index 000000000..b49224552 --- /dev/null +++ b/plugins/official/material-icons/icons/test.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#8bc34a"/><path d="M4.2 8.2l2.4 2.4 5.2-5.2" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/tex.svg b/plugins/official/material-icons/icons/tex.svg new file mode 100644 index 000000000..8d647f822 --- /dev/null +++ b/plugins/official/material-icons/icons/tex.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#008080"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TEX</text></svg> diff --git a/plugins/official/material-icons/icons/text.svg b/plugins/official/material-icons/icons/text.svg new file mode 100644 index 000000000..1ad8d7b17 --- /dev/null +++ b/plugins/official/material-icons/icons/text.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1h5.6L13 4.4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/><path fill="#fff" fill-opacity=".45" d="M9.6 1v3.4H13z"/><path d="M5 7.5h6M5 10h6M5 12.5h4" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/theme.json b/plugins/official/material-icons/icons/theme.json new file mode 100644 index 000000000..919c17fe3 --- /dev/null +++ b/plugins/official/material-icons/icons/theme.json @@ -0,0 +1,471 @@ +{ + "iconDefinitions": { + "archive": "icons/archive.svg", + "astro": "icons/astro.svg", + "audio": "icons/audio.svg", + "batch": "icons/batch.svg", + "c": "icons/c.svg", + "cargo": "icons/cargo.svg", + "class": "icons/class.svg", + "clojure": "icons/clojure.svg", + "cpp": "icons/cpp.svg", + "csharp": "icons/csharp.svg", + "css": "icons/css.svg", + "csv": "icons/csv.svg", + "dart": "icons/dart.svg", + "database": "icons/database.svg", + "diff": "icons/diff.svg", + "docker": "icons/docker.svg", + "editorconfig": "icons/editorconfig.svg", + "elixir": "icons/elixir.svg", + "env": "icons/env.svg", + "erlang": "icons/erlang.svg", + "file": "icons/file.svg", + "folder": "icons/folder.svg", + "folder-api": "icons/folder-api.svg", + "folder-assets": "icons/folder-assets.svg", + "folder-components": "icons/folder-components.svg", + "folder-config": "icons/folder-config.svg", + "folder-db": "icons/folder-db.svg", + "folder-deps": "icons/folder-deps.svg", + "folder-dist": "icons/folder-dist.svg", + "folder-docs": "icons/folder-docs.svg", + "folder-git": "icons/folder-git.svg", + "folder-i18n": "icons/folder-i18n.svg", + "folder-open": "icons/folder-open.svg", + "folder-plugins": "icons/folder-plugins.svg", + "folder-scripts": "icons/folder-scripts.svg", + "folder-src": "icons/folder-src.svg", + "folder-styles": "icons/folder-styles.svg", + "folder-test": "icons/folder-test.svg", + "font": "icons/font.svg", + "fsharp": "icons/fsharp.svg", + "git": "icons/git.svg", + "go": "icons/go.svg", + "gomod": "icons/gomod.svg", + "graphql": "icons/graphql.svg", + "groovy": "icons/groovy.svg", + "haskell": "icons/haskell.svg", + "header": "icons/header.svg", + "html": "icons/html.svg", + "http": "icons/http.svg", + "image": "icons/image.svg", + "ini": "icons/ini.svg", + "java": "icons/java.svg", + "javascript": "icons/javascript.svg", + "json": "icons/json.svg", + "julia": "icons/julia.svg", + "kotlin": "icons/kotlin.svg", + "less": "icons/less.svg", + "license": "icons/license.svg", + "lock": "icons/lock.svg", + "log": "icons/log.svg", + "lua": "icons/lua.svg", + "makefile": "icons/makefile.svg", + "markdown": "icons/markdown.svg", + "nim": "icons/nim.svg", + "nix": "icons/nix.svg", + "notebook": "icons/notebook.svg", + "npm": "icons/npm.svg", + "objc": "icons/objc.svg", + "ocaml": "icons/ocaml.svg", + "pdf": "icons/pdf.svg", + "perl": "icons/perl.svg", + "php": "icons/php.svg", + "pnpm": "icons/pnpm.svg", + "powershell": "icons/powershell.svg", + "proto": "icons/proto.svg", + "python": "icons/python.svg", + "r": "icons/r.svg", + "react": "icons/react.svg", + "react-ts": "icons/react-ts.svg", + "readme": "icons/readme.svg", + "ruby": "icons/ruby.svg", + "rust": "icons/rust.svg", + "sass": "icons/sass.svg", + "scala": "icons/scala.svg", + "settings": "icons/settings.svg", + "shell": "icons/shell.svg", + "solidity": "icons/solidity.svg", + "svelte": "icons/svelte.svg", + "svg": "icons/svg.svg", + "swift": "icons/swift.svg", + "terraform": "icons/terraform.svg", + "test": "icons/test.svg", + "tex": "icons/tex.svg", + "text": "icons/text.svg", + "toml": "icons/toml.svg", + "tsconfig": "icons/tsconfig.svg", + "typescript": "icons/typescript.svg", + "typescript-def": "icons/typescript-def.svg", + "video": "icons/video.svg", + "vim": "icons/vim.svg", + "vite": "icons/vite.svg", + "vue": "icons/vue.svg", + "wasm": "icons/wasm.svg", + "xml": "icons/xml.svg", + "yaml": "icons/yaml.svg", + "zig": "icons/zig.svg" + }, + "file": "file", + "folder": "folder", + "folderExpanded": "folder-open", + "fileExtensions": { + "7z": "archive", + "astro": "astro", + "avi": "video", + "avif": "image", + "bash": "shell", + "bat": "batch", + "bib": "tex", + "bmp": "image", + "bz2": "archive", + "c": "c", + "c++": "cpp", + "cc": "cpp", + "cfg": "ini", + "cjs": "javascript", + "class": "class", + "clj": "clojure", + "cljc": "clojure", + "cljs": "clojure", + "cmd": "batch", + "conf": "ini", + "cpp": "cpp", + "cs": "csharp", + "css": "css", + "csv": "csv", + "csx": "csharp", + "cts": "typescript", + "cxx": "cpp", + "d.cts": "typescript-def", + "d.mts": "typescript-def", + "d.ts": "typescript-def", + "dart": "dart", + "db": "database", + "diff": "diff", + "edn": "clojure", + "env": "env", + "erb": "ruby", + "erl": "erlang", + "ex": "elixir", + "exs": "elixir", + "fish": "shell", + "flac": "audio", + "fs": "fsharp", + "fsi": "fsharp", + "fsx": "fsharp", + "gemspec": "ruby", + "gif": "image", + "go": "go", + "gql": "graphql", + "gradle": "groovy", + "graphql": "graphql", + "groovy": "groovy", + "gz": "archive", + "h": "header", + "hcl": "terraform", + "heex": "elixir", + "hh": "header", + "hpp": "header", + "hrl": "erlang", + "hs": "haskell", + "htm": "html", + "html": "html", + "http": "http", + "hxx": "header", + "icns": "image", + "ico": "image", + "iml": "xml", + "ini": "ini", + "inl": "header", + "ipynb": "notebook", + "jar": "class", + "java": "java", + "jl": "julia", + "jpeg": "image", + "jpg": "image", + "js": "javascript", + "json": "json", + "json5": "json", + "jsonc": "json", + "jsx": "react", + "kt": "kotlin", + "kts": "kotlin", + "less": "less", + "lhs": "haskell", + "lock": "lock", + "log": "log", + "lua": "lua", + "m": "objc", + "m4a": "audio", + "markdown": "markdown", + "md": "markdown", + "mdx": "markdown", + "mjs": "javascript", + "mkv": "video", + "ml": "ocaml", + "mli": "ocaml", + "mm": "objc", + "mov": "video", + "mp3": "audio", + "mp4": "video", + "mts": "typescript", + "nim": "nim", + "nix": "nix", + "ogg": "audio", + "otf": "font", + "patch": "diff", + "pdf": "pdf", + "php": "php", + "pl": "perl", + "plist": "xml", + "pm": "perl", + "png": "image", + "prisma": "database", + "properties": "ini", + "proto": "proto", + "ps1": "powershell", + "psd1": "powershell", + "psm1": "powershell", + "py": "python", + "pyi": "python", + "pyw": "python", + "r": "r", + "rake": "ruby", + "rar": "archive", + "rb": "ruby", + "rest": "http", + "rmd": "r", + "rs": "rust", + "sass": "sass", + "sbt": "scala", + "sc": "scala", + "scala": "scala", + "scss": "sass", + "sh": "shell", + "sol": "solidity", + "spec.js": "test", + "spec.jsx": "test", + "spec.mjs": "test", + "spec.mts": "test", + "spec.ts": "test", + "spec.tsx": "test", + "sql": "database", + "sqlite": "database", + "sty": "tex", + "svelte": "svelte", + "svg": "svg", + "swift": "swift", + "tar": "archive", + "test.js": "test", + "test.jsx": "test", + "test.mjs": "test", + "test.mts": "test", + "test.py": "test", + "test.rs": "test", + "test.ts": "test", + "test.tsx": "test", + "tex": "tex", + "text": "text", + "tf": "terraform", + "tfvars": "terraform", + "tgz": "archive", + "tif": "image", + "tiff": "image", + "toml": "toml", + "ts": "typescript", + "tsv": "csv", + "tsx": "react-ts", + "ttf": "font", + "txt": "text", + "vim": "vim", + "vue": "vue", + "wasm": "wasm", + "wat": "wasm", + "wav": "audio", + "webm": "video", + "webp": "image", + "woff": "font", + "woff2": "font", + "xhtml": "html", + "xml": "xml", + "xsd": "xml", + "xsl": "xml", + "xz": "archive", + "yaml": "yaml", + "yml": "yaml", + "zig": "zig", + "zip": "archive", + "zon": "zig", + "zsh": "shell", + "zst": "archive" + }, + "fileNames": { + ".clippy.toml": "settings", + ".dockerignore": "docker", + ".editorconfig": "editorconfig", + ".env": "env", + ".env.development": "env", + ".env.example": "env", + ".env.local": "env", + ".env.production": "env", + ".eslintrc": "editorconfig", + ".eslintrc.json": "editorconfig", + ".gitattributes": "git", + ".gitignore": "git", + ".gitkeep": "git", + ".gitmodules": "git", + ".mailmap": "git", + ".npmignore": "npm", + ".npmrc": "npm", + ".oxlintrc.json": "editorconfig", + ".prettierignore": "editorconfig", + ".prettierrc": "editorconfig", + ".rustfmt.toml": "settings", + "bun.lockb": "lock", + "cargo.lock": "lock", + "cargo.toml": "cargo", + "clippy.toml": "settings", + "cmakelists.txt": "makefile", + "compose.yaml": "docker", + "compose.yml": "docker", + "composer.lock": "lock", + "containerfile": "docker", + "copying": "license", + "deny.toml": "settings", + "docker-compose.yaml": "docker", + "docker-compose.yml": "docker", + "dockerfile": "docker", + "gemfile.lock": "lock", + "gnumakefile": "makefile", + "go.mod": "gomod", + "go.sum": "gomod", + "go.work": "gomod", + "jsconfig.json": "tsconfig", + "justfile": "makefile", + "licence": "license", + "licence.md": "license", + "license": "license", + "license.md": "license", + "license.txt": "license", + "makefile": "makefile", + "mise.toml": "editorconfig", + "notice": "license", + "package-lock.json": "lock", + "package.json": "npm", + "pnpm-lock.yaml": "lock", + "pnpm-workspace.yaml": "pnpm", + "poetry.lock": "lock", + "readme": "readme", + "readme.md": "readme", + "readme.rst": "readme", + "readme.txt": "readme", + "rust-toolchain": "cargo", + "rust-toolchain.toml": "cargo", + "rustfmt.toml": "settings", + "tsconfig.base.json": "tsconfig", + "tsconfig.json": "tsconfig", + "uv.lock": "lock", + "vite.config.js": "vite", + "vite.config.mts": "vite", + "vite.config.ts": "vite", + "vitest.config.mts": "vite", + "vitest.config.ts": "vite", + "yarn.lock": "lock" + }, + "folderNames": { + ".cargo": "folder-config", + ".config": "folder-config", + ".git": "folder-git", + ".github": "folder-git", + ".gitlab": "folder-git", + ".husky": "folder-git", + ".idea": "folder-config", + ".venv": "folder-deps", + ".vscode": "folder-config", + "__tests__": "folder-test", + "addons": "folder-plugins", + "api": "folder-api", + "assets": "folder-assets", + "bin": "folder-dist", + "build": "folder-dist", + "ci": "folder-scripts", + "components": "folder-components", + "config": "folder-config", + "configs": "folder-config", + "coverage": "folder-dist", + "crates": "folder-src", + "css": "folder-styles", + "database": "folder-db", + "db": "folder-db", + "deps": "folder-deps", + "dist": "folder-dist", + "doc": "folder-docs", + "docs": "folder-docs", + "documentation": "folder-docs", + "e2e": "folder-test", + "etc": "folder-config", + "extensions": "folder-plugins", + "features": "folder-components", + "fixtures": "folder-test", + "fonts": "folder-assets", + "handlers": "folder-api", + "i18n": "folder-i18n", + "icons": "folder-assets", + "images": "folder-assets", + "img": "folder-assets", + "internal": "folder-src", + "lang": "folder-i18n", + "lib": "folder-src", + "libs": "folder-src", + "locale": "folder-i18n", + "locales": "folder-i18n", + "media": "folder-assets", + "migrations": "folder-db", + "node_modules": "folder-deps", + "obj": "folder-dist", + "out": "folder-dist", + "packages": "folder-src", + "pages": "folder-components", + "pkg": "folder-src", + "plugins": "folder-plugins", + "public": "folder-assets", + "release": "folder-dist", + "res": "folder-assets", + "resources": "folder-assets", + "routes": "folder-api", + "rpc": "folder-api", + "schema": "folder-db", + "script": "folder-scripts", + "scripts": "folder-scripts", + "scss": "folder-styles", + "sdk": "folder-plugins", + "server": "folder-api", + "settings": "folder-config", + "source": "folder-src", + "sources": "folder-src", + "spec": "folder-test", + "specs": "folder-test", + "sql": "folder-db", + "src": "folder-src", + "static": "folder-assets", + "styles": "folder-styles", + "target": "folder-dist", + "tasks": "folder-scripts", + "test": "folder-test", + "testdata": "folder-test", + "tests": "folder-test", + "theme": "folder-styles", + "themes": "folder-styles", + "third_party": "folder-deps", + "tools": "folder-scripts", + "translations": "folder-i18n", + "ui": "folder-components", + "vendor": "folder-deps", + "venv": "folder-deps", + "views": "folder-components", + "widgets": "folder-components", + "wiki": "folder-docs" + } +} diff --git a/plugins/official/material-icons/icons/toml.svg b/plugins/official/material-icons/icons/toml.svg new file mode 100644 index 000000000..e322c4c8c --- /dev/null +++ b/plugins/official/material-icons/icons/toml.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#8d6e63"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TML</text></svg> diff --git a/plugins/official/material-icons/icons/tsconfig.svg b/plugins/official/material-icons/icons/tsconfig.svg new file mode 100644 index 000000000..b1e3d3216 --- /dev/null +++ b/plugins/official/material-icons/icons/tsconfig.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0288d1"/><text x="8" y="10.66" fill="#bbdefb" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TS</text></svg> diff --git a/plugins/official/material-icons/icons/typescript-def.svg b/plugins/official/material-icons/icons/typescript-def.svg new file mode 100644 index 000000000..2bdef4cd3 --- /dev/null +++ b/plugins/official/material-icons/icons/typescript-def.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0277bd"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">D</text></svg> diff --git a/plugins/official/material-icons/icons/typescript.svg b/plugins/official/material-icons/icons/typescript.svg new file mode 100644 index 000000000..ff3be5032 --- /dev/null +++ b/plugins/official/material-icons/icons/typescript.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0288d1"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TS</text></svg> diff --git a/plugins/official/material-icons/icons/video.svg b/plugins/official/material-icons/icons/video.svg new file mode 100644 index 000000000..7bc2ac04a --- /dev/null +++ b/plugins/official/material-icons/icons/video.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="2" fill="#ff7043"/><path fill="#fff" d="M6.4 5.5v5l4-2.5z"/></svg> diff --git a/plugins/official/material-icons/icons/vim.svg b/plugins/official/material-icons/icons/vim.svg new file mode 100644 index 000000000..46ff8ce6f --- /dev/null +++ b/plugins/official/material-icons/icons/vim.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#43a047"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">VI</text></svg> diff --git a/plugins/official/material-icons/icons/vite.svg b/plugins/official/material-icons/icons/vite.svg new file mode 100644 index 000000000..402a8dee8 --- /dev/null +++ b/plugins/official/material-icons/icons/vite.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#9575cd"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">V</text></svg> diff --git a/plugins/official/material-icons/icons/vue.svg b/plugins/official/material-icons/icons/vue.svg new file mode 100644 index 000000000..0481473ac --- /dev/null +++ b/plugins/official/material-icons/icons/vue.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#41b883"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">V</text></svg> diff --git a/plugins/official/material-icons/icons/wasm.svg b/plugins/official/material-icons/icons/wasm.svg new file mode 100644 index 000000000..b520537de --- /dev/null +++ b/plugins/official/material-icons/icons/wasm.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#654ff0"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">WA</text></svg> diff --git a/plugins/official/material-icons/icons/xml.svg b/plugins/official/material-icons/icons/xml.svg new file mode 100644 index 000000000..4f3edab6b --- /dev/null +++ b/plugins/official/material-icons/icons/xml.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff9800"/><path d="M5.5 5L3 8l2.5 3M10.5 5L13 8l-2.5 3M9 4.5l-2 7" fill="none" stroke="#fff" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/yaml.svg b/plugins/official/material-icons/icons/yaml.svg new file mode 100644 index 000000000..e1f12636a --- /dev/null +++ b/plugins/official/material-icons/icons/yaml.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e53935"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">YML</text></svg> diff --git a/plugins/official/material-icons/icons/zig.svg b/plugins/official/material-icons/icons/zig.svg new file mode 100644 index 000000000..26894c3fb --- /dev/null +++ b/plugins/official/material-icons/icons/zig.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#f9a825"/><text x="8" y="11.42" fill="#3e2723" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">Z</text></svg> diff --git a/plugins/official/material-icons/plugin.toml b/plugins/official/material-icons/plugin.toml new file mode 100644 index 000000000..4c56e7a6c --- /dev/null +++ b/plugins/official/material-icons/plugin.toml @@ -0,0 +1,15 @@ +id = "openidea.material-icons" +name = "Material Icons" +version = "0.1.0" +description = "Colourful file and folder icons on the Material palette: 100+ icons for languages, well-known files and project folders." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: 106 icons covering 189 extensions, 71 file names and 92 folder names." +engines.openidea = "^1.0" + +# Apply it in Settings › Plugins › Material Icons. The icons are generated by +# generate.py (not part of the package); edit its tables and re-run it. +[[contributes.iconThemes]] +id = "material" +label = "Material Icons" +path = "icons/theme.json" diff --git a/plugins/official/mybatis/.gitignore b/plugins/official/mybatis/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/mybatis/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/mybatis/Cargo.lock b/plugins/official/mybatis/Cargo.lock new file mode 100644 index 000000000..f4c6da463 --- /dev/null +++ b/plugins/official/mybatis/Cargo.lock @@ -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-mybatis" +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" diff --git a/plugins/official/mybatis/Cargo.toml b/plugins/official/mybatis/Cargo.toml new file mode 100644 index 000000000..415bdf6ae --- /dev/null +++ b/plugins/official/mybatis/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-mybatis" +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" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm new file mode 100644 index 000000000..dc36a1647 Binary files /dev/null and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/plugin.toml b/plugins/official/mybatis/plugin.toml new file mode 100644 index 000000000..fac119161 --- /dev/null +++ b/plugins/official/mybatis/plugin.toml @@ -0,0 +1,17 @@ +id = "openidea.mybatis" +name = "MyBatis Navigator" +version = "0.1.0" +description = "Jumps between MyBatis mapper interfaces and their XML statements (gutter arrows and Go to Definition), completes statement ids and #{parameters}." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: navigation both ways, line markers, id and parameter completion." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["project.read"] + +[[contributes.providers]] +kinds = ["definition", "line-markers", "completion"] +languages = ["java", "xml"] diff --git a/plugins/official/mybatis/src/lib.rs b/plugins/official/mybatis/src/lib.rs new file mode 100644 index 000000000..eca8ba1ba --- /dev/null +++ b/plugins/official/mybatis/src/lib.rs @@ -0,0 +1,376 @@ +//! MyBatis Navigator for OpenIDEA: a mapper interface (`com.acme.UserMapper`) +//! and the XML with `<mapper namespace="com.acme.UserMapper">` point at each +//! other: gutter arrows and Go to Definition on methods and statement ids, +//! completion of statement ids and `#{parameters}`. + +use openidea_plugin::providers::{CompletionItem, CompletionKind, Document, LineMarker, Providers}; +use openidea_plugin::text::{offset, position}; +use openidea_plugin::{Invocation, Location, Plugin, Position, Project, Range, host, register_plugin}; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +/// How long the namespace → XML file index is trusted. +const INDEX_TTL: Duration = Duration::from_secs(30); +const MAX_XML: u32 = 5000; +const STATEMENTS: [&str; 4] = ["select", "insert", "update", "delete"]; + +/// A named thing and where its name is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Named { + pub name: String, + pub range: Range, +} + +/// A mapper XML file. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MapperXml { + pub namespace: Option<String>, + /// `<select|insert|update|delete id="…">` (the id value's range). + pub statements: Vec<Named>, +} + +/// A Java interface. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MapperJava { + /// Fully qualified name. + pub name: Option<String>, + pub methods: Vec<(Named, Vec<String>)>, +} + +fn range_of(text: &str, start: usize, end: usize) -> Range { + Range::new(position(text, start), position(text, end)) +} + +pub fn parse_xml(text: &str) -> MapperXml { + let mut out = MapperXml::default(); + let attr = |tag_start: usize, name: &str| -> Option<(String, usize, usize)> { + let tag_end = text[tag_start..].find('>').map_or(text.len(), |i| tag_start + i); + let tag = &text[tag_start..tag_end]; + let i = tag.find(&format!("{name}=\""))? + name.len() + 2; + let j = tag[i..].find('"')? + i; + Some((tag[i..j].to_owned(), tag_start + i, tag_start + j)) + }; + let mut at = 0; + while let Some(i) = text[at..].find('<') { + let start = at + i + 1; + at = start; + let name: String = text[start..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + if name == "mapper" { + out.namespace = attr(start, "namespace").map(|(v, ..)| v); + } else if STATEMENTS.contains(&name.as_str()) + && let Some((id, s, e)) = attr(start, "id") + { + out.statements.push(Named { name: id, range: range_of(text, s, e) }); + } + } + out +} + +fn ident(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '$' +} + +/// The interface's name and methods (with their parameter names). +pub fn parse_java(text: &str) -> MapperJava { + let package = + text.lines().find_map(|l| l.trim().strip_prefix("package ").map(|p| p.trim_end_matches(';').trim().to_owned())); + let Some(i) = text.find("interface ") else { return MapperJava::default() }; + let simple: String = text[i + 10..].trim_start().chars().take_while(|c| ident(*c)).collect(); + let name = Some(match package { + Some(p) => format!("{p}.{simple}"), + None => simple, + }); + let body_start = text[i..].find('{').map_or(text.len(), |b| i + b + 1); + let mut methods = Vec::new(); + let mut depth = 0; + let mut stmt_start = body_start; + let bytes: Vec<(usize, char)> = text[body_start..].char_indices().map(|(k, c)| (body_start + k, c)).collect(); + for (k, c) in bytes { + match c { + '{' => depth += 1, + '}' if depth == 0 => break, + '}' => { + depth -= 1; + stmt_start = k + 1; + } + ';' if depth == 0 => { + let stmt = &text[stmt_start..k]; + if let Some(paren) = stmt.find('(') { + let before = stmt[..paren].trim_end(); + let name_start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1); + let method = &before[name_start..]; + if !method.is_empty() && !before.contains(" default ") { + let s = stmt_start + name_start; + let params = stmt[paren + 1..stmt.rfind(')').unwrap_or(stmt.len())] + .split(',') + .filter_map(|p| { + // `@Param("id") long id` → the @Param name wins. + if let Some(q) = p.find("@Param(\"") { + return p[q + 8..].split('"').next().map(str::to_owned); + } + p.split_whitespace().last().map(|n| n.trim_matches(|c: char| !ident(c)).to_owned()) + }) + .filter(|n| !n.is_empty()) + .collect(); + methods.push(( + Named { name: method.to_owned(), range: range_of(text, s, s + method.len()) }, + params, + )); + } + } + stmt_start = k + 1; + } + _ => {} + } + } + MapperJava { name, methods } +} + +fn project_of(path: &str) -> Option<Project> { + host::projects() + .ok()? + .into_iter() + .filter(|p| p.root().is_ok_and(|r| path.starts_with(&r))) + .max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0)) +} + +#[derive(Default)] +pub struct Navigator { + /// project root → (namespace → XML path), with when it was built. + xml_index: BTreeMap<String, (Instant, BTreeMap<String, String>)>, +} + +impl Navigator { + /// The XML mapping `namespace`, (re)indexing the project when needed. + fn xml_for(&mut self, project: &Project, namespace: &str) -> Option<(String, MapperXml)> { + let root = project.root().ok()?; + for attempt in 0..2 { + let fresh = self.xml_index.get(&root).is_some_and(|(at, _)| at.elapsed() < INDEX_TTL); + if !fresh || attempt == 1 { + let mut map = BTreeMap::new(); + for path in project.find_files("**/*.xml", MAX_XML).ok()? { + let Ok(text) = project.read_file(&path) else { continue }; + if !text.contains("<mapper") { + continue; + } + if let Some(ns) = parse_xml(&text).namespace { + map.insert(ns, path); + } + } + self.xml_index.insert(root.clone(), (Instant::now(), map)); + } + let path = self.xml_index.get(&root).and_then(|(_, m)| m.get(namespace)).cloned(); + if let Some(path) = path + && let Ok(text) = project.read_file(&path) + { + let xml = parse_xml(&text); + if xml.namespace.as_deref() == Some(namespace) { + return Some((path, xml)); + } + } + if !fresh { + break; // just rebuilt + } + } + None + } + + /// The interface `namespace` names (`com.acme.UserMapper` → `**/com/acme/UserMapper.java`). + fn java_for(project: &Project, namespace: &str) -> Option<(String, MapperJava)> { + let rel = format!("**/{}.java", namespace.replace('.', "/")); + let path = project.find_files(&rel, 1).ok()?.into_iter().next()?; + let java = parse_java(&project.read_file(&path).ok()?); + (java.name.as_deref() == Some(namespace)).then_some((path, java)) + } + + /// Pairs of (name in this document, target location) for `doc`. + fn links(&mut self, doc: &Document) -> Vec<(Named, Location, String)> { + let Some(project) = project_of(&doc.path) else { return Vec::new() }; + if doc.language == "xml" { + let xml = parse_xml(&doc.text); + let Some(ns) = xml.namespace else { return Vec::new() }; + let Some((path, java)) = Self::java_for(&project, &ns) else { return Vec::new() }; + let simple = ns.rsplit('.').next().unwrap_or(&ns).to_owned(); + xml.statements + .into_iter() + .filter_map(|s| { + let (m, _) = java.methods.iter().find(|(m, _)| m.name == s.name)?; + let tip = format!("{simple}.{}()", m.name); + Some((s, Location { path: path.clone(), range: m.range }, tip)) + }) + .collect() + } else { + let java = parse_java(&doc.text); + let Some(name) = java.name else { return Vec::new() }; + let Some((path, xml)) = self.xml_for(&project, &name) else { return Vec::new() }; + let file = path.rsplit(['/', '\\']).next().unwrap_or(&path).to_owned(); + java.methods + .into_iter() + .filter_map(|(m, _)| { + let s = xml.statements.iter().find(|s| s.name == m.name)?; + Some((m, Location { path: path.clone(), range: s.range }, format!("{file}: {}", s.name))) + }) + .collect() + } + } +} + +fn contains(r: Range, p: Position) -> bool { + r.start <= p && p <= r.end +} + +impl Providers for Navigator { + fn line_markers(&mut self, doc: &Document) -> Vec<LineMarker> { + let icon = if doc.language == "xml" { "implementing" } else { "implemented" }; + self.links(doc) + .into_iter() + .map(|(n, target, tip)| LineMarker { + line: n.range.start.line, + icon: Some(icon.into()), + tooltip: format!("Go to {tip}"), + target: Some(target), + }) + .collect() + } + + fn definition(&mut self, doc: &Document, at: Position) -> Vec<Location> { + self.links(doc).into_iter().filter(|(n, ..)| contains(n.range, at)).map(|(_, l, _)| l).collect() + } + + fn completion(&mut self, doc: &Document, at: Position, _trigger: Option<&str>) -> Vec<CompletionItem> { + if doc.language != "xml" { + return Vec::new(); + } + let Some(cursor) = offset(&doc.text, at) else { return Vec::new() }; + let before = &doc.text[..cursor]; + let xml = parse_xml(&doc.text); + let Some(ns) = xml.namespace.clone() else { return Vec::new() }; + let Some(project) = project_of(&doc.path) else { return Vec::new() }; + let Some((_, java)) = Self::java_for(&project, &ns) else { return Vec::new() }; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + let line = &before[line_start..]; + // Inside `id="…` of a statement tag: the interface's unmapped methods. + if let Some(i) = line.rfind("id=\"") + && !line[i + 4..].contains('"') + && STATEMENTS.iter().any(|t| line.contains(&format!("<{t}"))) + { + let typed = &line[i + 4..]; + let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32); + return java + .methods + .iter() + .filter(|(m, _)| m.name.starts_with(typed) && !xml.statements.iter().any(|s| s.name == m.name)) + .map(|(m, _)| CompletionItem { + replace: Some(Range::new(start, at)), + detail: Some(ns.rsplit('.').next().unwrap_or(&ns).into()), + ..CompletionItem::new(&m.name, CompletionKind::Method) + }) + .collect(); + } + // After `#{`: the enclosing statement's parameters. + if let Some(i) = before.rfind("#{") + && !before[i..].contains('}') + { + let typed = &before[i + 2..]; + let statement = + xml.statements.iter().rev().find(|s| offset(&doc.text, s.range.start).is_some_and(|o| o < cursor)); + let Some((_, params)) = statement.and_then(|s| java.methods.iter().find(|(m, _)| m.name == s.name)) else { + return Vec::new(); + }; + let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32); + return params + .iter() + .filter(|p| p.starts_with(typed)) + .map(|p| CompletionItem { + replace: Some(Range::new(start, at)), + ..CompletionItem::new(p, CompletionKind::Variable) + }) + .collect(); + } + Vec::new() + } +} + +impl Plugin for Navigator { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + Err(format!("unknown command {id}")) + } +} + +register_plugin!(Navigator, providers); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + const JAVA: &str = "package com.acme;\n\nimport org.apache.ibatis.annotations.Param;\n\npublic interface UserMapper {\n User findById(@Param(\"id\") long id);\n\n int insert(User user);\n\n default void helper() {}\n\n List<User> search(String name, int limit);\n}\n"; + const XML: &str = "<?xml version=\"1.0\"?>\n<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\" resultType=\"User\">\n select * from users where id = #{id}\n </select>\n <insert id=\"insert\">\n insert into users values (#{user})\n </insert>\n <select id=\"\">\n </select>\n</mapper>\n"; + + #[test] + fn parses_both_sides() { + let java = parse_java(JAVA); + assert_eq!(java.name.as_deref(), Some("com.acme.UserMapper")); + let names: Vec<_> = java.methods.iter().map(|(m, p)| (m.name.as_str(), p.clone())).collect(); + assert_eq!( + names, + [ + ("findById", vec!["id".to_owned()]), + ("insert", vec!["user".into()]), + ("search", vec!["name".into(), "limit".into()]) + ] + ); + assert_eq!(java.methods[0].0.range, Range::new(Position::new(5, 9), Position::new(5, 17))); + let xml = parse_xml(XML); + assert_eq!(xml.namespace.as_deref(), Some("com.acme.UserMapper")); + assert_eq!(xml.statements.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["findById", "insert", ""]); + assert_eq!(xml.statements[0].range, Range::new(Position::new(2, 14), Position::new(2, 22))); + } + + fn setup() -> MockHost { + let host = MockHost::install(); + host.add_file("src/main/java/com/acme/UserMapper.java", JAVA); + host.add_file("src/main/resources/mapper/UserMapper.xml", XML); + host.add_file("pom.xml", "<project/>"); + host + } + + fn doc(path: &str, language: &str, text: &str) -> Document { + Document { path: path.into(), language: language.into(), version: 1, text: text.into() } + } + + #[test] + fn navigates_both_ways() { + let _host = setup(); + let mut n = Navigator::default(); + let java = doc("/project/src/main/java/com/acme/UserMapper.java", "java", JAVA); + let markers = n.line_markers(&java); + assert_eq!(markers.iter().map(|m| m.line).collect::<Vec<_>>(), [5, 7], "search has no statement"); + assert_eq!(markers[0].tooltip, "Go to UserMapper.xml: findById"); + let target = markers[0].target.as_ref().unwrap(); + assert_eq!( + (target.path.as_str(), target.range.start), + ("/project/src/main/resources/mapper/UserMapper.xml", Position::new(2, 14)) + ); + + let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", XML); + let to_java = n.definition(&xml, Position::new(5, 15)); + assert_eq!(to_java.len(), 1); + assert_eq!((to_java[0].path.ends_with("UserMapper.java"), to_java[0].range.start), (true, Position::new(7, 8))); + assert!(n.definition(&xml, Position::new(3, 5)).is_empty(), "not on an id"); + assert_eq!(n.line_markers(&xml)[0].tooltip, "Go to UserMapper.findById()"); + } + + #[test] + fn completes_ids_and_parameters() { + let _host = setup(); + let mut n = Navigator::default(); + let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", XML); + let ids = n.completion(&xml, Position::new(8, 14), None); + assert_eq!(ids.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["search"], "unmapped methods only"); + let text = XML.replace("#{id}", "#{"); + let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", &text); + let params = n.completion(&xml, Position::new(3, 38), None); + assert_eq!(params.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["id"]); + } +} diff --git a/plugins/official/solidity/.gitignore b/plugins/official/solidity/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/solidity/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/solidity/Cargo.lock b/plugins/official/solidity/Cargo.lock new file mode 100644 index 000000000..d2be61fe3 --- /dev/null +++ b/plugins/official/solidity/Cargo.lock @@ -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-solidity" +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" diff --git a/plugins/official/solidity/Cargo.toml b/plugins/official/solidity/Cargo.toml new file mode 100644 index 000000000..697f1b60f --- /dev/null +++ b/plugins/official/solidity/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-solidity" +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" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm new file mode 100644 index 000000000..e6d0c908d Binary files /dev/null and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/languages/solidity.toml b/plugins/official/solidity/languages/solidity.toml new file mode 100644 index 000000000..27c519ddb --- /dev/null +++ b/plugins/official/solidity/languages/solidity.toml @@ -0,0 +1,21 @@ +id = "solidity" +name = "Solidity" +extensions = ["sol"] +line-comment = "//" +block-comment = ["/*", "*/"] +indent = { tab-size = 4, insert-spaces = true } + +[[language-servers]] +name = "solidity-language-server" +command = "nomicfoundation-solidity-language-server" +args = ["--stdio"] +root-markers = ["hardhat.config.ts", "hardhat.config.js", "foundry.toml", "truffle-config.js", "remappings.txt", "package.json"] +install-hint = "npm install -g @nomicfoundation/solidity-language-server" + +[[language-servers]] +name = "solc" +command = "solc" +args = ["--lsp"] +root-markers = ["foundry.toml", "remappings.txt"] +fallback = true +install-hint = "Install solc (https://docs.soliditylang.org/en/latest/installing-solidity.html)" diff --git a/plugins/official/solidity/plugin.toml b/plugins/official/solidity/plugin.toml new file mode 100644 index 000000000..cc03892bf --- /dev/null +++ b/plugins/official/solidity/plugin.toml @@ -0,0 +1,41 @@ +id = "openidea.solidity" +name = "Solidity" +version = "0.1.0" +description = "Solidity support: file detection, highlighting, structure view and folding, plus the Solidity language server when it is installed." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: .sol detection, highlighting, structure view, folding; language server via @nomicfoundation/solidity-language-server or solc." +engines.openidea = "^1.0" +# Highlighting, structure and folding come from this component; it is loaded +# when the first Solidity file is opened. +wasm = "dist/plugin.wasm" +activation = ["on-language:solidity"] + +# The language servers in the definition need `process:` capabilities; they +# are asked for on install and can be refused (the rest keeps working). +[[contributes.languages]] +path = "languages/solidity.toml" + +# Offered in Foundry / Hardhat projects once the program is allowed. +[[contributes.runDetectors]] +id = "forge-test" +name = "forge test" +files = ["foundry.toml"] +command = "forge" +args = ["test"] +test = true + +[[contributes.runDetectors]] +id = "forge-build" +name = "forge build" +files = ["foundry.toml"] +command = "forge" +args = ["build"] + +[[contributes.runDetectors]] +id = "hardhat-test" +name = "hardhat test" +files = ["hardhat.config.ts", "hardhat.config.js"] +command = "npx" +args = ["hardhat", "test"] +test = true diff --git a/plugins/official/solidity/src/lib.rs b/plugins/official/solidity/src/lib.rs new file mode 100644 index 000000000..5b7abcd47 --- /dev/null +++ b/plugins/official/solidity/src/lib.rs @@ -0,0 +1,689 @@ +//! Solidity for OpenIDEA: highlighting, structure view and folding from a +//! small hand-written lexer (the IDE has no Solidity grammar), exported as the +//! plugin's `syntax-provider`. Detection and the language server come from +//! `languages/solidity.toml`. + +use openidea_plugin::syntax::{Fold, FoldKind, Highlight, Symbol, SymbolKind, SyntaxProvider, Token, bracket_folds}; +use openidea_plugin::{Invocation, Plugin, register_plugin}; +use std::ops::Range; + +#[derive(Default)] +pub struct Solidity; + +impl Plugin for Solidity { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + Err(format!("unknown command {id}")) + } +} + +impl SyntaxProvider for Solidity { + fn highlights(&mut self, _language: &str, text: &str) -> Vec<Token> { + highlights(text) + } + + fn outline(&mut self, _language: &str, text: &str) -> Vec<Symbol> { + outline(text) + } + + fn folding(&mut self, _language: &str, text: &str) -> Vec<Fold> { + folding(text) + } +} + +register_plugin!(Solidity, syntax); + +// --- lexer ------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Ident, + Number, + Str, + Comment, + DocComment, + Op, + Punct, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Lexeme { + kind: Kind, + start: usize, + end: usize, +} + +impl Lexeme { + fn text<'a>(&self, src: &'a str) -> &'a str { + &src[self.start..self.end] + } + + fn is_trivia(&self) -> bool { + matches!(self.kind, Kind::Comment | Kind::DocComment) + } +} + +fn is_ident_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' || b == b'$' +} + +fn is_ident(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' +} + +fn lex(text: &str) -> Vec<Lexeme> { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + let start = i; + let kind = if b.is_ascii_whitespace() { + i += 1; + continue; + } else if bytes[i..].starts_with(b"//") { + let end = text[i..].find('\n').map_or(bytes.len(), |n| i + n); + let doc = bytes[i..].starts_with(b"///") && !bytes[i..].starts_with(b"////"); + i = end; + if doc { Kind::DocComment } else { Kind::Comment } + } else if bytes[i..].starts_with(b"/*") { + let end = text[i + 2..].find("*/").map_or(bytes.len(), |n| i + 2 + n + 2); + let doc = bytes[i..].starts_with(b"/**") && !bytes[i..].starts_with(b"/**/"); + i = end; + if doc { Kind::DocComment } else { Kind::Comment } + } else if b == b'"' || b == b'\'' { + i = string_end(bytes, i); + Kind::Str + } else if b.is_ascii_digit() { + i = number_end(bytes, i); + Kind::Number + } else if is_ident_start(b) { + while i < bytes.len() && is_ident(bytes[i]) { + i += 1; + } + // hex"…", unicode"…" + let word = &text[start..i]; + if (word == "hex" || word == "unicode") && matches!(bytes.get(i), Some(b'"' | b'\'')) { + i = string_end(bytes, i); + Kind::Str + } else { + Kind::Ident + } + } else if b"(){}[];,.".contains(&b) { + i += 1; + Kind::Punct + } else if b"+-*/%=!<>&|^~?:".contains(&b) { + while i < bytes.len() && b"+-*/%=!<>&|^~?:".contains(&bytes[i]) && !bytes[i..].starts_with(b"//") { + i += 1; + } + Kind::Op + } else { + // Anything else (non-ASCII outside strings): skip the whole character. + i += text[i..].chars().next().map_or(1, char::len_utf8); + continue; + }; + out.push(Lexeme { kind, start, end: i }); + } + out +} + +fn string_end(bytes: &[u8], open: usize) -> usize { + let quote = bytes[open]; + let mut i = open + 1; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'\n' => return i, + c if c == quote => return i + 1, + _ => i += 1, + } + } + bytes.len() +} + +fn number_end(bytes: &[u8], mut i: usize) -> usize { + if bytes[i..].starts_with(b"0x") || bytes[i..].starts_with(b"0X") { + i += 2; + while i < bytes.len() && (bytes[i].is_ascii_hexdigit() || bytes[i] == b'_') { + i += 1; + } + return i; + } + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'_') { + i += 1; + } + if i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1].is_ascii_digit() { + i += 1; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'_') { + i += 1; + } + } + if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') { + let mut j = i + 1; + if j < bytes.len() && (bytes[j] == b'-' || bytes[j] == b'+') { + j += 1; + } + if j < bytes.len() && bytes[j].is_ascii_digit() { + i = j; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + } + i +} + +// --- highlighting -------------------------------------------------------------- + +const KEYWORDS: &[&str] = &[ + "abstract", + "anonymous", + "as", + "assembly", + "break", + "calldata", + "catch", + "constant", + "constructor", + "continue", + "contract", + "default", + "delete", + "do", + "else", + "emit", + "enum", + "error", + "event", + "external", + "fallback", + "for", + "from", + "function", + "global", + "if", + "immutable", + "import", + "indexed", + "interface", + "internal", + "is", + "let", + "library", + "memory", + "modifier", + "new", + "override", + "payable", + "pragma", + "private", + "public", + "pure", + "receive", + "return", + "returns", + "revert", + "solidity", + "storage", + "struct", + "switch", + "case", + "transient", + "try", + "type", + "unchecked", + "using", + "view", + "virtual", + "while", + "leave", + "wei", + "gwei", + "ether", + "seconds", + "minutes", + "hours", + "days", + "weeks", + "years", +]; + +const BUILTINS: &[&str] = &[ + "msg", + "block", + "tx", + "abi", + "now", + "gasleft", + "blockhash", + "blobhash", + "keccak256", + "sha256", + "ripemd160", + "ecrecover", + "addmod", + "mulmod", + "selfdestruct", + "require", + "assert", + "this", + "super", +]; + +const ATOMS: &[&str] = &["true", "false"]; + +fn is_elementary_type(word: &str) -> bool { + if matches!( + word, + "address" | "bool" | "string" | "byte" | "bytes" | "int" | "uint" | "fixed" | "ufixed" | "mapping" | "var" + ) { + return true; + } + let sized = |prefix: &str| { + word.strip_prefix(prefix).is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit() || b == b'x')) + }; + sized("uint") || sized("int") || sized("bytes") || sized("ufixed") || sized("fixed") +} + +/// The next lexeme that is not a comment. +fn next_code(lexemes: &[Lexeme], i: usize) -> Option<&Lexeme> { + lexemes[i + 1..].iter().find(|l| !l.is_trivia()) +} + +fn prev_code(lexemes: &[Lexeme], i: usize) -> Option<&Lexeme> { + lexemes[..i].iter().rev().find(|l| !l.is_trivia()) +} + +fn highlights(text: &str) -> Vec<Token> { + let lexemes = lex(text); + let mut out = Vec::with_capacity(lexemes.len()); + for (i, l) in lexemes.iter().enumerate() { + let kind = match l.kind { + Kind::Comment => Highlight::Comment, + Kind::DocComment => Highlight::DocComment, + Kind::Str => Highlight::String, + Kind::Number => Highlight::Number, + Kind::Op => Highlight::Operator, + Kind::Punct => Highlight::Punctuation, + Kind::Ident => { + let word = l.text(text); + let prev = prev_code(&lexemes, i).map(|p| p.text(text)); + let next = next_code(&lexemes, i).map(|n| n.text(text)); + if prev == Some(".") { + if next == Some("(") { Highlight::FunctionCall } else { Highlight::Property } + } else if ATOMS.contains(&word) { + Highlight::Atom + } else if KEYWORDS.contains(&word) { + Highlight::Keyword + } else if is_elementary_type(word) { + Highlight::Type + } else if matches!(prev, Some("function" | "modifier" | "event" | "error")) { + Highlight::Function + } else if matches!(prev, Some("contract" | "interface" | "library" | "struct" | "enum" | "is" | "new")) + { + Highlight::Type + } else if BUILTINS.contains(&word) { + Highlight::Builtin + } else if next == Some("(") { + Highlight::FunctionCall + } else if word.starts_with(|c: char| c.is_ascii_uppercase()) { + if word.len() > 1 && word.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + { + Highlight::Constant + } else { + Highlight::Type + } + } else { + continue; + } + } + }; + out.push(Token { range: l.start..l.end, kind }); + } + out +} + +// --- structure ------------------------------------------------------------------ + +/// An open `{`: the symbol whose body it is, and what may be declared inside. +struct Frame { + symbol: Option<usize>, + body: Body, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Body { + File, + Contract, + Struct, + Enum, + Code, +} + +const MEMBER_MODIFIERS: &[&str] = + &["public", "private", "internal", "external", "constant", "immutable", "override", "transient", "payable"]; + +fn outline(text: &str) -> Vec<Symbol> { + let code: Vec<Lexeme> = lex(text).into_iter().filter(|l| !l.is_trivia()).collect(); + let word = |i: usize| code.get(i).map_or("", |l| l.text(text)); + let mut symbols: Vec<Symbol> = Vec::new(); + let mut frames = vec![Frame { symbol: None, body: Body::File }]; + // A declaration waiting for its body (`{`) or its end (`;`). + let mut pending: Option<(usize, Body)> = None; + let mut stmt_start = 0usize; + let mut i = 0; + while i < code.len() { + let l = code[i]; + let w = l.text(text); + let body = frames.last().map_or(Body::File, |f| f.body); + let parent = frames.iter().rev().find_map(|f| f.symbol); + let add = |symbols: &mut Vec<Symbol>, name_at: usize, kind: SymbolKind, detail: Option<&str>| { + let name = code[name_at]; + symbols.push(Symbol { + name: name.text(text).to_owned(), + detail: detail.map(str::to_owned), + kind, + range: l.start..name.end, + name_range: name.start..name.end, + parent, + }); + symbols.len() - 1 + }; + let declares = matches!(body, Body::File | Body::Contract) && pending.is_none(); + match (l.kind, w) { + (Kind::Punct, "{") => { + let (symbol, inner) = match pending.take() { + Some((s, b)) => (Some(s), b), + None => (None, if body == Body::Code || body == Body::Contract { Body::Code } else { body }), + }; + frames.push(Frame { symbol, body: inner }); + stmt_start = i + 1; + } + (Kind::Punct, "}") => { + if frames.len() > 1 + && let Some(frame) = frames.pop() + && let Some(s) = frame.symbol + { + symbols[s].range.end = l.end; + } + stmt_start = i + 1; + } + (Kind::Punct, ";") => { + if let Some((s, _)) = pending.take() { + symbols[s].range.end = l.end; + } else if matches!(body, Body::Contract | Body::Struct | Body::File) && stmt_start < i { + // `Type [modifiers] name [= value];`: a state variable, struct member or file constant. + let stmt = &code[stmt_start..i]; + let head = stmt[0].text(text); + let skip = matches!(head, "using" | "import" | "pragma" | "return" | "emit" | "type"); + let until = stmt.iter().position(|s| s.text(text) == "=").unwrap_or(stmt.len()); + let name = stmt[..until].iter().rposition(|s| { + s.kind == Kind::Ident + && !MEMBER_MODIFIERS.contains(&s.text(text)) + && !is_elementary_type(s.text(text)) + }); + if let (false, Some(n)) = (skip, name) + && n > 0 + { + let constant = stmt[..until].iter().any(|s| matches!(s.text(text), "constant" | "immutable")); + let kind = if constant { SymbolKind::Constant } else { SymbolKind::Field }; + if body != Body::File || constant { + let at = stmt_start + n; + let name = code[at]; + symbols.push(Symbol { + name: name.text(text).to_owned(), + detail: None, + kind, + range: code[stmt_start].start..l.end, + name_range: name.start..name.end, + parent, + }); + } + } + } + stmt_start = i + 1; + } + (Kind::Ident, "contract" | "interface" | "library") if declares && word(i + 1) != "" => { + if code.get(i + 1).is_some_and(|n| n.kind == Kind::Ident) { + let kind = match w { + "interface" => SymbolKind::Interface, + "library" => SymbolKind::Module, + _ => SymbolKind::Class, + }; + let abstract_ = i > 0 && word(i - 1) == "abstract"; + let s = add(&mut symbols, i + 1, kind, abstract_.then_some("abstract")); + if abstract_ { + symbols[s].range.start = code[i - 1].start; + } + pending = Some((s, Body::Contract)); + i += 1; + } + } + (Kind::Ident, "function" | "modifier" | "event" | "error") if declares => { + if code.get(i + 1).is_some_and(|n| n.kind == Kind::Ident) { + let (kind, detail) = match w { + "function" if body == Body::Contract => (SymbolKind::Method, None), + "function" => (SymbolKind::Function, None), + "modifier" => (SymbolKind::Method, Some("modifier")), + "event" => (SymbolKind::Event, None), + _ => (SymbolKind::Event, Some("error")), + }; + let s = add(&mut symbols, i + 1, kind, detail); + pending = Some((s, Body::Code)); + i += 1; + } + } + (Kind::Ident, "constructor" | "fallback" | "receive") if declares && word(i + 1) == "(" => { + let kind = if w == "constructor" { SymbolKind::Constructor } else { SymbolKind::Method }; + let s = add(&mut symbols, i, kind, None); + pending = Some((s, Body::Code)); + } + (Kind::Ident, "struct" | "enum") if declares => { + if code.get(i + 1).is_some_and(|n| n.kind == Kind::Ident) { + let (kind, inner) = + if w == "struct" { (SymbolKind::Struct, Body::Struct) } else { (SymbolKind::Enum, Body::Enum) }; + let s = add(&mut symbols, i + 1, kind, None); + pending = Some((s, inner)); + i += 1; + } + } + (Kind::Ident, "type") if declares && word(i + 2) == "is" => { + let s = add(&mut symbols, i + 1, SymbolKind::TypeAlias, None); + pending = Some((s, Body::Code)); + i += 1; + } + (Kind::Ident, _) if body == Body::Enum && matches!(word(i + 1), "," | "}") => { + symbols.push(Symbol { + name: w.to_owned(), + detail: None, + kind: SymbolKind::EnumMember, + range: l.start..l.end, + name_range: l.start..l.end, + parent, + }); + } + _ => {} + } + i += 1; + } + symbols +} + +// --- folding ----------------------------------------------------------------------- + +fn folding(text: &str) -> Vec<Fold> { + let lexemes = lex(text); + let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32; + let skip: Vec<Range<usize>> = lexemes + .iter() + .filter(|l| matches!(l.kind, Kind::Str | Kind::Comment | Kind::DocComment)) + .map(|l| l.start..l.end) + .collect(); + let mut folds = bracket_folds(text, b'{', b'}', &skip); + for l in lexemes.iter().filter(|l| l.is_trivia()) { + let (start, end) = (line_of(l.start), line_of(l.end)); + if end > start { + folds.push(Fold { start_line: start, end_line: end, kind: FoldKind::Comment }); + } + } + // Consecutive `import` lines. + let mut run: Option<(u32, u32)> = None; + let flush = |run: &mut Option<(u32, u32)>, folds: &mut Vec<Fold>| { + if let Some((s, e)) = run.take() + && e > s + { + folds.push(Fold { start_line: s, end_line: e, kind: FoldKind::Imports }); + } + }; + for (n, line) in text.lines().enumerate() { + let n = n as u32; + let t = line.trim_start(); + if t.starts_with("import ") || t.starts_with("import{") { + run = Some(run.map_or((n, n), |(s, _)| (s, n))); + } else if !t.is_empty() { + flush(&mut run, &mut folds); + } + } + flush(&mut run, &mut folds); + folds +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./IERC20.sol"; +import {Ownable} from "./Ownable.sol"; + +/// @title A token +abstract contract Token is IERC20, Ownable { + uint256 public constant MAX_SUPPLY = 1_000_000e18; + mapping(address => uint256) private balances; + string name = "tök"; + + enum State { Idle, Running } + struct Holder { + address who; + uint96 amount; + } + + event Transfer(address indexed from, address indexed to, uint256 value); + error Insufficient(uint256 needed); + + modifier onlyHolder() { + require(balances[msg.sender] > 0, "no"); + _; + } + + constructor(string memory n) { + name = n; + } + + function transfer(address to, uint256 value) external returns (bool) { + /* multi + line */ + if (balances[msg.sender] < value) revert Insufficient(value); + emit Transfer(msg.sender, to, value); + return true; + } + + function total() public view virtual returns (uint256); +} + +interface IThing { + function thing() external; +} + +function free(uint x) pure returns (uint) { return x; } +"#; + + fn names(symbols: &[Symbol], parent: Option<usize>) -> Vec<(&str, SymbolKind)> { + symbols.iter().filter(|s| s.parent == parent).map(|s| (s.name.as_str(), s.kind)).collect() + } + + #[test] + fn outline_lists_declarations_with_their_members() { + let symbols = outline(SAMPLE); + let top = names(&symbols, None); + assert_eq!( + top, + [("Token", SymbolKind::Class), ("IThing", SymbolKind::Interface), ("free", SymbolKind::Function)] + ); + let token = symbols.iter().position(|s| s.name == "Token").unwrap(); + assert_eq!(symbols[token].detail.as_deref(), Some("abstract")); + assert!(SAMPLE[symbols[token].range.clone()].starts_with("abstract contract Token")); + assert!(SAMPLE[symbols[token].range.clone()].ends_with('}')); + assert_eq!( + names(&symbols, Some(token)), + [ + ("MAX_SUPPLY", SymbolKind::Constant), + ("balances", SymbolKind::Field), + ("name", SymbolKind::Field), + ("State", SymbolKind::Enum), + ("Holder", SymbolKind::Struct), + ("Transfer", SymbolKind::Event), + ("Insufficient", SymbolKind::Event), + ("onlyHolder", SymbolKind::Method), + ("constructor", SymbolKind::Constructor), + ("transfer", SymbolKind::Method), + ("total", SymbolKind::Method), + ] + ); + let state = symbols.iter().position(|s| s.name == "State").unwrap(); + assert_eq!( + names(&symbols, Some(state)), + [("Idle", SymbolKind::EnumMember), ("Running", SymbolKind::EnumMember)] + ); + let holder = symbols.iter().position(|s| s.name == "Holder").unwrap(); + assert_eq!(names(&symbols, Some(holder)), [("who", SymbolKind::Field), ("amount", SymbolKind::Field)]); + let total = symbols.iter().find(|s| s.name == "total").unwrap(); + assert!(SAMPLE[total.range.clone()].ends_with("returns (uint256);")); + // Parents come first. + assert!(symbols.iter().enumerate().all(|(i, s)| s.parent.is_none_or(|p| p < i))); + } + + #[test] + fn highlights_classify_tokens() { + let tokens = highlights(SAMPLE); + let kind_of = |needle: &str| { + let at = SAMPLE.find(needle).unwrap(); + tokens.iter().find(|t| t.range.start == at).map(|t| t.kind) + }; + assert_eq!(kind_of("// SPDX"), Some(Highlight::Comment)); + assert_eq!(kind_of("/// @title"), Some(Highlight::DocComment)); + assert_eq!(kind_of("pragma"), Some(Highlight::Keyword)); + assert_eq!(kind_of("uint256 public"), Some(Highlight::Type)); + assert_eq!(kind_of("MAX_SUPPLY"), Some(Highlight::Constant)); + assert_eq!(kind_of("1_000_000e18"), Some(Highlight::Number)); + assert_eq!(kind_of("\"tök\""), Some(Highlight::String)); + assert_eq!(kind_of("Transfer(address"), Some(Highlight::Function)); + assert_eq!(kind_of("require("), Some(Highlight::Builtin)); + assert_eq!(kind_of("sender]"), Some(Highlight::Property)); + assert_eq!(kind_of("true;"), Some(Highlight::Atom)); + assert_eq!(kind_of("IERC20, Ownable"), Some(Highlight::Type)); + // Sorted and non-overlapping, on char boundaries. + assert!(tokens.windows(2).all(|w| w[0].range.end <= w[1].range.start)); + assert!(tokens.iter().all(|t| SAMPLE.is_char_boundary(t.range.start) && SAMPLE.is_char_boundary(t.range.end))); + } + + #[test] + fn folds_bodies_comments_and_imports() { + let folds = folding(SAMPLE); + let line = |needle: &str| SAMPLE[..SAMPLE.find(needle).unwrap()].matches('\n').count() as u32; + assert!(folds.contains(&Fold { start_line: 3, end_line: 4, kind: FoldKind::Imports })); + assert!(folds.iter().any(|f| f.kind == FoldKind::Comment && f.start_line == line("/* multi"))); + let contract = line("abstract contract"); + assert!(folds.iter().any(|f| f.kind == FoldKind::Region && f.start_line == contract)); + } + + #[test] + fn lexer_survives_unterminated_input() { + for text in ["\"abc", "/* open", "0x", "a.", "uint256 x = 1e", "é∑ unknown"] { + let _ = highlights(text); + let _ = outline(text); + let _ = folding(text); + } + } +} diff --git a/plugins/official/string-tools/.gitignore b/plugins/official/string-tools/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/string-tools/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/string-tools/Cargo.lock b/plugins/official/string-tools/Cargo.lock new file mode 100644 index 000000000..8f0807fe9 --- /dev/null +++ b/plugins/official/string-tools/Cargo.lock @@ -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-string-tools" +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" diff --git a/plugins/official/string-tools/Cargo.toml b/plugins/official/string-tools/Cargo.toml new file mode 100644 index 000000000..559b812e9 --- /dev/null +++ b/plugins/official/string-tools/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-string-tools" +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" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm new file mode 100644 index 000000000..1280d055b Binary files /dev/null and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/plugin.toml b/plugins/official/string-tools/plugin.toml new file mode 100644 index 000000000..18ff2bb52 --- /dev/null +++ b/plugins/official/string-tools/plugin.toml @@ -0,0 +1,171 @@ +id = "openidea.string-tools" +name = "String Tools" +version = "0.1.0" +description = "Case conversion (camelCase, snake_case, kebab-case…), encoding (Base64, URL, HTML, JSON), line sorting and more, on every selection at once." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: 26 commands in four groups." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +# The commands are declared below, so Find Action and the keymap list them +# before the code is ever loaded; it loads on the first one run. +activation = ["on-command:*"] + +[capabilities] +required = ["editor.read", "editor.write"] + +[[contributes.commands]] +id = "camel-case" +title = "String Tools: To camelCase" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "pascal-case" +title = "String Tools: To PascalCase" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "snake-case" +title = "String Tools: To snake_case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "screaming-snake-case" +title = "String Tools: To SCREAMING_SNAKE_CASE" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "kebab-case" +title = "String Tools: To kebab-case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "dot-case" +title = "String Tools: To dot.case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "title-case" +title = "String Tools: To Title Case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "upper-case" +title = "String Tools: To UPPER CASE" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "lower-case" +title = "String Tools: To lower case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "toggle-case" +title = "String Tools: Toggle Case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "base64-encode" +title = "String Tools: Encode Base64" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "base64-decode" +title = "String Tools: Decode Base64" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "url-encode" +title = "String Tools: URL-Encode" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "url-decode" +title = "String Tools: URL-Decode" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "html-escape" +title = "String Tools: Escape HTML" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "html-unescape" +title = "String Tools: Unescape HTML" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "json-escape" +title = "String Tools: Escape as JSON String" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "sort-lines" +title = "String Tools: Sort Lines" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "sort-lines-desc" +title = "String Tools: Sort Lines Descending" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "reverse-lines" +title = "String Tools: Reverse Lines" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "unique-lines" +title = "String Tools: Remove Duplicate Lines" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "trim-lines" +title = "String Tools: Trim Lines" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "reverse" +title = "String Tools: Reverse Text" +category = "String Tools › Other" +when = "editorFocus" + +[[contributes.commands]] +id = "swap-quotes" +title = "String Tools: Swap Quotes (' ↔ \")" +category = "String Tools › Other" +when = "editorFocus" + +[[contributes.commands]] +id = "increment" +title = "String Tools: Increment Numbers" +category = "String Tools › Other" +when = "editorFocus" + +[[contributes.commands]] +id = "decrement" +title = "String Tools: Decrement Numbers" +category = "String Tools › Other" +when = "editorFocus" diff --git a/plugins/official/string-tools/src/lib.rs b/plugins/official/string-tools/src/lib.rs new file mode 100644 index 000000000..c69ec6fc5 --- /dev/null +++ b/plugins/official/string-tools/src/lib.rs @@ -0,0 +1,460 @@ +//! String Tools for OpenIDEA: text transformations on every selection of the +//! focused editor, applied as one undoable change. An empty selection +//! (a cursor) works on the word under it; line commands on whole lines. + +use openidea_plugin::text::{offset, position}; +use openidea_plugin::{Error, Invocation, Plugin, Range, TextEdit, TextEditor, register_plugin}; + +#[derive(Default)] +pub struct StringTools; + +impl Plugin for StringTools { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + let transform = transform(id).ok_or_else(|| format!("unknown command {id}"))?; + let editor = ctx.editor().ok_or("String Tools works on the focused editor")?; + apply(&editor, transform, lines_command(id)).map_err(|e| e.to_string())?; + Ok(None) + } +} + +register_plugin!(StringTools); + +type Transform = fn(&str) -> String; + +fn lines_command(id: &str) -> bool { + matches!(id, "sort-lines" | "sort-lines-desc" | "reverse-lines" | "unique-lines" | "trim-lines") +} + +fn transform(id: &str) -> Option<Transform> { + Some(match id { + "camel-case" => |s| join_words(s, Style::Camel), + "pascal-case" => |s| join_words(s, Style::Pascal), + "snake-case" => |s| join_words(s, Style::Snake), + "screaming-snake-case" => |s| join_words(s, Style::ScreamingSnake), + "kebab-case" => |s| join_words(s, Style::Kebab), + "dot-case" => |s| join_words(s, Style::Dot), + "title-case" => title_case, + "upper-case" => |s| s.to_uppercase(), + "lower-case" => |s| s.to_lowercase(), + "toggle-case" => toggle_case, + "base64-encode" => |s| base64_encode(s.as_bytes()), + "base64-decode" => { + |s| base64_decode(s).map_or_else(|| s.to_owned(), |b| String::from_utf8_lossy(&b).into_owned()) + } + "url-encode" => url_encode, + "url-decode" => url_decode, + "html-escape" => html_escape, + "html-unescape" => html_unescape, + "json-escape" => json_escape, + "sort-lines" => |s| map_lines(s, |l| l.sort()), + "sort-lines-desc" => |s| map_lines(s, |l| l.sort_by(|a, b| b.cmp(a))), + "reverse-lines" => |s| map_lines(s, |l| l.reverse()), + "unique-lines" => |s| { + map_lines(s, |l| { + let mut seen = std::collections::HashSet::new(); + l.retain(|x| seen.insert(x.to_owned())); + }) + }, + "trim-lines" => |s| s.split('\n').map(str::trim).collect::<Vec<_>>().join("\n"), + "reverse" => |s| s.chars().rev().collect(), + "swap-quotes" => |s| { + s.chars() + .map(|c| match c { + '"' => '\'', + '\'' => '"', + c => c, + }) + .collect() + }, + "increment" => |s| step_numbers(s, 1), + "decrement" => |s| step_numbers(s, -1), + _ => return None, + }) +} + +/// Replaces each selection with `f(text)`: an empty one expands to the word +/// under the cursor, or (`lines`) every selection to its whole lines. +/// Retried once if the user typed meanwhile. +fn apply(editor: &TextEditor, f: Transform, lines: bool) -> Result<u64, Error> { + let selections = editor.selections()?; + for _ in 0..2 { + let doc = editor.document()?; + let mut edits = Vec::with_capacity(selections.len()); + for r in &selections { + let range = if lines { line_range(&doc.text, *r) } else { word_range(&doc.text, *r) }; + let Some(old) = doc.text_in(range) else { continue }; + let new = f(old); + if new != old { + edits.push(TextEdit { range, text: new }); + } + } + // Overlapping selections (two cursors in one word) edit once. + edits.sort_by_key(|e| e.range); + edits.dedup_by(|b, a| b.range.start < a.range.end); + match editor.apply_edits(doc.version, &edits) { + Err(Error::Stale) => continue, + other => return other, + } + } + Err(Error::Stale) +} + +fn word_range(text: &str, r: Range) -> Range { + if !r.is_empty() { + return r; + } + let Some(at) = offset(text, r.start) else { return r }; + let word = |c: char| c.is_alphanumeric() || c == '_' || c == '-' || c == '.'; + let start = text[..at].char_indices().rev().take_while(|(_, c)| word(*c)).last().map_or(at, |(i, _)| i); + let end = text[at..].char_indices().find(|(_, c)| !word(*c)).map_or(text.len(), |(i, _)| at + i); + Range::new(position(text, start), position(text, end)) +} + +fn line_range(text: &str, r: Range) -> Range { + let start = openidea_plugin::Position::new(r.start.line, 0); + let end_line = if r.end.character == 0 && r.end.line > r.start.line { r.end.line - 1 } else { r.end.line }; + let line_end = text.split('\n').nth(end_line as usize).map_or(0, |l| l.encode_utf16().count() as u32); + Range::new(start, openidea_plugin::Position::new(end_line, line_end)) +} + +#[derive(Clone, Copy)] +enum Style { + Camel, + Pascal, + Snake, + ScreamingSnake, + Kebab, + Dot, +} + +/// Splits identifiers on separators and case changes: `fooBar_baz-qux` → +/// [foo, Bar, baz, qux]; `HTTPServer` → [HTTP, Server]. +fn words(s: &str) -> Vec<String> { + let chars: Vec<char> = s.chars().collect(); + let mut out = Vec::new(); + let mut cur = String::new(); + for (i, &c) in chars.iter().enumerate() { + if !c.is_alphanumeric() { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + continue; + } + let prev = i.checked_sub(1).map(|j| chars[j]); + let next = chars.get(i + 1).copied(); + let boundary = c.is_uppercase() + && prev.is_some_and(|p| { + p.is_lowercase() || p.is_ascii_digit() || (p.is_uppercase() && next.is_some_and(char::is_lowercase)) + }); + if boundary && !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + cur.push(c); + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +fn capitalize(w: &str) -> String { + let mut c = w.chars(); + c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect()).unwrap_or_default() +} + +fn join_words(s: &str, style: Style) -> String { + // Keep surrounding whitespace (a whole-line selection). + let lead = &s[..s.len() - s.trim_start().len()]; + let trail = &s[s.trim_end().len()..]; + let w = words(s); + let body = match style { + Style::Camel => { + w.iter().enumerate().map(|(i, x)| if i == 0 { x.to_lowercase() } else { capitalize(x) }).collect() + } + Style::Pascal => w.iter().map(|x| capitalize(x)).collect(), + Style::Snake => w.iter().map(|x| x.to_lowercase()).collect::<Vec<_>>().join("_"), + Style::ScreamingSnake => w.iter().map(|x| x.to_uppercase()).collect::<Vec<_>>().join("_"), + Style::Kebab => w.iter().map(|x| x.to_lowercase()).collect::<Vec<_>>().join("-"), + Style::Dot => w.iter().map(|x| x.to_lowercase()).collect::<Vec<_>>().join("."), + }; + if body.is_empty() { s.to_owned() } else { format!("{lead}{body}{trail}") } +} + +fn title_case(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut start = true; + for c in s.chars() { + if start && c.is_alphabetic() { + out.extend(c.to_uppercase()); + start = false; + } else { + out.extend(c.to_lowercase()); + start = c.is_whitespace(); + } + } + out +} + +fn toggle_case(s: &str) -> String { + s.chars() + .flat_map(|c| -> Box<dyn Iterator<Item = char>> { + if c.is_uppercase() { Box::new(c.to_lowercase()) } else { Box::new(c.to_uppercase()) } + }) + .collect() +} + +fn map_lines(s: &str, f: impl FnOnce(&mut Vec<&str>)) -> String { + let mut lines: Vec<&str> = s.split('\n').collect(); + f(&mut lines); + lines.join("\n") +} + +/// Adds `by` to every integer in `s` (keeping zero padding). +fn step_numbers(s: &str, by: i64) -> String { + let mut out = String::with_capacity(s.len()); + let chars: Vec<char> = s.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i].is_ascii_digit() { + let start = i; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + let neg = start > 0 && chars[start - 1] == '-' && (start < 2 || !chars[start - 2].is_alphanumeric()); + let digits: String = chars[start..i].iter().collect(); + match digits.parse::<i64>() { + Ok(n) => { + let n = if neg { -n } else { n }; + let next = n.saturating_add(by); + if neg { + out.pop(); // the minus sign + } + let width = digits.len(); + let text = if next < 0 { format!("-{:0width$}", -next) } else { format!("{next:0width$}") }; + out.push_str(&text); + } + Err(_) => out.push_str(&digits), + } + } else { + out.push(chars[i]); + i += 1; + } + } + out +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn base64_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for i in 0..4 { + if i <= chunk.len() { + out.push(B64[(n >> (18 - 6 * i) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +fn base64_decode(s: &str) -> Option<Vec<u8>> { + let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + let clean = clean.strip_suffix(b"==").or_else(|| clean.strip_suffix(b"=")).unwrap_or(&clean); + let mut out = Vec::with_capacity(clean.len() * 3 / 4); + for chunk in clean.chunks(4) { + if chunk.len() == 1 { + return None; + } + let mut n = 0u32; + for (i, b) in chunk.iter().enumerate() { + let v = B64.iter().position(|x| x == b).or_else(|| match b { + b'-' => Some(62), + b'_' => Some(63), + _ => None, + })?; + n |= (v as u32) << (18 - 6 * i); + } + for i in 0..chunk.len() - 1 { + out.push((n >> (16 - 8 * i)) as u8); + } + } + Some(out) +} + +fn url_encode(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), + _ => format!("%{b:02X}"), + }) + .collect() +} + +fn url_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = |j: usize| bytes.get(j).and_then(|b| (*b as char).to_digit(16)); + match bytes[i] { + b'%' if hex(i + 1).is_some() && hex(i + 2).is_some() => { + out.push((hex(i + 1).unwrap() * 16 + hex(i + 2).unwrap()) as u8); + i += 3; + } + b'+' => { + out.push(b' '); + i += 1; + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +fn html_unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let Some(end) = rest[..rest.len().min(12)].find(';') else { + out.push('&'); + rest = &rest[1..]; + continue; + }; + let entity = &rest[1..end]; + let decoded = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some('\u{a0}'), + e if e.starts_with("#x") || e.starts_with("#X") => { + u32::from_str_radix(&e[2..], 16).ok().and_then(char::from_u32) + } + e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32), + _ => None, + }; + match decoded { + Some(c) => { + out.push(c); + rest = &rest[end + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + #[test] + fn cases() { + let t = |id: &str, s: &str| transform(id).unwrap()(s); + assert_eq!(t("camel-case", "hello_world-again"), "helloWorldAgain"); + assert_eq!(t("pascal-case", "HTTPServer error"), "HttpServerError"); + assert_eq!(t("snake-case", "parseHTTPResponse2Fast"), "parse_http_response2_fast"); + assert_eq!(t("screaming-snake-case", "maxValue"), "MAX_VALUE"); + assert_eq!(t("kebab-case", " FooBar "), " foo-bar "); + assert_eq!(t("dot-case", "a_b"), "a.b"); + assert_eq!(t("title-case", "the QUICK fox"), "The Quick Fox"); + assert_eq!(t("toggle-case", "aBc"), "AbC"); + } + + #[test] + fn encodings_round_trip() { + for s in ["", "f", "fo", "foo", "héllo wörld ✓"] { + assert_eq!(base64_decode(&base64_encode(s.as_bytes())).unwrap(), s.as_bytes()); + assert_eq!(url_decode(&url_encode(s)), s); + assert_eq!(html_unescape(&html_escape(s)), s); + } + assert_eq!(base64_encode(b"Man"), "TWFu"); + assert_eq!(url_encode("a b&c"), "a%20b%26c"); + assert_eq!(html_unescape("<a> AB &bogus; &"), "<a> AB &bogus; &"); + assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n"); + } + + #[test] + fn lines_and_numbers() { + let t = |id: &str, s: &str| transform(id).unwrap()(s); + assert_eq!(t("sort-lines", "b\na\nc"), "a\nb\nc"); + assert_eq!(t("unique-lines", "a\nb\na"), "a\nb"); + assert_eq!(t("increment", "v1.09 and -1"), "v2.10 and 0"); + assert_eq!(t("decrement", "x0"), "x-1"); + } + + #[test] + fn every_selection_and_cursor_in_one_undoable_change() { + let host = MockHost::install(); + host.open_editor("/p/a.rs", "rust", "let fooBar = baz_qux;\n", &[(0, 6, 0, 6), (0, 13, 0, 20)]); + StringTools.run_command("snake-case", &host.invocation(), None).unwrap(); + assert_eq!(host.editor_text(), "let foo_bar = baz_qux;\n"); + assert_eq!(host.editor_version(), 2, "one change for both"); + // The editor sends the (moved) selections with the next command. + host.open_editor("/p/a.rs", "rust", &host.editor_text(), &[(0, 4, 0, 11), (0, 14, 0, 21)]); + StringTools.run_command("upper-case", &host.invocation(), None).unwrap(); + assert_eq!(host.editor_text(), "let FOO_BAR = BAZ_QUX;\n"); + } + + #[test] + fn line_commands_take_whole_lines() { + let host = MockHost::install(); + host.open_editor("/p/a.txt", "text", "c\nb\na\nz", &[(0, 0, 3, 0)]); + StringTools.run_command("sort-lines", &host.invocation(), None).unwrap(); + assert_eq!(host.editor_text(), "a\nb\nc\nz"); + } + + #[test] + fn degrades_without_write_access() { + let host = MockHost::install().with_capabilities(&["editor.read"]); + host.open_editor("/p/a.txt", "text", "abc", &[(0, 0, 0, 3)]); + let err = StringTools.run_command("upper-case", &host.invocation(), None).unwrap_err(); + assert!(err.contains("editor.write"), "{err}"); + assert_eq!(host.editor_text(), "abc"); + } +} diff --git a/sdk/rust/openidea-plugin/Cargo.lock b/sdk/rust/openidea-plugin/Cargo.lock new file mode 100644 index 000000000..5b8089e7c --- /dev/null +++ b/sdk/rust/openidea-plugin/Cargo.lock @@ -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-plugin" +version = "0.1.0" +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" diff --git a/sdk/rust/openidea-plugin/Cargo.toml b/sdk/rust/openidea-plugin/Cargo.toml new file mode 100644 index 000000000..7728a8600 --- /dev/null +++ b/sdk/rust/openidea-plugin/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "openidea-plugin" +version = "0.1.0" +edition = "2024" +description = "SDK for OpenIDEA plugins: typed host API, plugin trait, and a mock host for `cargo test`" +license = "Apache-2.0" +publish = false + +# Standalone: plugins depend on it by path or version; not part of the IDE workspace. +[workspace] + +[lib] +path = "src/lib.rs" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wit-bindgen = "0.63" diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs new file mode 100644 index 000000000..12cf7a822 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -0,0 +1,588 @@ +//! The IDE functions and handles a plugin may use. Gated ones need the +//! capability in `plugin.toml` (`[capabilities] required = [...]`) and the +//! user's grant; without it they return [`Error::Denied`]. + +use crate::types::*; + +#[cfg(target_arch = "wasm32")] +mod imp { + //! Calls through the `openidea:plugin@1.0.0` world. + use crate::bindings::openidea::plugin as wit; + use crate::types::*; + + pub type InvocationRef<'a> = &'a wit::invocation::Invocation; + pub type ProjectRes = wit::workspace::Project; + pub type EditorRes = wit::editor::TextEditor; + pub type TaskRes = wit::tasks::Task; + + pub fn error(e: wit::types::Error) -> Error { + use wit::types::Error as E; + match e { + E::Denied(c) => Error::Denied(c), + E::Stale => Error::Stale, + E::NotFound(m) => Error::NotFound(m), + E::Invalid(m) => Error::Invalid(m), + E::Cancelled => Error::Cancelled, + E::Failed(m) => Error::Failed(m), + } + } + + fn range_in(r: wit::types::Range) -> Range { + Range::new(Position::new(r.start.line, r.start.character), Position::new(r.end.line, r.end.character)) + } + + fn range_out(r: Range) -> wit::types::Range { + wit::types::Range { + start: wit::types::Position { line: r.start.line, character: r.start.character }, + end: wit::types::Position { line: r.end.line, character: r.end.character }, + } + } + + pub fn log(level: LogLevel, message: &str) { + use wit::types::LogLevel as L; + let level = match level { + LogLevel::Trace => L::Trace, + LogLevel::Debug => L::Debug, + LogLevel::Info => L::Info, + LogLevel::Warn => L::Warn, + LogLevel::Error => L::Error, + }; + wit::log::log(level, message) + } + + pub fn notify(level: NotificationLevel, title: &str, body: &str) { + use wit::types::NotificationLevel as N; + let level = match level { + NotificationLevel::Info => N::Info, + NotificationLevel::Warning => N::Warning, + NotificationLevel::Error => N::Error, + }; + wit::ui::show_notification(level, title, body) + } + + pub fn set_status_item(id: &str, item: &StatusItem) { + let item = wit::ui::StatusItem { + text: item.text.clone(), + tooltip: item.tooltip.clone(), + command: item.command.clone(), + }; + wit::ui::set_status_item(id, &item) + } + + pub fn remove_status_item(id: &str) { + wit::ui::remove_status_item(id) + } + + pub fn register_command(id: &str, title: &str, category: Option<&str>) -> Result<(), Error> { + wit::contributions::register_command(id, title, category).map_err(error) + } + + pub fn register_language(definition: &str) -> Result<(), Error> { + wit::contributions::register_language(definition).map_err(error) + } + + pub fn register_theme(definition: &str) -> Result<(), Error> { + wit::contributions::register_theme(definition).map_err(error) + } + + pub fn storage_get(key: &str) -> Result<Option<String>, Error> { + wit::storage::get(key).map_err(error) + } + + pub fn storage_set(key: &str, value: &str) -> Result<(), Error> { + wit::storage::set(key, value).map_err(error) + } + + pub fn storage_delete(key: &str) -> Result<(), Error> { + wit::storage::delete(key).map_err(error) + } + + pub fn storage_keys(prefix: &str) -> Result<Vec<String>, Error> { + wit::storage::keys(prefix).map_err(error) + } + + pub fn projects() -> Result<Vec<ProjectRes>, Error> { + wit::workspace::projects().map_err(error) + } + + pub fn project_root(p: &ProjectRes) -> Result<String, Error> { + p.root().map_err(error) + } + + pub fn project_read(p: &ProjectRes, path: &str) -> Result<String, Error> { + p.read_file(path).map_err(error) + } + + pub fn invocation_project(i: InvocationRef<'_>) -> Option<ProjectRes> { + i.project() + } + + pub fn invocation_editor(i: InvocationRef<'_>) -> Option<EditorRes> { + i.editor() + } + + pub fn editor_path(e: &EditorRes) -> String { + e.path() + } + + pub fn editor_language(e: &EditorRes) -> String { + e.language() + } + + pub fn editor_document(e: &EditorRes) -> Result<Document, Error> { + e.document().map(|d| Document { version: d.version, text: d.text }).map_err(error) + } + + pub fn editor_selections(e: &EditorRes) -> Result<Vec<Range>, Error> { + e.selections().map(|s| s.into_iter().map(range_in).collect()).map_err(error) + } + + pub fn editor_apply(e: &EditorRes, version: u64, edits: &[TextEdit]) -> Result<u64, Error> { + let edits: Vec<wit::types::TextEdit> = + edits.iter().map(|t| wit::types::TextEdit { range: range_out(t.range), text: t.text.clone() }).collect(); + e.apply_edits(version, &edits).map_err(error) + } + + pub fn vcs_status(p: &ProjectRes) -> Result<Option<RepositoryStatus>, Error> { + wit::vcs::status(p) + .map(|s| { + s.map(|s| 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(error) + } + + pub fn vcs_fetch(p: &ProjectRes) -> Result<(), Error> { + wit::vcs::fetch(p).map_err(error) + } + + fn kind(k: EventKind) -> wit::events::EventKind { + use wit::events::EventKind as K; + match k { + EventKind::FileOpened => K::FileOpened, + EventKind::FileSaved => K::FileSaved, + EventKind::EditorFocused => K::EditorFocused, + EventKind::ActionPerformed => K::ActionPerformed, + EventKind::VcsChanged => K::VcsChanged, + EventKind::Scheduled => K::Scheduled, + } + } + + pub fn subscribe(k: EventKind) -> Result<(), Error> { + wit::events::subscribe(kind(k)).map_err(error) + } + + pub fn unsubscribe(k: EventKind) { + wit::events::unsubscribe(kind(k)) + } + + pub fn begin_task(title: &str, cancellable: bool) -> TaskRes { + wit::tasks::begin(title, cancellable) + } + + pub fn task_report(t: &TaskRes, done: u32, total: Option<u32>, message: Option<&str>) { + t.report(done, total, message) + } + + pub fn task_cancelled(t: &TaskRes) -> bool { + t.is_cancelled() + } + + pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result<Vec<String>, Error> { + p.find_files(glob, limit).map_err(error) + } + + pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { + use wit::diagnostics::{Diagnostic as D, Severity as S}; + let items: Vec<D> = items + .iter() + .map(|d| D { + range: range_out(d.range), + severity: match d.severity { + Severity::Error => S::Error, + Severity::Warning => S::Warning, + Severity::Info => S::Info, + Severity::Hint => S::Hint, + }, + message: d.message.clone(), + code: d.code.clone(), + }) + .collect(); + wit::diagnostics::publish(path, &items).map_err(error) + } + + pub fn fetch(req: &HttpRequest) -> Result<HttpResponse, Error> { + let r = wit::net::Request { + method: req.method.clone(), + url: req.url.clone(), + headers: req.headers.iter().map(|(n, v)| wit::net::Header { name: n.clone(), value: v.clone() }).collect(), + body: req.body.clone(), + timeout_ms: req.timeout_ms, + }; + wit::net::fetch(&r) + .map(|r| HttpResponse { + status: r.status, + headers: r.headers.into_iter().map(|h| (h.name, h.value)).collect(), + body: r.body, + }) + .map_err(error) + } + + pub fn secret_is_set(name: &str) -> bool { + wit::secrets::is_set(name) + } + + pub fn refresh_view(view: &str) { + wit::views::refresh(view) + } + + pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> { + wit::views::post_message(view, message).map_err(error) + } + + pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { + wit::views::open_terminal(title, program, args).map_err(error) + } + + pub fn setting(key: &str) -> Option<String> { + wit::settings::get(key) + } + + pub fn run_process( + program: &str, + args: &[String], + cwd: Option<&ProjectRes>, + stdin: Option<&str>, + ) -> Result<ProcessOutput, Error> { + wit::process::run(program, args, cwd, stdin) + .map(|o| ProcessOutput { status: o.status, stdout: o.stdout, stderr: o.stderr }) + .map_err(error) + } + + pub fn range_from(r: wit::types::Range) -> Range { + range_in(r) + } + + pub fn range_to(r: Range) -> wit::types::Range { + range_out(r) + } + + pub fn event(e: wit::events::Event) -> Event { + use wit::events::{ActionSource as S, Event as E}; + match e { + E::FileOpened(p) => Event::FileOpened(p), + E::FileSaved(p) => Event::FileSaved(p), + E::EditorFocused(p) => Event::EditorFocused(p), + E::VcsChanged(p) => Event::VcsChanged(p), + E::Scheduled => Event::Scheduled, + E::ActionPerformed(a) => Event::ActionPerformed(ActionPerformed { + action: a.action, + title: a.title, + source: match a.source { + S::Keyboard => ActionSource::Keyboard, + S::Menu => ActionSource::Menu, + S::Toolbar => ActionSource::Toolbar, + S::ContextMenu => ActionSource::ContextMenu, + S::Search => ActionSource::Search, + S::Other => ActionSource::Other, + }, + shortcut: a.shortcut, + }), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +use crate::mock::current as imp; + +/// Where a command or event batch comes from: the window's project and +/// focused editor. +pub struct Invocation<'a> { + inner: imp::InvocationRef<'a>, +} + +impl<'a> Invocation<'a> { + #[doc(hidden)] + pub fn __new(inner: imp::InvocationRef<'a>) -> Self { + Self { inner } + } + + /// The window's project. + pub fn project(&self) -> Option<Project> { + imp::invocation_project(self.inner).map(|inner| Project { inner }) + } + + /// The window's focused editor. + pub fn editor(&self) -> Option<TextEditor> { + imp::invocation_editor(self.inner).map(|inner| TextEditor { inner }) + } +} + +/// A project open in the IDE. +pub struct Project { + inner: imp::ProjectRes, +} + +impl Project { + /// Absolute path of the root. Needs `project.read`. + pub fn root(&self) -> Result<String, Error> { + imp::project_root(&self.inner) + } + + /// Reads a UTF-8 file inside the project (absolute or root-relative); + /// unsaved editor contents win. Needs `project.read`. + pub fn read_file(&self, path: &str) -> Result<String, Error> { + imp::project_read(&self.inner, path) + } + + /// The repository containing the project (`None`: not under git). Needs `vcs.read`. + pub fn vcs_status(&self) -> Result<Option<RepositoryStatus>, Error> { + imp::vcs_status(&self.inner) + } + + /// Fetches all remotes. Needs `vcs.write`; long-running, so call it in a [`Task`]. + pub fn vcs_fetch(&self) -> Result<(), Error> { + imp::vcs_fetch(&self.inner) + } + + /// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`), + /// at most `limit`, as absolute paths; ignored files are skipped. Needs `project.read`. + pub fn find_files(&self, glob: &str, limit: u32) -> Result<Vec<String>, Error> { + imp::project_find(&self.inner, glob, limit) + } + + /// Runs the approved `process:<program>` in this project's root. + pub fn run_process(&self, program: &str, args: &[String], stdin: Option<&str>) -> Result<ProcessOutput, Error> { + imp::run_process(program, args, Some(&self.inner), stdin) + } +} + +/// Replaces the plugin's diagnostics for `path` (absolute, in an open +/// project); an empty slice clears them. Needs `project.read`. +pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { + imp::publish_diagnostics(path, items) +} + +/// HTTP(S) to a host granted with `net:<host>` (plain http only to +/// localhost). `{{secret:<name>}}` in a header is filled in by the IDE (needs +/// `secrets`). `Error::Cancelled` when the call was superseded. +pub fn fetch(request: &HttpRequest) -> Result<HttpResponse, Error> { + imp::fetch(request) +} + +/// Whether the user stored secret `name` (declared under `[[contributes.secrets]]`). +pub fn secret_is_set(name: &str) -> bool { + imp::secret_is_set(name) +} + +/// The content of view (or custom editor) `view` changed: the IDE renders it again. +pub fn refresh_view(view: &str) { + imp::refresh_view(view) +} + +/// Sends `message` to the script of view `view`'s HTML body. +pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> { + imp::post_view_message(view, message) +} + +/// Opens a terminal tab in the invoking window running the approved +/// `process:<program>` in the project root: logs that keep coming, +/// interactive sessions (`kubectl exec -it …`). +pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { + imp::open_terminal(title, program, args) +} + +/// One of the plugin's settings (`[contributes.settings]`) as JSON: the +/// user's value (project over global) or the schema's default. +pub fn setting(key: &str) -> Option<String> { + imp::setting(key) +} + +/// A boolean setting. +pub fn setting_bool(key: &str) -> Option<bool> { + setting(key).and_then(|v| v.parse().ok()) +} + +/// A string setting. +pub fn setting_string(key: &str) -> Option<String> { + let v = setting(key)?; + let inner = v.strip_prefix('"')?.strip_suffix('"')?; + Some(crate::text::unescape_json(inner)) +} + +/// A number setting. +pub fn setting_number(key: &str) -> Option<f64> { + setting(key).and_then(|v| v.parse().ok()) +} + +/// Runs the approved `process:<program>` in the plugin's data directory +/// (see [`Project::run_process`] to run it in a project). +pub fn run_process(program: &str, args: &[String], stdin: Option<&str>) -> Result<ProcessOutput, Error> { + imp::run_process(program, args, None, stdin) +} + +/// Projects open in any window. Needs `project.read`. +pub fn projects() -> Result<Vec<Project>, Error> { + Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect()) +} + +/// A file open in an editor. +pub struct TextEditor { + inner: imp::EditorRes, +} + +impl TextEditor { + pub fn path(&self) -> String { + imp::editor_path(&self.inner) + } + + pub fn language(&self) -> String { + imp::editor_language(&self.inner) + } + + /// Current version and text. Needs `editor.read`. + pub fn document(&self) -> Result<Document, Error> { + imp::editor_document(&self.inner) + } + + /// Selections when the command was invoked. Needs `editor.read`. + pub fn selections(&self) -> Result<Vec<Range>, Error> { + imp::editor_selections(&self.inner) + } + + /// Applies `edits` (in `version`'s coordinates, not overlapping) as one + /// undoable change; [`Error::Stale`] if the document changed meanwhile. + /// Returns the new version. Needs `editor.write`. + pub fn apply_edits(&self, version: u64, edits: &[TextEdit]) -> Result<u64, Error> { + imp::editor_apply(&self.inner, version, edits) + } + + /// Replaces every selection with `f(selected text)` in one undoable + /// change, retrying once if the user typed in between. Cursors get + /// `f("")` inserted. Needs `editor.read` and `editor.write`. + pub fn transform_selections(&self, mut f: impl FnMut(&str) -> String) -> Result<u64, Error> { + let selections = self.selections()?; + let mut last = Error::Stale; + for _ in 0..2 { + let doc = self.document()?; + let mut edits = Vec::with_capacity(selections.len()); + for r in &selections { + let old = doc.text_in(*r).ok_or_else(|| Error::Invalid("selection outside the document".into()))?; + let new = f(old); + if new != old { + edits.push(TextEdit { range: *r, text: new }); + } + } + match self.apply_edits(doc.version, &edits) { + Err(Error::Stale) => last = Error::Stale, + other => return other, + } + } + Err(last) + } +} + +/// Long-running work shown in the status bar; dropping it ends it. While a +/// task is open, the current call may run for up to 60 s instead of 5 s. +pub struct Task { + inner: imp::TaskRes, +} + +impl Task { + /// `done` of `total` units (`None`: indeterminate), with a short status. + pub fn report(&self, done: u32, total: Option<u32>, message: Option<&str>) { + imp::task_report(&self.inner, done, total, message) + } + + /// The user cancelled it: stop soon and return. + pub fn is_cancelled(&self) -> bool { + imp::task_cancelled(&self.inner) + } +} + +/// Starts a task (see [`Task`]). +pub fn begin_task(title: &str, cancellable: bool) -> Task { + Task { inner: imp::begin_task(title, cancellable) } +} + +/// Writes to the IDE log, tagged with the plugin id. +pub fn log(level: LogLevel, message: &str) { + imp::log(level, message) +} + +/// Shows a notification balloon in the window the command or events came +/// from (every window when there is none). +pub fn notify(level: NotificationLevel, title: &str, body: &str) { + imp::notify(level, title, body) +} + +/// Shows or updates the plugin's status bar item `id` (at most 8). +pub fn set_status_item(id: &str, item: &StatusItem) { + imp::set_status_item(id, item) +} + +pub fn remove_status_item(id: &str) { + imp::remove_status_item(id) +} + +/// Registers or renames a command at runtime (action `plugin.<plugin-id>.<id>`). +/// Prefer declaring commands in `plugin.toml`: they show before the plugin runs. +pub fn register_command(id: &str, title: &str, category: Option<&str>) -> Result<(), Error> { + imp::register_command(id, title, category) +} + +/// Registers a language (`languages/<id>.toml` format) at runtime. +pub fn register_language(definition: &str) -> Result<(), Error> { + imp::register_language(definition) +} + +/// Registers a theme (theme JSON) at runtime. +pub fn register_theme(definition: &str) -> Result<(), Error> { + imp::register_theme(definition) +} + +/// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of +/// the session (also after the instance is reclaimed: an event activates the +/// plugin again). Needs `events` (`events.input` for `ActionPerformed`). +pub fn subscribe(kind: EventKind) -> Result<(), Error> { + imp::subscribe(kind) +} + +pub fn unsubscribe(kind: EventKind) { + imp::unsubscribe(kind) +} + +/// The plugin's persistent key-value store (≤ 1 MiB). Needs `storage`. +pub mod storage { + use super::imp; + use crate::types::Error; + + pub fn get(key: &str) -> Result<Option<String>, Error> { + imp::storage_get(key) + } + + pub fn set(key: &str, value: &str) -> Result<(), Error> { + imp::storage_set(key, value) + } + + pub fn delete(key: &str) -> Result<(), Error> { + imp::storage_delete(key) + } + + /// Keys starting with `prefix`, sorted. + pub fn keys(prefix: &str) -> Result<Vec<String>, Error> { + imp::storage_keys(prefix) + } +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub use imp::{event as __event, range_from as __range_from, range_to as __range_to}; diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs new file mode 100644 index 000000000..5695cf5e7 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -0,0 +1,649 @@ +//! SDK for OpenIDEA plugins written in Rust (plugin API 1.0). +//! +//! ```ignore +//! use openidea_plugin::{Context, Invocation, Plugin, register_plugin}; +//! +//! #[derive(Default)] +//! struct Shout; +//! +//! impl Plugin for Shout { +//! fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { +//! match id { +//! "shout" => { +//! let editor = ctx.editor().ok_or("no editor is focused")?; +//! editor.transform_selections(|s| s.to_uppercase())?; +//! Ok(None) +//! } +//! _ => Err(format!("unknown command {id}")), +//! } +//! } +//! } +//! +//! register_plugin!(Shout); +//! ``` +//! +//! On `wasm32-wasip2` the [`host`] functions and handles call the IDE through +//! the `openidea:plugin@1.0.0` WIT world. On any other target they call the +//! [`mock`] host, so plugin logic is tested with plain `cargo test`: +//! +//! ```ignore +//! let host = openidea_plugin::mock::MockHost::install(); +//! host.open_editor("/project/a.txt", "rust", "hi there", &[(0, 0, 0, 2)]); +//! Shout.run_command("shout", &host.invocation(), None).unwrap(); +//! assert_eq!(host.editor_text(), "HI there"); +//! ``` +//! +//! Instance memory is a cache: the instance is dropped after a crash or when +//! idle. Keep what must survive in [`host::storage`]. +//! +//! Build with `openidea-plugin build` (or `cargo build --release --target +//! wasm32-wasip2`); see `plugins/README.md`. + +pub mod host; +#[cfg(not(target_arch = "wasm32"))] +pub mod mock; +pub mod providers; +pub mod syntax; +pub mod text; +mod types; +pub mod views; + +pub use host::{Invocation, Project, Task, TextEditor}; +pub use types::*; + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod bindings { + wit_bindgen::generate!({ + world: "plugin", + path: "wit", + pub_export_macro: true, + export_macro_name: "__export_plugin_world", + default_bindings_module: "openidea_plugin::bindings", + }); +} + +/// The `syntax` world: only the `syntax-provider` export, merged with +/// `plugin` into one component (world `plugin-syntax`). +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod syntax_bindings { + wit_bindgen::generate!({ + world: "syntax", + path: "wit", + pub_export_macro: true, + export_macro_name: "__export_syntax_world", + default_bindings_module: "openidea_plugin::syntax_bindings", + }); +} + +/// The `editor-providers` world: only the `providers` export, merged with +/// `plugin` (world `plugin-providers`). +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod providers_bindings { + wit_bindgen::generate!({ + world: "editor-providers", + path: "wit", + pub_export_macro: true, + export_macro_name: "__export_editor_providers_world", + default_bindings_module: "openidea_plugin::providers_bindings", + with: { + "openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types, + }, + }); +} + +/// The `views-provider` world: only the `view-provider` export, merged with +/// `plugin` (world `plugin-views`). +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod views_bindings { + wit_bindgen::generate!({ + world: "views-provider", + path: "wit", + pub_export_macro: true, + export_macro_name: "__export_views_world", + default_bindings_module: "openidea_plugin::views_bindings", + with: { + "openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.0.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.0.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.0.0": crate::bindings::openidea::plugin::invocation, + }, + }); +} + +/// The plugin API version this SDK targets. +pub const API_VERSION: &str = "1.0"; + +/// A plugin. One instance lives as long as the plugin's Wasm instance (until +/// it is disabled, crashes or is reclaimed when idle). +pub trait Plugin: 'static { + /// Called once when the instance is created. Register runtime commands + /// and subscriptions here; restore state from [`host::storage`]. + fn activate(&mut self, _ctx: &Context) -> Result<(), String> { + Ok(()) + } + + /// Called before the instance is dropped. + fn deactivate(&mut self) {} + + /// Runs one of the plugin's commands; `Ok(Some(text))` is shown to the user. + fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, String>; + + /// A batch of subscribed events (see [`host::subscribe`]), oldest first. + fn handle_events(&mut self, _events: &[Event], _ctx: &Invocation) {} +} + +/// Exports `T` (a [`Plugin`] + `Default`) as the plugin's component. With +/// `syntax`, `T` is also a [`syntax::SyntaxProvider`] for its languages. +#[macro_export] +macro_rules! register_plugin { + ($plugin:ty) => { + // Natively (tests) nothing is exported; keep the type "used". + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, providers) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__providers_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, views) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__views_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, providers, views) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__providers_exports!($plugin, __OpenideaComponent); + $crate::__views_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, syntax) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + + impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for __OpenideaComponent { + fn highlights( + language: ::std::string::String, + text: ::std::string::String, + ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Token> + { + $crate::__rt::highlights::<$plugin>(&language, &text) + } + + fn outline( + language: ::std::string::String, + text: ::std::string::String, + ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Symbol> + { + $crate::__rt::outline::<$plugin>(&language, &text) + } + + fn folding( + language: ::std::string::String, + text: ::std::string::String, + ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Fold> + { + $crate::__rt::folding::<$plugin>(&language, &text) + } + } + + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + $crate::syntax_bindings::__export_syntax_world!(__OpenideaComponent with_types_in $crate::syntax_bindings); + }; + }; +} + +/// `register_plugin!(T, providers)`: `T` is also a [`providers::Providers`]. +#[doc(hidden)] +#[macro_export] +macro_rules! __providers_exports { + ($plugin:ty, $component:ident) => { + impl $crate::providers_bindings::exports::openidea::plugin::providers::Guest for $component { + fn completion( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + at: $crate::bindings::openidea::plugin::types::Position, + trigger: ::core::option::Option<::std::string::String>, + ) -> ::std::vec::Vec<$crate::providers_bindings::exports::openidea::plugin::providers::CompletionItem> { + $crate::__rt::completion::<$plugin>(doc, at, trigger) + } + + fn inline_completion( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + at: $crate::bindings::openidea::plugin::types::Position, + ) -> ::core::option::Option<$crate::providers_bindings::exports::openidea::plugin::providers::InlineSuggestion> { + $crate::__rt::inline_completion::<$plugin>(doc, at) + } + + fn code_actions( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + selection: $crate::bindings::openidea::plugin::types::Range, + ) -> ::std::vec::Vec<$crate::providers_bindings::exports::openidea::plugin::providers::CodeAction> { + $crate::__rt::code_actions::<$plugin>(doc, selection) + } + + fn definition( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + at: $crate::bindings::openidea::plugin::types::Position, + ) -> ::std::vec::Vec<$crate::bindings::openidea::plugin::types::Location> { + $crate::__rt::definition::<$plugin>(doc, at) + } + + fn line_markers( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + ) -> ::std::vec::Vec<$crate::providers_bindings::exports::openidea::plugin::providers::LineMarker> { + $crate::__rt::line_markers::<$plugin>(doc) + } + } + + $crate::providers_bindings::__export_editor_providers_world!($component with_types_in $crate::providers_bindings); + }; +} + +/// `register_plugin!(T, views)`: `T` is also a [`views::Views`]. +#[doc(hidden)] +#[macro_export] +macro_rules! __views_exports { + ($plugin:ty, $component:ident) => { + impl $crate::views_bindings::exports::openidea::plugin::view_provider::Guest for $component { + fn render( + view: ::std::string::String, + viewport: ::core::option::Option<$crate::views_bindings::openidea::plugin::view_types::Viewport>, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + ) -> ::core::result::Result< + $crate::views_bindings::openidea::plugin::view_types::View, + $crate::bindings::openidea::plugin::types::Error, + > { + $crate::__rt::render_view::<$plugin>(view, viewport, ctx) + } + + fn handle( + view: ::std::string::String, + event: $crate::views_bindings::openidea::plugin::view_types::ViewEvent, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + ) -> ::core::result::Result<(), $crate::bindings::openidea::plugin::types::Error> { + $crate::__rt::view_event::<$plugin>(view, event, ctx) + } + } + + $crate::views_bindings::__export_views_world!($component with_types_in $crate::views_bindings); + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __plugin_exports { + ($plugin:ty, $component:ident, $b:path) => { + impl $crate::bindings::exports::openidea::plugin::lifecycle::Guest for $component { + fn activate( + ctx: $crate::bindings::openidea::plugin::types::Context, + ) -> ::core::result::Result<(), ::std::string::String> { + $crate::__rt::activate::<$plugin>(ctx) + } + + fn deactivate() { + $crate::__rt::deactivate::<$plugin>() + } + } + + impl $crate::bindings::exports::openidea::plugin::commands::Guest for $component { + fn run( + id: ::std::string::String, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + arg: ::core::option::Option<::std::string::String>, + ) -> ::core::result::Result<::core::option::Option<::std::string::String>, ::std::string::String> { + $crate::__rt::run_command::<$plugin>(id, ctx, arg) + } + } + + impl $crate::bindings::exports::openidea::plugin::event_handler::Guest for $component { + fn handle( + events: ::std::vec::Vec<$crate::bindings::openidea::plugin::events::Event>, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + ) { + $crate::__rt::handle_events::<$plugin>(events, ctx) + } + } + }; +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod __rt { + //! The single plugin instance behind the exports. + use super::syntax::SyntaxProvider; + use super::{Context, Invocation, Plugin}; + use crate::bindings::openidea::plugin as b; + use crate::syntax_bindings::exports::openidea::plugin::syntax_provider as wit; + use std::any::Any; + use std::cell::RefCell; + + thread_local! { + static INSTANCE: RefCell<Option<Box<dyn Any>>> = const { RefCell::new(None) }; + } + + fn with<T: Plugin + Default, R>(f: impl FnOnce(&mut T) -> R) -> R { + INSTANCE.with(|cell| { + let mut slot = cell.borrow_mut(); + let plugin = slot.get_or_insert_with(|| Box::new(T::default())); + f(plugin.downcast_mut::<T>().expect("one plugin type per component")) + }) + } + + pub fn activate<T: Plugin + Default>(ctx: b::types::Context) -> Result<(), String> { + let ctx = Context { + plugin_id: ctx.plugin_id, + plugin_version: ctx.plugin_version, + api_version: ctx.api_version, + capabilities: ctx.capabilities, + }; + with::<T, _>(|p| p.activate(&ctx)) + } + + pub fn deactivate<T: Plugin + Default>() { + with::<T, _>(|p| p.deactivate()); + INSTANCE.with(|cell| cell.borrow_mut().take()); + } + + pub fn run_command<T: Plugin + Default>( + id: String, + ctx: &b::invocation::Invocation, + arg: Option<String>, + ) -> Result<Option<String>, String> { + let ctx = Invocation::__new(ctx); + with::<T, _>(|p| p.run_command(&id, &ctx, arg.as_deref())) + } + + // ---- providers ---- + + use crate::providers as p; + use crate::providers_bindings::exports::openidea::plugin::providers as w; + + fn doc(d: w::Document) -> p::Document { + p::Document { path: d.path, language: d.language, version: d.version, text: d.text } + } + + fn at(p: b::types::Position) -> crate::Position { + crate::Position::new(p.line, p.character) + } + + fn loc(l: crate::Location) -> b::types::Location { + b::types::Location { path: l.path, range: crate::host::__range_to(l.range) } + } + + pub fn completion<T: Plugin + p::Providers + Default>( + d: w::Document, + pos: b::types::Position, + trigger: Option<String>, + ) -> Vec<w::CompletionItem> { + let d = doc(d); + with::<T, _>(|x| x.completion(&d, at(pos), trigger.as_deref())) + .into_iter() + .map(|i| w::CompletionItem { + label: i.label, + detail: i.detail, + kind: i.kind.map(|k| match k { + p::CompletionKind::Text => w::CompletionKind::Text, + p::CompletionKind::Method => w::CompletionKind::Method, + p::CompletionKind::Function => w::CompletionKind::Function, + p::CompletionKind::Constructor => w::CompletionKind::Constructor, + p::CompletionKind::Field => w::CompletionKind::Field, + p::CompletionKind::Variable => w::CompletionKind::Variable, + p::CompletionKind::Class => w::CompletionKind::Class, + p::CompletionKind::Interface => w::CompletionKind::Interface, + p::CompletionKind::Module => w::CompletionKind::Module, + p::CompletionKind::Property => w::CompletionKind::Property, + p::CompletionKind::Keyword => w::CompletionKind::Keyword, + p::CompletionKind::Snippet => w::CompletionKind::Snippet, + p::CompletionKind::Value => w::CompletionKind::Value, + p::CompletionKind::Constant => w::CompletionKind::Constant, + p::CompletionKind::File => w::CompletionKind::File, + p::CompletionKind::Reference => w::CompletionKind::Reference, + }), + insert_text: i.insert_text, + replace: i.replace.map(crate::host::__range_to), + sort_text: i.sort_text, + documentation: i.documentation, + }) + .collect() + } + + pub fn inline_completion<T: Plugin + p::Providers + Default>( + d: w::Document, + pos: b::types::Position, + ) -> Option<w::InlineSuggestion> { + let d = doc(d); + with::<T, _>(|x| x.inline_completion(&d, at(pos))).map(|s| w::InlineSuggestion { + insert_text: s.insert_text, + replace: s.replace.map(crate::host::__range_to), + }) + } + + pub fn code_actions<T: Plugin + p::Providers + Default>( + d: w::Document, + sel: b::types::Range, + ) -> Vec<w::CodeAction> { + let d = doc(d); + with::<T, _>(|x| x.code_actions(&d, crate::host::__range_from(sel))) + .into_iter() + .map(|a| w::CodeAction { + title: a.title, + kind: a.kind, + preferred: a.preferred, + edits: a + .edits + .into_iter() + .map(|(path, edits)| w::FileEdit { + path, + edits: edits + .into_iter() + .map(|e| b::types::TextEdit { range: crate::host::__range_to(e.range), text: e.text }) + .collect(), + }) + .collect(), + }) + .collect() + } + + pub fn definition<T: Plugin + p::Providers + Default>( + d: w::Document, + pos: b::types::Position, + ) -> Vec<b::types::Location> { + let d = doc(d); + with::<T, _>(|x| x.definition(&d, at(pos))).into_iter().map(loc).collect() + } + + pub fn line_markers<T: Plugin + p::Providers + Default>(d: w::Document) -> Vec<w::LineMarker> { + let d = doc(d); + with::<T, _>(|x| x.line_markers(&d)) + .into_iter() + .map(|m| w::LineMarker { line: m.line, icon: m.icon, tooltip: m.tooltip, target: m.target.map(loc) }) + .collect() + } + + // ---- views ---- + + use crate::views as v; + use crate::views_bindings::openidea::plugin::view_types as vt; + + fn error_out(e: crate::Error) -> b::types::Error { + use b::types::Error as E; + match e { + crate::Error::Denied(c) => E::Denied(c), + crate::Error::Stale => E::Stale, + crate::Error::NotFound(m) => E::NotFound(m), + crate::Error::Invalid(m) => E::Invalid(m), + crate::Error::Cancelled => E::Cancelled, + crate::Error::Failed(m) => E::Failed(m), + } + } + + fn view_out(view: v::View) -> vt::View { + let body = match view.body { + v::Body::Message(m) => vt::Body::Message(m), + v::Body::Tree(items) => vt::Body::Tree( + items + .into_iter() + .map(|i| vt::TreeItem { + key: i.key, + parent: i.parent, + label: i.label, + icon: i.icon, + detail: i.detail, + expandable: i.expandable, + expanded: i.expanded, + }) + .collect(), + ), + v::Body::List(items) => vt::Body::List( + items + .into_iter() + .map(|i| vt::ListItem { key: i.key, label: i.label, icon: i.icon, detail: i.detail }) + .collect(), + ), + v::Body::Form(fields) => vt::Body::Form( + fields + .into_iter() + .map(|f| vt::Field { + key: f.key, + label: f.label, + value: match f.value { + v::FieldValue::Text(t) => vt::FieldValue::Text(t), + v::FieldValue::Multiline(t) => vt::FieldValue::Multiline(t), + v::FieldValue::Checkbox(c) => vt::FieldValue::Checkbox(c), + v::FieldValue::Choice(o, s) => vt::FieldValue::Choice((o, s)), + }, + }) + .collect(), + ), + v::Body::Table(t) => vt::Body::Table(vt::Table { + columns: t.columns, + total_rows: t.total_rows, + first_row: t.first_row, + rows: t.rows, + editable: t.editable, + }), + v::Body::Html(h) => vt::Body::Html(h), + }; + vt::View { + toolbar: view + .toolbar + .into_iter() + .map(|b| vt::Button { id: b.id, label: b.label, icon: b.icon, enabled: b.enabled }) + .collect(), + body, + status: view.status, + } + } + + pub fn render_view<T: Plugin + v::Views + Default>( + view: String, + viewport: Option<vt::Viewport>, + ctx: &b::invocation::Invocation, + ) -> Result<vt::View, b::types::Error> { + let ctx = Invocation::__new(ctx); + let viewport = viewport.map(|v| v::Viewport { first_row: v.first_row, rows: v.rows }); + with::<T, _>(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out) + } + + pub fn view_event<T: Plugin + v::Views + Default>( + view: String, + event: vt::ViewEvent, + ctx: &b::invocation::Invocation, + ) -> Result<(), b::types::Error> { + let ctx = Invocation::__new(ctx); + let event = match event { + vt::ViewEvent::Button(id) => v::ViewEvent::Button(id), + vt::ViewEvent::Activate(k) => v::ViewEvent::Activate(k), + vt::ViewEvent::Select(k) => v::ViewEvent::Select(k), + vt::ViewEvent::Expand(k) => v::ViewEvent::Expand(k), + vt::ViewEvent::Collapse(k) => v::ViewEvent::Collapse(k), + vt::ViewEvent::Field((k, value)) => v::ViewEvent::Field(k, value), + vt::ViewEvent::Cell(c) => v::ViewEvent::Cell { row: c.row, column: c.column, value: c.value }, + vt::ViewEvent::InsertRow(r) => v::ViewEvent::InsertRow(r), + vt::ViewEvent::DeleteRows(r) => v::ViewEvent::DeleteRows(r), + vt::ViewEvent::Message(m) => v::ViewEvent::Message(m), + }; + with::<T, _>(|p| p.handle(&view, event, &ctx)).map_err(error_out) + } + + pub fn handle_events<T: Plugin + Default>(events: Vec<b::events::Event>, ctx: &b::invocation::Invocation) { + let events: Vec<crate::Event> = events.into_iter().map(crate::host::__event).collect(); + let ctx = Invocation::__new(ctx); + with::<T, _>(|p| p.handle_events(&events, &ctx)) + } + + fn offset(n: usize) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) + } + + pub fn highlights<T: Plugin + SyntaxProvider + Default>(language: &str, text: &str) -> Vec<wit::Token> { + with::<T, _>(|p| p.highlights(language, text)) + .into_iter() + .map(|t| wit::Token { start: offset(t.range.start), end: offset(t.range.end), kind: t.kind.name().into() }) + .collect() + } + + pub fn outline<T: Plugin + SyntaxProvider + Default>(language: &str, text: &str) -> Vec<wit::Symbol> { + with::<T, _>(|p| p.outline(language, text)) + .into_iter() + .map(|s| wit::Symbol { + name: s.name, + detail: s.detail, + kind: s.kind.name().into(), + start: offset(s.range.start), + end: offset(s.range.end), + name_start: offset(s.name_range.start), + name_end: offset(s.name_range.end), + parent: s.parent.map(offset), + }) + .collect() + } + + pub fn folding<T: Plugin + SyntaxProvider + Default>(language: &str, text: &str) -> Vec<wit::Fold> { + with::<T, _>(|p| p.folding(language, text)) + .into_iter() + .map(|f| wit::Fold { + start_line: f.start_line, + end_line: f.end_line, + kind: match f.kind { + super::syntax::FoldKind::Region => "region", + super::syntax::FoldKind::Comment => "comment", + super::syntax::FoldKind::Imports => "imports", + } + .into(), + }) + .collect() + } +} diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs new file mode 100644 index 000000000..e82a96cfa --- /dev/null +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -0,0 +1,788 @@ +//! A mock IDE for testing plugins with `cargo test` on the host target. +//! +//! [`MockHost::install`] makes the [`crate::host`] functions of the current +//! thread talk to a fresh mock: it records what the plugin did, keeps one +//! editor with a versioned document, and enforces the capabilities you give +//! it like the real host (a missing one returns [`Error::Denied`]). + +use crate::host::Invocation; +use crate::types::*; +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::marker::PhantomData; +use std::rc::Rc; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Notification { + pub level: NotificationLevel, + pub title: String, + pub body: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Command { + pub id: String, + pub title: String, + pub category: Option<String>, +} + +/// A task the plugin began. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MockTask { + pub title: String, + /// `(done, total, message)` reports, in order. + pub reports: Vec<(u32, Option<u32>, Option<String>)>, + pub cancelled: bool, + pub ended: bool, +} + +type NetHandler = Box<dyn Fn(&HttpRequest) -> Result<HttpResponse, Error>>; +type ProcessHandler = Box<dyn Fn(&[String], Option<&str>) -> Result<ProcessOutput, Error>>; + +/// A process run the plugin made. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProcessRun { + pub program: String, + pub args: Vec<String>, + /// The project root it ran in (`None`: the data directory). + pub cwd: Option<String>, +} + +struct Editor { + path: String, + language: String, + text: String, + version: u64, + selections: Vec<Range>, +} + +#[derive(Default)] +struct State { + capabilities: Vec<String>, + root: Option<String>, + other_projects: Vec<String>, + files: BTreeMap<String, String>, + editor: Option<Editor>, + notifications: Vec<Notification>, + commands: Vec<Command>, + languages: Vec<String>, + themes: Vec<String>, + logs: Vec<(LogLevel, String)>, + status: BTreeMap<String, StatusItem>, + storage: BTreeMap<String, String>, + subscriptions: BTreeSet<EventKind>, + tasks: Vec<MockTask>, + vcs: Option<RepositoryStatus>, + fetches: u32, + fetch_error: Option<Error>, + diagnostics: BTreeMap<String, Vec<Diagnostic>>, + secrets: BTreeMap<String, String>, + net: Option<NetHandler>, + requests: Vec<HttpRequest>, + processes: BTreeMap<String, ProcessHandler>, + runs: Vec<ProcessRun>, + refreshed: Vec<String>, + view_messages: Vec<(String, String)>, + terminals: Vec<MockTerminal>, + settings: BTreeMap<String, String>, +} + +/// A terminal the plugin opened. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MockTerminal { + pub title: String, + pub program: String, + pub args: Vec<String>, +} + +thread_local! { + static CURRENT: RefCell<Option<Rc<RefCell<State>>>> = const { RefCell::new(None) }; +} + +/// Handle to the mock installed for this thread. Dropping it uninstalls it. +pub struct MockHost { + state: Rc<RefCell<State>>, +} + +/// Every capability a plugin can hold, except `process:`/`net:`/`fs.read:`. +const ALL: &[&str] = + &["project.read", "editor.read", "editor.write", "vcs.read", "vcs.write", "storage", "events", "events.input"]; + +impl MockHost { + /// Installs a mock granting the usual capabilities, with project `/project`. + pub fn install() -> Self { + let state = Rc::new(RefCell::new(State { + capabilities: ALL.iter().map(|s| (*s).to_owned()).collect(), + root: Some("/project".into()), + ..State::default() + })); + CURRENT.with(|c| *c.borrow_mut() = Some(state.clone())); + Self { state } + } + + /// Grants exactly these capabilities. + pub fn with_capabilities(self, caps: &[&str]) -> Self { + self.state.borrow_mut().capabilities = caps.iter().map(|s| (*s).to_owned()).collect(); + self + } + + /// The invoking window's project (`None`: no project). + pub fn set_project_root(&self, root: Option<&str>) { + self.state.borrow_mut().root = root.map(str::to_owned); + } + + /// More open projects (for [`crate::host::projects`]). + pub fn add_project(&self, root: &str) { + self.state.borrow_mut().other_projects.push(root.to_owned()); + } + + /// A project file `read_file` can return (project-relative or absolute path). + pub fn add_file(&self, path: &str, text: &str) { + self.state.borrow_mut().files.insert(path.to_owned(), text.to_owned()); + } + + /// Focuses an editor on `text` with selections `(line, col, line, col)`. + pub fn open_editor(&self, path: &str, language: &str, text: &str, selections: &[(u32, u32, u32, u32)]) { + let selections = + selections.iter().map(|&(a, b, c, d)| Range::new(Position::new(a, b), Position::new(c, d))).collect(); + self.state.borrow_mut().editor = + Some(Editor { path: path.into(), language: language.into(), text: text.into(), version: 1, selections }); + } + + pub fn editor_text(&self) -> String { + self.state.borrow().editor.as_ref().map(|e| e.text.clone()).unwrap_or_default() + } + + pub fn editor_version(&self) -> u64 { + self.state.borrow().editor.as_ref().map_or(0, |e| e.version) + } + + /// The user edits the document (its version moves on). + pub fn type_in_editor(&self, text: &str) { + if let Some(e) = self.state.borrow_mut().editor.as_mut() { + e.text.push_str(text); + e.version += 1; + } + } + + /// The invocation a command from the focused window gets. + pub fn invocation(&self) -> Invocation<'static> { + Invocation::__new(MockInvocation(PhantomData)) + } + + pub fn notifications(&self) -> Vec<Notification> { + self.state.borrow().notifications.clone() + } + + pub fn commands(&self) -> Vec<Command> { + self.state.borrow().commands.clone() + } + + pub fn languages(&self) -> Vec<String> { + self.state.borrow().languages.clone() + } + + pub fn themes(&self) -> Vec<String> { + self.state.borrow().themes.clone() + } + + pub fn logs(&self) -> Vec<(LogLevel, String)> { + self.state.borrow().logs.clone() + } + + pub fn status_items(&self) -> BTreeMap<String, StatusItem> { + self.state.borrow().status.clone() + } + + pub fn storage(&self) -> BTreeMap<String, String> { + self.state.borrow().storage.clone() + } + + pub fn subscriptions(&self) -> BTreeSet<EventKind> { + self.state.borrow().subscriptions.clone() + } + + pub fn tasks(&self) -> Vec<MockTask> { + self.state.borrow().tasks.clone() + } + + /// The user cancels every open task. + pub fn cancel_tasks(&self) { + for t in &mut self.state.borrow_mut().tasks { + t.cancelled = true; + } + } + + /// What `vcs-status` returns for every project (`None`: not under git). + pub fn set_vcs_status(&self, status: Option<RepositoryStatus>) { + self.state.borrow_mut().vcs = status; + } + + /// Makes `vcs-fetch` fail. + pub fn fail_fetch(&self, error: Option<Error>) { + self.state.borrow_mut().fetch_error = error; + } + + pub fn fetches(&self) -> u32 { + self.state.borrow().fetches + } + + /// Diagnostics the plugin published, per path. + pub fn diagnostics(&self) -> BTreeMap<String, Vec<Diagnostic>> { + self.state.borrow().diagnostics.clone() + } + + /// Views the plugin asked to refresh, in order. + pub fn refreshed_views(&self) -> Vec<String> { + self.state.borrow().refreshed.clone() + } + + /// `(view, message)` the plugin posted to its views' frames. + pub fn view_messages(&self) -> Vec<(String, String)> { + self.state.borrow().view_messages.clone() + } + + /// Terminals the plugin opened. + pub fn terminals(&self) -> Vec<MockTerminal> { + self.state.borrow().terminals.clone() + } + + /// The value of one of the plugin's settings, as JSON (`true`, `"text"`). + pub fn set_setting(&self, key: &str, json: &str) { + self.state.borrow_mut().settings.insert(key.into(), json.into()); + } + + /// A secret the user stored. + pub fn set_secret(&self, name: &str, value: &str) { + self.state.borrow_mut().secrets.insert(name.into(), value.into()); + } + + /// Answers the plugin's HTTP requests (secrets already filled in). + pub fn on_fetch(&self, handler: impl Fn(&HttpRequest) -> Result<HttpResponse, Error> + 'static) { + self.state.borrow_mut().net = Some(Box::new(handler)); + } + + /// Requests the plugin made, as sent (secrets filled in). + pub fn requests(&self) -> Vec<HttpRequest> { + self.state.borrow().requests.clone() + } + + /// What running `process:<program>` does: `(args, stdin) → output`. + pub fn on_process( + &self, + program: &str, + handler: impl Fn(&[String], Option<&str>) -> Result<ProcessOutput, Error> + 'static, + ) { + self.state.borrow_mut().processes.insert(program.into(), Box::new(handler)); + } + + pub fn process_runs(&self) -> Vec<ProcessRun> { + self.state.borrow().runs.clone() + } + + /// A context as `activate` receives it. + pub fn context(&self, plugin_id: &str) -> Context { + Context { + plugin_id: plugin_id.into(), + plugin_version: "0.0.0".into(), + api_version: crate::API_VERSION.into(), + capabilities: self.state.borrow().capabilities.clone(), + } + } +} + +impl Drop for MockHost { + fn drop(&mut self) { + CURRENT.with(|c| { + let mut cur = c.borrow_mut(); + if cur.as_ref().is_some_and(|s| Rc::ptr_eq(s, &self.state)) { + *cur = None; + } + }); + } +} + +#[doc(hidden)] +#[derive(Clone, Copy)] +pub struct MockInvocation<'a>(PhantomData<&'a ()>); + +#[doc(hidden)] +pub struct MockProject { + root: String, +} + +#[doc(hidden)] +pub struct MockEditor; + +#[doc(hidden)] +pub struct MockTaskRes { + index: usize, +} + +impl Drop for MockTaskRes { + fn drop(&mut self) { + current::with(|s| { + if let Some(t) = s.tasks.get_mut(self.index) { + t.ended = true; + } + }) + } +} + +#[doc(hidden)] +pub mod current { + //! The host functions, backed by the installed mock. + use super::*; + + pub type InvocationRef<'a> = MockInvocation<'a>; + pub type ProjectRes = MockProject; + pub type EditorRes = MockEditor; + pub type TaskRes = MockTaskRes; + + pub(super) fn with<R>(f: impl FnOnce(&mut State) -> R) -> R { + CURRENT.with(|c| { + let cur = c.borrow(); + let state = cur.as_ref().expect("no mock host: call openidea_plugin::mock::MockHost::install() first"); + f(&mut state.borrow_mut()) + }) + } + + fn require(s: &State, cap: &str) -> Result<(), Error> { + if s.capabilities.iter().any(|c| c == cap) { Ok(()) } else { Err(Error::Denied(cap.to_owned())) } + } + + pub fn log(level: LogLevel, message: &str) { + with(|s| s.logs.push((level, message.to_owned()))) + } + + pub fn notify(level: NotificationLevel, title: &str, body: &str) { + with(|s| s.notifications.push(Notification { level, title: title.into(), body: body.into() })) + } + + pub fn set_status_item(id: &str, item: &StatusItem) { + with(|s| s.status.insert(id.to_owned(), item.clone())); + } + + pub fn remove_status_item(id: &str) { + with(|s| s.status.remove(id)); + } + + pub fn register_command(id: &str, title: &str, category: Option<&str>) -> Result<(), Error> { + with(|s| { + let cmd = Command { id: id.into(), title: title.into(), category: category.map(str::to_owned) }; + match s.commands.iter_mut().find(|c| c.id == id) { + Some(existing) => *existing = cmd, + None => s.commands.push(cmd), + } + Ok(()) + }) + } + + pub fn register_language(definition: &str) -> Result<(), Error> { + with(|s| { + if !definition.contains("id") { + return Err(Error::Invalid("invalid language definition".into())); + } + s.languages.push(definition.into()); + Ok(()) + }) + } + + pub fn register_theme(definition: &str) -> Result<(), Error> { + with(|s| { + if !definition.trim_start().starts_with('{') { + return Err(Error::Invalid("invalid theme JSON".into())); + } + s.themes.push(definition.into()); + Ok(()) + }) + } + + pub fn storage_get(key: &str) -> Result<Option<String>, Error> { + with(|s| { + require(s, "storage")?; + Ok(s.storage.get(key).cloned()) + }) + } + + pub fn storage_set(key: &str, value: &str) -> Result<(), Error> { + with(|s| { + require(s, "storage")?; + s.storage.insert(key.to_owned(), value.to_owned()); + Ok(()) + }) + } + + pub fn storage_delete(key: &str) -> Result<(), Error> { + with(|s| { + require(s, "storage")?; + s.storage.remove(key); + Ok(()) + }) + } + + pub fn storage_keys(prefix: &str) -> Result<Vec<String>, Error> { + with(|s| { + require(s, "storage")?; + Ok(s.storage.keys().filter(|k| k.starts_with(prefix)).cloned().collect()) + }) + } + + pub fn projects() -> Result<Vec<ProjectRes>, Error> { + with(|s| { + require(s, "project.read")?; + Ok(s.root.iter().chain(&s.other_projects).map(|root| MockProject { root: root.clone() }).collect()) + }) + } + + pub fn project_root(p: &ProjectRes) -> Result<String, Error> { + with(|s| { + require(s, "project.read")?; + Ok(p.root.clone()) + }) + } + + pub fn project_read(p: &ProjectRes, path: &str) -> Result<String, Error> { + with(|s| { + require(s, "project.read")?; + let root = p.root.as_str(); + let rel = path.strip_prefix(root).map(|p| p.trim_start_matches('/')).unwrap_or(path); + if rel.starts_with('/') || rel.split('/').any(|seg| seg == "..") { + return Err(Error::Invalid(format!("{path}: access outside the project root is not allowed"))); + } + let open = s.editor.as_ref().filter(|e| e.path == path || e.path == format!("{root}/{rel}")); + if let Some(e) = open { + return Ok(e.text.clone()); + } + s.files.get(rel).or_else(|| s.files.get(path)).cloned().ok_or_else(|| Error::NotFound(path.into())) + }) + } + + pub fn invocation_project(_: InvocationRef<'_>) -> Option<ProjectRes> { + with(|s| s.root.clone().map(|root| MockProject { root })) + } + + pub fn invocation_editor(_: InvocationRef<'_>) -> Option<EditorRes> { + with(|s| s.editor.as_ref().map(|_| MockEditor)) + } + + fn editor<R>(f: impl FnOnce(&mut State, &mut Editor) -> R) -> R { + with(|s| { + let mut e = s.editor.take().expect("the editor was closed"); + let r = f(s, &mut e); + s.editor = Some(e); + r + }) + } + + pub fn editor_path(_: &EditorRes) -> String { + editor(|_, e| e.path.clone()) + } + + pub fn editor_language(_: &EditorRes) -> String { + editor(|_, e| e.language.clone()) + } + + pub fn editor_document(_: &EditorRes) -> Result<Document, Error> { + editor(|s, e| { + require(s, "editor.read")?; + Ok(Document { version: e.version, text: e.text.clone() }) + }) + } + + pub fn editor_selections(_: &EditorRes) -> Result<Vec<Range>, Error> { + editor(|s, e| { + require(s, "editor.read")?; + Ok(e.selections.clone()) + }) + } + + pub fn editor_apply(_: &EditorRes, version: u64, edits: &[TextEdit]) -> Result<u64, Error> { + editor(|s, e| { + require(s, "editor.write")?; + if version != e.version { + return Err(Error::Stale); + } + let mut spans = Vec::with_capacity(edits.len()); + for edit in edits { + let start = crate::text::offset(&e.text, edit.range.start); + let end = crate::text::offset(&e.text, edit.range.end); + match (start, end) { + (Some(a), Some(b)) if a <= b => spans.push((a, b, edit.text.as_str())), + _ => return Err(Error::Invalid("edit outside the document".into())), + } + } + spans.sort_by_key(|(a, b, _)| (*a, *b)); + if spans.windows(2).any(|w| w[0].1 > w[1].0) { + return Err(Error::Invalid("overlapping edits".into())); + } + for (a, b, text) in spans.into_iter().rev() { + e.text.replace_range(a..b, text); + } + if !edits.is_empty() { + e.version += 1; + } + Ok(e.version) + }) + } + + pub fn vcs_status(_: &ProjectRes) -> Result<Option<RepositoryStatus>, Error> { + with(|s| { + require(s, "vcs.read")?; + Ok(s.vcs.clone()) + }) + } + + pub fn vcs_fetch(_: &ProjectRes) -> Result<(), Error> { + with(|s| { + require(s, "vcs.write")?; + if let Some(e) = s.fetch_error.clone() { + return Err(e); + } + s.fetches += 1; + Ok(()) + }) + } + + pub fn subscribe(kind: EventKind) -> Result<(), Error> { + with(|s| { + let cap = match kind { + EventKind::ActionPerformed => Some("events.input"), + EventKind::Scheduled => None, + _ => Some("events"), + }; + if let Some(cap) = cap { + require(s, cap)?; + } + s.subscriptions.insert(kind); + Ok(()) + }) + } + + pub fn unsubscribe(kind: EventKind) { + with(|s| s.subscriptions.remove(&kind)); + } + + pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result<Vec<String>, Error> { + with(|s| { + require(s, "project.read")?; + let root = p.root.trim_end_matches('/'); + let mut out: Vec<String> = s + .files + .keys() + .map(|k| if k.starts_with('/') { k.clone() } else { format!("{root}/{k}") }) + .chain(s.editor.as_ref().map(|e| e.path.clone())) + .filter(|path| { + path.strip_prefix(root).map(|r| r.trim_start_matches('/')).is_some_and(|rel| glob_match(glob, rel)) + }) + .collect(); + out.sort(); + out.dedup(); + out.truncate(limit as usize); + Ok(out) + }) + } + + pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { + with(|s| { + require(s, "project.read")?; + if items.is_empty() { + s.diagnostics.remove(path); + } else { + s.diagnostics.insert(path.to_owned(), items.to_vec()); + } + Ok(()) + }) + } + + fn net_host(url: &str) -> Option<String> { + let rest = url.split_once("://")?.1; + let host = rest.split(['/', ':', '?']).next()?; + Some(host.to_ascii_lowercase()) + } + + pub fn fetch(req: &HttpRequest) -> Result<HttpResponse, Error> { + with(|s| { + let host = net_host(&req.url).ok_or_else(|| Error::Invalid(format!("{:?} is not a URL", req.url)))?; + let cap = format!("net:{host}"); + if !s.capabilities.iter().any(|c| *c == cap || c == "net:*") { + return Err(Error::Denied(cap)); + } + let mut sent = req.clone(); + for (_, value) in &mut sent.headers { + while let Some(i) = value.find("{{secret:") { + require(s, "secrets")?; + let end = value[i..].find("}}").ok_or_else(|| Error::Invalid("unterminated secret".into()))? + i; + let name = value[i + 9..end].to_owned(); + let secret = + s.secrets.get(&name).cloned().ok_or_else(|| Error::NotFound(format!("secret {name:?}")))?; + value.replace_range(i..end + 2, &secret); + } + } + s.requests.push(sent.clone()); + match &s.net { + Some(handler) => handler(&sent), + None => Err(Error::Failed("no network in the mock (MockHost::on_fetch)".into())), + } + }) + } + + pub fn secret_is_set(name: &str) -> bool { + with(|s| s.capabilities.iter().any(|c| c == "secrets") && s.secrets.contains_key(name)) + } + + pub fn refresh_view(view: &str) { + with(|s| s.refreshed.push(view.into())) + } + + pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> { + with(|s| s.view_messages.push((view.into(), message.into()))); + Ok(()) + } + + pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { + with(|s| { + require(s, &format!("process:{program}"))?; + s.terminals.push(MockTerminal { title: title.into(), program: program.into(), args: args.to_vec() }); + Ok(()) + }) + } + + pub fn setting(key: &str) -> Option<String> { + with(|s| s.settings.get(key).cloned()) + } + + pub fn run_process( + program: &str, + args: &[String], + cwd: Option<&ProjectRes>, + stdin: Option<&str>, + ) -> Result<ProcessOutput, Error> { + with(|s| { + require(s, &format!("process:{program}"))?; + s.runs.push(ProcessRun { program: program.into(), args: args.to_vec(), cwd: cwd.map(|p| p.root.clone()) }); + match s.processes.get(program) { + Some(handler) => handler(args, stdin), + None => Err(Error::NotFound(format!("{program} (MockHost::on_process)"))), + } + }) + } + + pub fn begin_task(title: &str, _cancellable: bool) -> TaskRes { + with(|s| { + s.tasks.push(MockTask { title: title.into(), ..MockTask::default() }); + MockTaskRes { index: s.tasks.len() - 1 } + }) + } + + pub fn task_report(t: &TaskRes, done: u32, total: Option<u32>, message: Option<&str>) { + with(|s| s.tasks[t.index].reports.push((done, total, message.map(str::to_owned)))) + } + + pub fn task_cancelled(t: &TaskRes) -> bool { + with(|s| s.tasks[t.index].cancelled) + } +} + +/// `*` within a segment, `**` across segments, `?` one character. +fn glob_match(glob: &str, path: &str) -> bool { + fn go(p: &[u8], s: &[u8]) -> bool { + match p { + [] => s.is_empty(), + [b'*', b'*', b'/', rest @ ..] => go(rest, s) || (!s.is_empty() && go(p, &s[1..])), + [b'*', b'*', rest @ ..] => (0..=s.len()).any(|i| go(rest, &s[i..])), + [b'*', rest @ ..] => (0..=s.len()).take_while(|&i| i == 0 || s[i - 1] != b'/').any(|i| go(rest, &s[i..])), + [b'?', rest @ ..] => !s.is_empty() && s[0] != b'/' && go(rest, &s[1..]), + [c, rest @ ..] => s.first() == Some(c) && go(rest, &s[1..]), + } + } + go(glob.as_bytes(), path.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host; + + #[test] + fn records_and_enforces() { + let mock = MockHost::install(); + mock.add_file("src/a.txt", "hello"); + let ctx = mock.invocation(); + let project = ctx.project().unwrap(); + assert_eq!(project.read_file("/project/src/a.txt").unwrap(), "hello"); + assert_eq!(project.read_file("src/a.txt").unwrap(), "hello"); + assert!(matches!(project.read_file("../etc/passwd"), Err(Error::Invalid(_)))); + host::notify(NotificationLevel::Info, "t", "b"); + host::register_command("c", "C", None).unwrap(); + host::register_command("c", "C2", Some("Cat")).unwrap(); + assert_eq!(mock.notifications()[0].title, "t"); + assert_eq!(mock.commands(), [Command { id: "c".into(), title: "C2".into(), category: Some("Cat".into()) }]); + host::storage::set("k", "v").unwrap(); + assert_eq!(host::storage::get("k").unwrap().as_deref(), Some("v")); + assert_eq!(host::storage::keys("").unwrap(), ["k"]); + host::set_status_item("s", &StatusItem { text: "x".into(), tooltip: None, command: None }); + assert_eq!(mock.status_items()["s"].text, "x"); + { + let task = host::begin_task("Work", true); + task.report(1, Some(2), None); + mock.cancel_tasks(); + assert!(task.is_cancelled()); + } + assert!(mock.tasks()[0].ended); + } + + #[test] + fn versioned_edits() { + let mock = MockHost::install(); + mock.open_editor("/project/a.txt", "text", "one two", &[(0, 0, 0, 3), (0, 4, 0, 7)]); + let editor = mock.invocation().editor().unwrap(); + let v = editor.transform_selections(|s| s.to_uppercase()).unwrap(); + assert_eq!((mock.editor_text().as_str(), v), ("ONE TWO", 2)); + assert_eq!(editor.apply_edits(1, &[]), Err(Error::Stale)); + mock.type_in_editor("!"); + assert_eq!(mock.editor_version(), 3); + } + + #[test] + fn missing_capabilities_are_denied() { + let mock = MockHost::install().with_capabilities(&["project.read"]); + mock.open_editor("/project/a.txt", "text", "x", &[]); + let editor = mock.invocation().editor().unwrap(); + assert_eq!(editor.apply_edits(1, &[]), Err(Error::Denied("editor.write".into()))); + assert_eq!(host::storage::get("k"), Err(Error::Denied("storage".into()))); + assert_eq!(host::subscribe(EventKind::ActionPerformed), Err(Error::Denied("events.input".into()))); + } + + #[test] + fn network_secrets_processes_and_files() { + let mock = MockHost::install(); + mock.add_file("src/a/UserMapper.xml", "<mapper/>"); + mock.add_file("src/B.java", "class B {}"); + let project = mock.invocation().project().unwrap(); + assert_eq!(project.find_files("**/*Mapper.xml", 10).unwrap(), ["/project/src/a/UserMapper.xml"]); + assert!( + matches!(host::fetch(&HttpRequest::get("https://api.x.io/v1")), Err(Error::Denied(c)) if c == "net:api.x.io") + ); + + let mock = MockHost::install().with_capabilities(&["net:api.x.io", "secrets", "project.read", "process:java"]); + mock.set_secret("key", "s3cr3t"); + mock.on_fetch(|r| Ok(HttpResponse { status: 200, headers: vec![], body: r.headers[0].1.clone().into_bytes() })); + let r = host::fetch(&HttpRequest::get("https://api.x.io/v1").header("Authorization", "Bearer {{secret:key}}")) + .unwrap(); + assert_eq!(r.text(), "Bearer s3cr3t"); + assert!(host::secret_is_set("key")); + mock.on_process("java", |args, _| { + Ok(ProcessOutput { status: Some(0), stdout: args.join(" "), stderr: String::new() }) + }); + let out = mock.invocation().project().unwrap().run_process("java", &["-version".into()], None).unwrap(); + assert_eq!(out.stdout, "-version"); + assert_eq!(mock.process_runs()[0].cwd.as_deref(), Some("/project")); + host::publish_diagnostics( + "/project/a.java", + &[Diagnostic { range: Range::default(), severity: Severity::Warning, message: "m".into(), code: None }], + ) + .unwrap(); + assert_eq!(mock.diagnostics()["/project/a.java"].len(), 1); + } +} diff --git a/sdk/rust/openidea-plugin/src/providers.rs b/sdk/rust/openidea-plugin/src/providers.rs new file mode 100644 index 000000000..52a0d7446 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/providers.rs @@ -0,0 +1,123 @@ +//! Editor providers: implement [`Providers`] and register with +//! `register_plugin!(T, providers)`, then declare the kinds and languages in +//! `plugin.toml`: +//! +//! ```toml +//! [[contributes.providers]] +//! kinds = ["completion", "definition"] # also inline-completion, code-actions, line-markers +//! languages = ["xml"] +//! ``` +//! +//! They run on the editing path: answer within ~50 ms (later results are +//! dropped; at 500 ms the call is interrupted). Inline completion may take +//! longer (it may use the network) but is cancelled when the user types on. + +use crate::types::{Location, Position, Range, TextEdit}; + +/// The document a request is about. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Document { + pub path: String, + pub language: String, + pub version: u64, + pub text: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompletionKind { + Text, + Method, + Function, + Constructor, + Field, + Variable, + Class, + Interface, + Module, + Property, + Keyword, + Snippet, + Value, + Constant, + File, + Reference, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CompletionItem { + pub label: String, + pub detail: Option<String>, + pub kind: Option<CompletionKind>, + pub insert_text: String, + /// Replaced on accept; `None`: the word at the caret. + pub replace: Option<Range>, + pub sort_text: Option<String>, + /// Markdown. + pub documentation: Option<String>, +} + +impl CompletionItem { + pub fn new(label: &str, kind: CompletionKind) -> Self { + Self { + label: label.into(), + detail: None, + kind: Some(kind), + insert_text: label.into(), + replace: None, + sort_text: None, + documentation: None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineSuggestion { + pub insert_text: String, + /// Replaced range; `None` inserts at the caret. + pub replace: Option<Range>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CodeAction { + pub title: String, + /// `quickfix`, `refactor`, … + pub kind: Option<String>, + pub preferred: bool, + /// (absolute path, edits) per file. + pub edits: Vec<(String, Vec<TextEdit>)>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineMarker { + /// 0-based. + pub line: u32, + /// `navigate` (default), `implemented`, `implementing`, `run`. + pub icon: Option<String>, + pub tooltip: String, + /// Opened on click. + pub target: Option<Location>, +} + +/// What a plugin provides in the editor. Only the kinds declared in +/// `plugin.toml` are ever called; the defaults return nothing. +pub trait Providers { + fn completion(&mut self, _doc: &Document, _at: Position, _trigger: Option<&str>) -> Vec<CompletionItem> { + Vec::new() + } + + fn inline_completion(&mut self, _doc: &Document, _at: Position) -> Option<InlineSuggestion> { + None + } + + fn code_actions(&mut self, _doc: &Document, _selection: Range) -> Vec<CodeAction> { + Vec::new() + } + + fn definition(&mut self, _doc: &Document, _at: Position) -> Vec<Location> { + Vec::new() + } + + fn line_markers(&mut self, _doc: &Document) -> Vec<LineMarker> { + Vec::new() + } +} diff --git a/sdk/rust/openidea-plugin/src/syntax.rs b/sdk/rust/openidea-plugin/src/syntax.rs new file mode 100644 index 000000000..b7b8516d5 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/syntax.rs @@ -0,0 +1,224 @@ +//! Syntax for the plugin's own languages (highlighting, structure view, +//! folding) when the IDE has no grammar for them. Implement [`SyntaxProvider`] +//! and register with `register_plugin!(MyPlugin, syntax)`. +//! +//! The IDE calls these with the whole document on the editing path, so keep +//! them fast (a hand-written lexer is typical). Offsets are UTF-8 byte offsets +//! into `text`; lines are 0-based. + +use std::ops::Range; + +/// Highlight categories; the theme decides the colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Highlight { + Keyword, + String, + StringSpecial, + Escape, + Regexp, + Atom, + Number, + Constant, + Comment, + DocComment, + Function, + FunctionCall, + Builtin, + Type, + Namespace, + Variable, + SelfVariable, + Property, + Attribute, + Macro, + Label, + Tag, + Operator, + Punctuation, +} + +impl Highlight { + pub fn name(self) -> &'static str { + match self { + Self::Keyword => "keyword", + Self::String => "string", + Self::StringSpecial => "string-special", + Self::Escape => "escape", + Self::Regexp => "regexp", + Self::Atom => "atom", + Self::Number => "number", + Self::Constant => "constant", + Self::Comment => "comment", + Self::DocComment => "doc-comment", + Self::Function => "function", + Self::FunctionCall => "function-call", + Self::Builtin => "builtin", + Self::Type => "type", + Self::Namespace => "namespace", + Self::Variable => "variable", + Self::SelfVariable => "self-variable", + Self::Property => "property", + Self::Attribute => "attribute", + Self::Macro => "macro", + Self::Label => "label", + Self::Tag => "tag", + Self::Operator => "operator", + Self::Punctuation => "punctuation", + } + } +} + +/// A highlighted range. Tokens must not overlap; the IDE sorts them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Token { + pub range: Range<usize>, + pub kind: Highlight, +} + +/// Kinds of structure-view symbols. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SymbolKind { + Module, + Namespace, + Package, + Class, + Interface, + Struct, + Enum, + EnumMember, + Trait, + Function, + Method, + Constructor, + Field, + Property, + Variable, + Constant, + TypeParameter, + TypeAlias, + Macro, + Event, + Operator, + Key, +} + +impl SymbolKind { + pub fn name(self) -> &'static str { + match self { + Self::Module => "module", + Self::Namespace => "namespace", + Self::Package => "package", + Self::Class => "class", + Self::Interface => "interface", + Self::Struct => "struct", + Self::Enum => "enum", + Self::EnumMember => "enum-member", + Self::Trait => "trait", + Self::Function => "function", + Self::Method => "method", + Self::Constructor => "constructor", + Self::Field => "field", + Self::Property => "property", + Self::Variable => "variable", + Self::Constant => "constant", + Self::TypeParameter => "type-parameter", + Self::TypeAlias => "type-alias", + Self::Macro => "macro", + Self::Event => "event", + Self::Operator => "operator", + Self::Key => "key", + } + } +} + +/// A symbol of the structure view. `parent` is the index of the enclosing +/// symbol in the same list, which must come before it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Symbol { + pub name: String, + pub detail: Option<String>, + pub kind: SymbolKind, + /// The whole declaration. + pub range: Range<usize>, + /// The name; where navigation lands. + pub name_range: Range<usize>, + pub parent: Option<usize>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FoldKind { + Region, + Comment, + Imports, +} + +/// A foldable range of lines (the first stays visible). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Fold { + pub start_line: u32, + pub end_line: u32, + pub kind: FoldKind, +} + +/// Syntax of the languages the plugin contributes. `language` is the +/// language id from the plugin's definition. +pub trait SyntaxProvider { + fn highlights(&mut self, language: &str, text: &str) -> Vec<Token>; + + fn outline(&mut self, _language: &str, _text: &str) -> Vec<Symbol> { + Vec::new() + } + + fn folding(&mut self, _language: &str, _text: &str) -> Vec<Fold> { + Vec::new() + } +} + +/// Folds for every multi-line `open` … `close` pair (braces, brackets), +/// skipping the ones inside the given non-code ranges (strings, comments). +pub fn bracket_folds(text: &str, open: u8, close: u8, skip: &[Range<usize>]) -> Vec<Fold> { + let mut folds = Vec::new(); + let mut stack: Vec<u32> = Vec::new(); + let mut line = 0u32; + let mut skip = skip.iter().peekable(); + for (i, b) in text.bytes().enumerate() { + while skip.peek().is_some_and(|r| r.end <= i) { + skip.next(); + } + if b == b'\n' { + line += 1; + continue; + } + if skip.peek().is_some_and(|r| r.start <= i) { + continue; + } + if b == open { + stack.push(line); + } else if b == close + && let Some(start) = stack.pop() + && line > start + { + folds.push(Fold { start_line: start, end_line: line, kind: FoldKind::Region }); + } + } + folds +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bracket_folds_skip_strings() { + let text = "a {\n \"{\"\n b {\n }\n}\n"; + let s = text.find('"').unwrap(); + let folds = bracket_folds(text, b'{', b'}', &[s..s + 3]); + assert_eq!( + folds, + [ + Fold { start_line: 2, end_line: 3, kind: FoldKind::Region }, + Fold { start_line: 0, end_line: 4, kind: FoldKind::Region }, + ] + ); + } +} diff --git a/sdk/rust/openidea-plugin/src/text.rs b/sdk/rust/openidea-plugin/src/text.rs new file mode 100644 index 000000000..2ba0f7afc --- /dev/null +++ b/sdk/rust/openidea-plugin/src/text.rs @@ -0,0 +1,82 @@ +//! Helpers for the editor's coordinates (0-based lines, UTF-16 columns). + +use crate::types::Position; + +/// Byte offset of `pos` in `text`, if it exists (a column past the end of +/// the line is clamped to it). +pub fn offset(text: &str, pos: Position) -> Option<usize> { + let mut line_start = 0; + for _ in 0..pos.line { + line_start += text[line_start..].find('\n')? + 1; + } + let line_end = text[line_start..].find('\n').map_or(text.len(), |i| line_start + i); + let mut units = 0u32; + for (i, ch) in text[line_start..line_end].char_indices() { + if units >= pos.character { + return Some(line_start + i); + } + units += ch.len_utf16() as u32; + } + Some(line_end) +} + +/// Position of the byte offset `at` in `text` (clamped to the text). +pub fn position(text: &str, at: usize) -> Position { + let at = at.min(text.len()); + let before = &text[..at]; + let line = before.matches('\n').count() as u32; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + let character = text[line_start..at].chars().map(|c| c.len_utf16() as u32).sum(); + Position { line, character } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_utf16_columns() { + let text = "ab\n😀x\nlast"; + for at in [0, 1, 2, 3, 7, 8, 9, text.len()] { + assert_eq!(offset(text, position(text, at)), Some(at), "{at}"); + } + assert_eq!(position(text, 7), Position::new(1, 2)); + assert_eq!(offset(text, Position::new(0, 99)), Some(2), "clamped to the line"); + assert_eq!(offset(text, Position::new(5, 0)), None); + } +} + +/// The text of a JSON string literal's contents (`\"`, `\\`, `\n`, `\uXXXX`). +pub fn unescape_json(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('r') => out.push('\r'), + Some('b') => out.push('\u{8}'), + Some('f') => out.push('\u{c}'), + Some('u') => { + let hex: String = chars.by_ref().take(4).collect(); + let mut code = u32::from_str_radix(&hex, 16).unwrap_or(0xfffd); + // A surrogate pair: `\ud83d\ude00`. + if (0xd800..0xdc00).contains(&code) { + let rest: String = chars.clone().take(6).collect(); + if let Some(low) = rest.strip_prefix("\\u").and_then(|h| u32::from_str_radix(h, 16).ok()) { + chars.by_ref().nth(5); + code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00); + } + } + out.push(char::from_u32(code).unwrap_or('\u{fffd}')); + } + Some(other) => out.push(other), + None => {} + } + } + out +} diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs new file mode 100644 index 000000000..34237c474 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -0,0 +1,265 @@ +//! Plain data types of the API (independent of the target). + +/// Why a host call failed. A capability that is not granted is `Denied`: +/// degrade gracefully (show what you can) instead of failing the command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Error { + /// Not granted; names the capability (`editor.write`). + Denied(String), + /// The document changed since the version the edit was made for. + Stale, + NotFound(String), + Invalid(String), + Cancelled, + Failed(String), +} + +impl std::fmt::Display for Error { + 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 in the meantime"), + 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), + } + } +} + +impl std::error::Error for Error {} + +impl From<Error> for String { + fn from(e: Error) -> Self { + e.to_string() + } +} + +/// Log level of [`crate::host::log`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +/// Severity of [`crate::host::notify`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NotificationLevel { + Info, + Warning, + Error, +} + +/// 0-based line and UTF-16 code unit column (as in the editor and LSP). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Position { + pub line: u32, + pub character: u32, +} + +impl Position { + pub const fn new(line: u32, character: u32) -> Self { + Self { line, character } + } +} + +/// Half-open range; a cursor is an empty range. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Range { + pub start: Position, + pub end: Position, +} + +impl Range { + pub const fn new(start: Position, end: Position) -> Self { + Self { start, end } + } + + pub fn is_empty(&self) -> bool { + self.start == self.end + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextEdit { + pub range: Range, + pub text: String, +} + +/// An editor's document at one version. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Document { + pub version: u64, + pub text: String, +} + +impl Document { + /// The text of `range` (UTF-16 columns), if it lies inside the document. + pub fn text_in(&self, range: Range) -> Option<&str> { + let start = crate::text::offset(&self.text, range.start)?; + let end = crate::text::offset(&self.text, range.end)?; + self.text.get(start.min(end)..end.max(start)) + } +} + +/// What `activate` receives. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Context { + pub plugin_id: String, + pub plugin_version: String, + /// Plugin API version the host implements. + pub api_version: String, + /// Capabilities granted to the plugin (`project.read`, …). + pub capabilities: Vec<String>, +} + +impl Context { + pub fn has(&self, capability: &str) -> bool { + self.capabilities.iter().any(|c| c == capability) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StatusItem { + pub text: String, + pub tooltip: Option<String>, + /// One of the plugin's commands, run on click. + pub command: Option<String>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum EventKind { + FileOpened, + FileSaved, + EditorFocused, + /// Needs `events.input`. + ActionPerformed, + VcsChanged, + Scheduled, +} + +/// How the user started an action. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActionSource { + Keyboard, + Menu, + Toolbar, + ContextMenu, + Search, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActionPerformed { + /// Action id (`editor.duplicate-line`). + pub action: String, + pub title: String, + pub source: ActionSource, + /// Its keyboard shortcut, if it has one. + pub shortcut: Option<String>, +} + +/// A workbench event (see [`crate::host::subscribe`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + FileOpened(String), + FileSaved(String), + EditorFocused(Option<String>), + ActionPerformed(ActionPerformed), + /// A repository (its root) changed. + VcsChanged(String), + /// The `on-schedule` period elapsed. + Scheduled, +} + +/// The repository containing a project. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RepositoryStatus { + pub root: String, + /// `None` when HEAD is detached. + pub branch: Option<String>, + pub commit: Option<String>, + pub upstream: Option<String>, + pub ahead: u32, + pub behind: u32, + /// Merge / rebase / cherry-pick in progress. + pub operation: Option<String>, + pub changed_files: u32, +} + +/// A range in a file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Location { + /// Absolute path. + pub path: String, + pub range: Range, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Severity { + Error, + Warning, + Info, + Hint, +} + +/// A problem in a file (see [`crate::host::publish_diagnostics`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Diagnostic { + pub range: Range, + pub severity: Severity, + pub message: String, + /// Rule or check id. + pub code: Option<String>, +} + +/// An HTTP request (see [`crate::host::fetch`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HttpRequest { + pub method: String, + pub url: String, + /// `{{secret:<name>}}` in a value is filled in by the IDE. + pub headers: Vec<(String, String)>, + pub body: Option<Vec<u8>>, + pub timeout_ms: Option<u32>, +} + +impl HttpRequest { + pub fn get(url: &str) -> Self { + Self { method: "GET".into(), url: url.into(), ..Self::default() } + } + + pub fn post(url: &str, body: impl Into<Vec<u8>>) -> Self { + Self { method: "POST".into(), url: url.into(), body: Some(body.into()), ..Self::default() } + } + + pub fn header(mut self, name: &str, value: &str) -> Self { + self.headers.push((name.into(), value.into())); + self + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HttpResponse { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec<u8>, +} + +impl HttpResponse { + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } +} + +/// What a process printed (see [`crate::host::run_process`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProcessOutput { + /// `None` when killed by a signal. + pub status: Option<i32>, + pub stdout: String, + pub stderr: String, +} diff --git a/sdk/rust/openidea-plugin/src/views.rs b/sdk/rust/openidea-plugin/src/views.rs new file mode 100644 index 000000000..3da8e506b --- /dev/null +++ b/sdk/rust/openidea-plugin/src/views.rs @@ -0,0 +1,225 @@ +//! Views and custom editors (`[[contributes.views]]`, +//! `[[contributes.customEditors]]`): implement [`Views`] and register with +//! `register_plugin!(MyPlugin, views)`. +//! +//! A view is data — a toolbar, a body (tree, list, form, table, message or +//! sandboxed HTML) and a status line. The IDE renders it and calls +//! [`Views::handle`] with what the user did; call [`crate::host::refresh_view`] +//! when the content changes. For a custom editor, `ctx.editor()` is its +//! document: read it with `document()` and change it with `apply_edits`, so +//! the text editor of the same file follows and undo is shared. + +use crate::{Error, Invocation}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Button { + pub id: String, + pub label: String, + /// An IDE icon (`refresh`, `add`, `remove`, `run`, `stop`, `settings`…). + pub icon: Option<String>, + pub enabled: bool, +} + +impl Button { + pub fn new(id: &str, label: &str) -> Self { + Self { id: id.into(), label: label.into(), icon: None, enabled: true } + } + + pub fn icon(mut self, icon: &str) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } +} + +/// A tree node; `parent` is the key of an item listed before it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TreeItem { + pub key: String, + pub parent: Option<String>, + pub label: String, + pub icon: Option<String>, + pub detail: Option<String>, + /// Shows an expander before the children are known ([`ViewEvent::Expand`]). + pub expandable: bool, + pub expanded: bool, +} + +impl TreeItem { + pub fn new(key: &str, label: &str) -> Self { + Self { + key: key.into(), + parent: None, + label: label.into(), + icon: None, + detail: None, + expandable: false, + expanded: false, + } + } + + pub fn parent(mut self, parent: &str) -> Self { + self.parent = Some(parent.into()); + self + } + + pub fn icon(mut self, icon: &str) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn detail(mut self, detail: &str) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Has (or may have) children; `expanded` shows them. + pub fn expandable(mut self, expanded: bool) -> Self { + self.expandable = true; + self.expanded = expanded; + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ListItem { + pub key: String, + pub label: String, + pub icon: Option<String>, + pub detail: Option<String>, +} + +impl ListItem { + pub fn new(key: &str, label: &str) -> Self { + Self { key: key.into(), label: label.into(), icon: None, detail: None } + } + + pub fn icon(mut self, icon: &str) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn detail(mut self, detail: &str) -> Self { + self.detail = Some(detail.into()); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FieldValue { + Text(String), + Multiline(String), + Checkbox(bool), + /// Options and the chosen one. + Choice(Vec<String>, String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Field { + pub key: String, + pub label: String, + pub value: FieldValue, +} + +impl Field { + pub fn new(key: &str, label: &str, value: FieldValue) -> Self { + Self { key: key.into(), label: label.into(), value } + } +} + +/// Rows `first_row..first_row + rows.len()` of `total_rows`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Table { + pub columns: Vec<String>, + pub total_rows: u32, + pub first_row: u32, + pub rows: Vec<Vec<String>>, + pub editable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Body { + Message(String), + Tree(Vec<TreeItem>), + List(Vec<ListItem>), + Form(Vec<Field>), + Table(Table), + /// Shown in a sandboxed frame; its script talks to the plugin with + /// `openidea.postMessage(text)` and `openidea.onMessage(fn)`. + Html(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct View { + pub toolbar: Vec<Button>, + pub body: Body, + pub status: Option<String>, +} + +impl View { + pub fn new(body: Body) -> Self { + Self { toolbar: Vec::new(), body, status: None } + } + + pub fn message(text: &str) -> Self { + Self::new(Body::Message(text.into())) + } + + pub fn button(mut self, b: Button) -> Self { + self.toolbar.push(b); + self + } + + pub fn status(mut self, status: &str) -> Self { + self.status = Some(status.into()); + self + } +} + +/// The table rows the IDE shows (ask for no more than these). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Viewport { + pub first_row: u32, + pub rows: u32, +} + +impl Viewport { + /// `first_row..end` clamped to `total`. + pub fn range(viewport: Option<Viewport>, total: u32, default_rows: u32) -> std::ops::Range<u32> { + let v = viewport.unwrap_or(Viewport { first_row: 0, rows: default_rows }); + let start = v.first_row.min(total); + start..start.saturating_add(v.rows).min(total) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ViewEvent { + Button(String), + Activate(String), + Select(String), + Expand(String), + Collapse(String), + /// A form field: key and new value (`true`/`false` for checkboxes). + Field(String, String), + Cell { + row: u32, + column: u32, + value: String, + }, + InsertRow(u32), + DeleteRows(Vec<u32>), + Message(String), +} + +/// Content of the plugin's declared views and custom editors. +pub trait Views { + fn render(&mut self, view: &str, viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error>; + + fn handle(&mut self, _view: &str, _event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + Ok(()) + } +} diff --git a/sdk/rust/openidea-plugin/wit/events.wit b/sdk/rust/openidea-plugin/wit/events.wit new file mode 100644 index 000000000..8470c298c --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/events.wit @@ -0,0 +1,68 @@ +package openidea:plugin@1.0.0; + +/// Workbench events, delivered in batches to `event-handler.handle`. +interface events { + use types.{error}; + + enum event-kind { + file-opened, + file-saved, + /// The focused editor changed. + editor-focused, + /// An IDE action ran. Requires `events.input`. + action-performed, + /// A repository's state changed (commit, checkout, fetch, …). + vcs-changed, + /// The plugin's `on-schedule` period elapsed. + scheduled, + } + + /// How the user started an action. + enum action-source { + keyboard, + menu, + toolbar, + context-menu, + search, + other, + } + + record action-performed { + /// Action id (`editor.duplicate-line`). + action: string, + title: string, + source: action-source, + /// Its keyboard shortcut, when it has one (`Ctrl+D`). + shortcut: option<string>, + } + + variant event { + file-opened(string), + file-saved(string), + editor-focused(option<string>), + action-performed(action-performed), + vcs-changed(string), + scheduled, + } + + /// Delivers `kind` events from now on, for the rest of the session (also + /// across idle reclaim: an event activates the plugin again). Declare + /// `activation = ["on-event:<kind>"]` to get them before first use. + /// Requires `events` (`events.input` for `action-performed`). + subscribe: func(kind: event-kind) -> result<_, error>; + unsubscribe: func(kind: event-kind); +} + +/// Long-running work with a progress indicator. While a task is open, the +/// call that began it may run for the background budget (60 s) instead of 5 s. +interface tasks { + resource task { + /// `done` of `total` units (`none`: indeterminate), with a short status. + report: func(done: u32, total: option<u32>, message: option<string>); + /// The user cancelled it: stop soon and return. + is-cancelled: func() -> bool; + } + + /// Starts a task shown in the status bar; dropping it ends it. + begin: func(title: string, cancellable: bool) -> task; +} diff --git a/sdk/rust/openidea-plugin/wit/exports.wit b/sdk/rust/openidea-plugin/wit/exports.wit new file mode 100644 index 000000000..f907ff472 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/exports.wit @@ -0,0 +1,70 @@ +package openidea:plugin@1.0.0; + +interface lifecycle { + use types.{context}; + + /// Called once after the instance is created. Register runtime + /// contributions and subscriptions here; restore state from `storage`. + activate: func(ctx: context) -> result<_, string>; + /// Called before the instance is dropped (disable, uninstall, idle reclaim). + deactivate: func(); +} + +interface commands { + use invocation.{invocation}; + + /// Runs one of the plugin's commands. Returns an optional message. + run: func(id: string, ctx: borrow<invocation>, arg: option<string>) -> result<option<string>, string>; +} + +interface event-handler { + use events.{event}; + use invocation.{invocation}; + + /// A batch of subscribed events, oldest first, from one window (`ctx`). + handle: func(events: list<event>, ctx: borrow<invocation>); +} + +/// Optional export (world `plugin-syntax`): syntax of the plugin's own +/// languages, for languages the IDE has no grammar for. The IDE calls it +/// with the whole document; results are flat. Offsets are UTF-8 byte offsets +/// into `text` (the IDE converts them); lines are 0-based. +interface syntax-provider { + /// One highlighted range. `kind` is a highlight category: keyword, string, + /// string-special, escape, regexp, atom, number, constant, comment, + /// doc-comment, function, function-call, builtin, type, namespace, + /// variable, self-variable, property, attribute, macro, label, tag, + /// operator, punctuation. Unknown kinds are ignored; ranges must be + /// sorted and not overlap. + record token { + start: u32, + end: u32, + kind: string, + } + + /// A symbol of the structure view. `parent` is the index of the enclosing + /// symbol in the same list (which must come first). + record symbol { + name: string, + detail: option<string>, + /// class, interface, struct, enum, function, method, constructor, + /// field, property, variable, constant, module, namespace, event, … + kind: string, + start: u32, + end: u32, + name-start: u32, + name-end: u32, + parent: option<u32>, + } + + /// A foldable line range. `kind`: region, comment or imports. + record fold { + start-line: u32, + end-line: u32, + kind: string, + } + + highlights: func(language: string, text: string) -> list<token>; + outline: func(language: string, text: string) -> list<symbol>; + folding: func(language: string, text: string) -> list<fold>; +} diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit new file mode 100644 index 000000000..750bfd342 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -0,0 +1,51 @@ +package openidea:plugin@1.0.0; + +/// Writes to the IDE log, tagged with the plugin id. +interface log { + use types.{log-level}; + + log: func(level: log-level, message: string); +} + +/// Contributions made at run time (declare static ones in plugin.toml). +interface contributions { + use types.{error}; + + /// Registers (or updates) a command `plugin.<plugin-id>.<id>`. + /// `id` must match `[a-z0-9][a-z0-9._-]*`. + register-command: func(id: string, title: string, category: option<string>) -> result<_, error>; + /// A language from a `languages/<id>.toml`-style definition. + register-language: func(definition: string) -> result<_, error>; + /// A theme from JSON: {"id","name","kind":"dark"|"light","tokens":{"--var":"value"}}. + register-theme: func(definition: string) -> result<_, error>; +} + +interface ui { + use types.{notification-level}; + + record status-item { + text: string, + tooltip: option<string>, + /// One of the plugin's commands, run when the item is clicked. + command: option<string>, + } + + /// Shows a notification balloon in the window the current command or + /// event came from (every window when there is none). + show-notification: func(level: notification-level, title: string, body: string); + /// Shows or updates the plugin's status bar item `id` in every window. + set-status-item: func(id: string, item: status-item); + remove-status-item: func(id: string); +} + +/// Persistent key-value storage of the plugin (survives restarts and idle +/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB. +interface storage { + use types.{error}; + + get: func(key: string) -> result<option<string>, error>; + set: func(key: string, value: string) -> result<_, error>; + delete: func(key: string) -> result<_, error>; + /// Keys starting with `prefix`, sorted. + keys: func(prefix: string) -> result<list<string>, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/io.wit b/sdk/rust/openidea-plugin/wit/io.wit new file mode 100644 index 000000000..a1aeb8490 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/io.wit @@ -0,0 +1,85 @@ +package openidea:plugin@1.0.0; + +/// Problems in files, shown in the editor and the Problems view. +interface diagnostics { + use types.{error, range}; + + enum severity { + error, + warning, + info, + hint, + } + + record diagnostic { + range: range, + severity: severity, + message: string, + /// Rule or check id (`LineLength`). + code: option<string>, + } + + /// Replaces the plugin's diagnostics for the file at `path` (absolute, in + /// an open project); an empty list clears them. Requires `project.read`. + publish: func(path: string, diagnostics: list<diagnostic>) -> result<_, error>; +} + +/// HTTP(S) to the hosts granted with `net:<host>`. +interface net { + use types.{error}; + + record header { + name: string, + value: string, + } + + record request { + method: string, + /// `https://…`; plain `http://` only to `localhost` / `127.0.0.1`. + url: string, + /// `{{secret:<name>}}` in a value is replaced by the plugin's secret + /// `name` on the way out (requires `secrets`); the plugin never sees it. + headers: list<header>, + body: option<list<u8>>, + /// Default 30 s; the call's own budget applies too. + timeout-ms: option<u32>, + } + + record response { + status: u16, + headers: list<header>, + /// At most 8 MiB. + body: list<u8>, + } + + /// Fails with `denied` for a host the plugin may not reach, `cancelled` + /// when a newer request replaced the call or the user cancelled. + fetch: func(req: request) -> result<response, error>; +} + +/// Secrets the user stored for the plugin (Settings › Plugins, kept in the +/// OS keychain). Plugins only use them through `{{secret:<name>}}` in +/// `net` requests. Requires `secrets`. +interface secrets { + is-set: func(name: string) -> bool; +} + +/// One-off runs of programs granted with `process:<program>` (the file the +/// user approved, checked before every run). +interface process { + use types.{error}; + use workspace.{project}; + + record output { + /// `none` when killed by a signal. + status: option<s32>, + /// At most 4 MiB each (the rest is dropped). + stdout: string, + stderr: string, + } + + /// Runs `program` with `args` in `cwd`'s root (else the plugin's data + /// directory) and waits; cancelled with the task or call. Call it inside + /// a task for anything slow. + run: func(program: string, args: list<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit new file mode 100644 index 000000000..84b20a900 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -0,0 +1,68 @@ +/// OpenIDEA plugin API, version 1.0 (docs/plugin_plan.md 4.3). Frozen: 1.x +/// only adds (new interfaces, new optional exports); a 2.0 keeps 1.0 running +/// through an adapter in the host. +/// +/// A plugin is a WebAssembly component targeting the `plugin` world (or +/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment +/// or processes beyond WASI's clocks and randomness; everything else goes +/// through the imports below, gated by the capabilities granted in +/// `plugin.toml` (a missing one returns `error::denied`). +package openidea:plugin@1.0.0; + +world plugin { + import log; + import contributions; + import ui; + import storage; + import workspace; + import editor; + import invocation; + import vcs; + import events; + import tasks; + import diagnostics; + import net; + import secrets; + import process; + import views; + import settings; + + export lifecycle; + export commands; + export event-handler; +} + +/// A plugin that also provides syntax for its languages. +world plugin-syntax { + include plugin; + export syntax-provider; +} + +/// A plugin with editor providers (`[[contributes.providers]]`). +world plugin-providers { + include plugin; + export providers; +} + +/// A plugin with views or custom editors (`[[contributes.views]]`, +/// `[[contributes.custom-editors]]`). +world plugin-views { + include plugin; + export view-provider; +} + +/// Only the `syntax-provider` export: guests generate it next to `plugin` +/// (the component tools merge both into one component). +world syntax { + export syntax-provider; +} + +/// Only the `providers` export, generated next to `plugin` like `syntax`. +world editor-providers { + export providers; +} + +/// Only the `view-provider` export, generated next to `plugin` like `syntax`. +world views-provider { + export view-provider; +} diff --git a/sdk/rust/openidea-plugin/wit/providers.wit b/sdk/rust/openidea-plugin/wit/providers.wit new file mode 100644 index 000000000..af3f86eb4 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/providers.wit @@ -0,0 +1,83 @@ +package openidea:plugin@1.0.0; + +/// Editor features a plugin provides for the languages it declares under +/// `[[contributes.providers]]` (the host calls only declared kinds). They run +/// on the editing path: a result later than ~50 ms is dropped, a call still +/// running at 500 ms is interrupted. +interface providers { + use types.{position, range, text-edit, location}; + + /// The document a request is about. + record document { + path: string, + language: string, + version: u64, + text: string, + } + + enum completion-kind { + text, + method, + function, + %constructor, + field, + variable, + class, + %interface, + module, + property, + keyword, + snippet, + value, + constant, + file, + reference, + } + + record completion-item { + label: string, + detail: option<string>, + kind: option<completion-kind>, + insert-text: string, + /// Replaced on accept; `none`: the word at the caret. + replace: option<range>, + sort-text: option<string>, + documentation: option<string>, + } + + record inline-suggestion { + insert-text: string, + /// Replaced range; `none` inserts at the caret. + replace: option<range>, + } + + record file-edit { + path: string, + edits: list<text-edit>, + } + + record code-action { + title: string, + /// `quickfix`, `refactor`, … + kind: option<string>, + preferred: bool, + edits: list<file-edit>, + } + + record line-marker { + line: u32, + /// Gutter icon: `navigate` (default), `implemented`, `implementing`, `run`. + icon: option<string>, + tooltip: string, + /// Opened on click. + target: option<location>, + } + + completion: func(doc: document, at: position, trigger: option<string>) -> list<completion-item>; + /// Ghost text at the caret (typing replaces an older request: it is cancelled). + inline-completion: func(doc: document, at: position) -> option<inline-suggestion>; + /// Actions for `selection` (quick fixes for the plugin's own diagnostics). + code-actions: func(doc: document, selection: range) -> list<code-action>; + definition: func(doc: document, at: position) -> list<location>; + line-markers: func(doc: document) -> list<line-marker>; +} diff --git a/sdk/rust/openidea-plugin/wit/types.wit b/sdk/rust/openidea-plugin/wit/types.wit new file mode 100644 index 000000000..5c4f237e1 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/types.wit @@ -0,0 +1,64 @@ +package openidea:plugin@1.0.0; + +/// Shared types. Positions are 0-based lines and UTF-16 code unit columns, +/// as in the editor and LSP. +interface types { + /// Why a host call failed. A capability that is not granted is reported + /// as `denied`, so the plugin can degrade (0.1 trapped instead). + variant error { + /// Not granted; the payload names the capability (`editor.write`). + denied(string), + /// The document changed since the version the edit was made for. + stale, + not-found(string), + invalid(string), + /// Cancelled by the user, or the plugin is being stopped. + cancelled, + failed(string), + } + + enum log-level { + trace, + debug, + info, + warn, + error, + } + + enum notification-level { + info, + warning, + error, + } + + record position { + line: u32, + character: u32, + } + + record range { + start: position, + end: position, + } + + record text-edit { + range: range, + text: string, + } + + /// A range in a file (absolute path). + record location { + path: string, + range: range, + } + + /// Passed to `lifecycle.activate`. + record context { + plugin-id: string, + plugin-version: string, + /// Version of this API the host implements, e.g. "1.0". + api-version: string, + /// Capabilities granted to the plugin (`editor.write`, `process:git`, …). + capabilities: list<string>, + } +} diff --git a/sdk/rust/openidea-plugin/wit/views.wit b/sdk/rust/openidea-plugin/wit/views.wit new file mode 100644 index 000000000..1dfbd1357 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/views.wit @@ -0,0 +1,146 @@ +package openidea:plugin@1.0.0; + +/// Declarative views (docs/plugin_plan.md 4.2, L2): tool windows declared +/// under `[[contributes.views]]` and custom editors under +/// `[[contributes.customEditors]]`. The plugin describes what to show; the +/// IDE renders it with its own components (theme, keyboard, accessibility) +/// and sends the user's actions back as `view-event`s. Nothing of the plugin +/// runs in the WebView, except a `html` body, which is shown in a sandboxed +/// frame that can only exchange messages with the plugin. +interface view-types { + record button { + id: string, + label: string, + /// An IDE icon name (`refresh`, `add`, `remove`, `run`, …). + icon: option<string>, + enabled: bool, + } + + /// A tree node; `parent` is the key of an earlier item (none: a root). + record tree-item { + key: string, + parent: option<string>, + label: string, + icon: option<string>, + /// Grey text after the label. + detail: option<string>, + /// Shows an expander even before its children are known + /// (`expand` asks for them). + expandable: bool, + expanded: bool, + } + + record list-item { + key: string, + label: string, + icon: option<string>, + detail: option<string>, + } + + variant field-value { + text(string), + multiline(string), + checkbox(bool), + /// The options and the chosen one. + choice(tuple<list<string>, string>), + } + + record field { + key: string, + label: string, + value: field-value, + } + + /// Rows `first-row`..`first-row + len(rows)` of `total-rows`: the IDE + /// asks for the rows it shows (`viewport`), so large tables stay cheap. + record table { + columns: list<string>, + total-rows: u32, + first-row: u32, + rows: list<list<string>>, + editable: bool, + } + + variant body { + /// A centred message (empty state, errors). + message(string), + tree(list<tree-item>), + %list(list<list-item>), + form(list<field>), + table(table), + /// HTML shown in a sandboxed frame: no scripts from elsewhere, no + /// network, no IDE API. Its script talks to the plugin with + /// `openidea.postMessage(text)` / `openidea.onMessage(fn)`. + html(string), + } + + record view { + toolbar: list<button>, + body: body, + /// A line below the body (counts, last refresh…). + status: option<string>, + } + + /// The table rows the IDE shows. + record viewport { + first-row: u32, + rows: u32, + } + + record cell-edit { + row: u32, + column: u32, + value: string, + } + + variant view-event { + /// A toolbar button. + button(string), + /// Double click / Enter on a tree or list item. + activate(string), + select(string), + expand(string), + collapse(string), + /// A form field changed: key and new value (`true`/`false` for checkboxes). + field(tuple<string, string>), + cell(cell-edit), + /// Insert an empty row before this one (the row count: append). + insert-row(u32), + delete-rows(list<u32>), + /// From the `html` body's script. + message(string), + } +} + +/// What a plugin's code can ask of its views. +interface views { + use types.{error}; + + /// The view (or every custom editor of that id) shows outdated content: + /// the IDE renders it again. + refresh: func(view: string); + /// Sends a message to the script of a view's `html` body. + post-message: func(view: string, message: string) -> result<_, error>; + /// Opens a terminal tab in the window the call came from, running + /// `program` (pinned by a `process:<program>` grant) with `args` in the + /// project root: long-running output (logs) and interactive sessions. + open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>; +} + +/// The plugin's own settings (`[contributes.settings]`), as the user set them +/// (project over global) or the schema's default. +interface settings { + /// The value of `key` as JSON (`true`, `3`, `"text"`), none if unknown. + get: func(key: string) -> option<string>; +} + +/// Optional export (world `plugin-views`): content of declared views and +/// custom editors. For a custom editor, `ctx.editor()` is its document. +interface view-provider { + use view-types.{view, viewport, view-event}; + use invocation.{invocation}; + use types.{error}; + + render: func(view: string, viewport: option<viewport>, ctx: borrow<invocation>) -> result<view, error>; + handle: func(view: string, event: view-event, ctx: borrow<invocation>) -> result<_, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit new file mode 100644 index 000000000..febca7c6f --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -0,0 +1,90 @@ +package openidea:plugin@1.0.0; + +interface workspace { + use types.{error}; + + /// A project open in the IDE. + resource project { + /// Absolute path of the project root. Requires `project.read`. + root: func() -> result<string, error>; + /// Reads a UTF-8 text file inside the project (absolute, or relative + /// to the root); paths leaving the root are refused. Unsaved editor + /// contents win over the disk. Requires `project.read`. + read-file: func(path: string) -> result<string, error>; + /// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`), + /// at most `limit`, as absolute paths. Ignored and excluded files are + /// skipped. Requires `project.read`. + find-files: func(glob: string, limit: u32) -> result<list<string>, error>; + } + + /// Projects open in any window. Requires `project.read`. + projects: func() -> result<list<project>, error>; +} + +interface editor { + use types.{error, range, text-edit}; + + record document { + /// Grows with every change; edits name the version they were made for. + version: u64, + text: string, + } + + /// A file open in an editor. + resource text-editor { + /// Absolute path of the file. + path: func() -> string; + /// Language id (`rust`, `solidity`, …). + language: func() -> string; + /// Current version and text. Requires `editor.read`. + document: func() -> result<document, error>; + /// Selections when the command was invoked (a cursor is an empty + /// range). Requires `editor.read`. + selections: func() -> result<list<range>, error>; + /// Applies `edits` as one undoable change, only if the document is + /// still at `version` (else `stale`, and nothing changes). Ranges are + /// in that version and must not overlap. Returns the new version. + /// Requires `editor.write`. + apply-edits: func(version: u64, edits: list<text-edit>) -> result<u64, error>; + } +} + +/// Where a command or event comes from. +interface invocation { + use workspace.{project}; + use editor.{text-editor}; + + resource invocation { + /// The window's project. + project: func() -> option<project>; + /// The window's focused editor. + editor: func() -> option<text-editor>; + } +} + +/// Git, for the repository containing a project. +interface vcs { + use types.{error}; + use workspace.{project}; + + record repository-status { + /// Absolute path of the working tree root. + root: string, + /// `none` when HEAD is detached. + branch: option<string>, + commit: option<string>, + upstream: option<string>, + ahead: u32, + behind: u32, + /// Merge / rebase / cherry-pick in progress. + operation: option<string>, + /// Files with staged or unstaged changes. + changed-files: u32, + } + + /// `none` when the project is not in a repository. Requires `vcs.read`. + status: func(project: borrow<project>) -> result<option<repository-status>, error>; + /// Fetches all remotes (pruning deleted branches). Requires `vcs.write`. + /// Long-running: call it inside a task. + fetch: func(project: borrow<project>) -> result<_, error>; +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 633a43b9e..c99577e25 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,11 +67,14 @@ ide-notebook.workspace = true ide-jsonrpc.workspace = true ide-tools = { path = "../crates/ide-tools" } ide-plugin = { path = "../crates/ide-plugin" } +ide-extension = { path = "../crates/ide-extension" } ide-docker = { path = "../crates/ide-docker" } ide-github = { path = "../crates/ide-github" } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" +# Plugin secrets in the login keychain. +security-framework = "3.7" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSView", "NSWindow"] } objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index e383c9fa4..dbef3318c 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -126,14 +126,8 @@ pub fn close_project(window: Window, state: State<'_, AppState>) { #[specta::specta] pub fn languages(state: State<'_, AppState>) -> Vec<LanguageInfo> { state.note_ipc(); - let mut out: Vec<LanguageInfo> = state.languages.all().iter().map(LanguageInfo::from).collect(); - // Plugin-contributed languages; built-in definitions win on id clashes. - for lang in state.plugins.languages() { - if !out.iter().any(|l| l.id == lang.id) { - out.push(LanguageInfo::from(&lang)); - } - } - out + // Includes plugin languages (built-in definitions win on id clashes). + state.languages.all().iter().map(LanguageInfo::from).collect() } /// Effective settings for the window's project, or global settings without one. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 07101df69..beaf69086 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -419,11 +419,33 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> { plugin::plugin_subscribe, plugin::plugin_list, plugin::plugin_set_enabled, + plugin::plugin_preview_install, plugin::plugin_install_dir, + plugin::plugin_grant, + plugin::plugin_set_capability, + plugin::plugin_link, + plugin::plugin_unlink, + plugin::plugin_assets, + plugin::plugin_activation_event, plugin::plugin_catalog, plugin::plugin_uninstall, plugin::plugin_reload, plugin::plugin_run_command, + plugin::plugin_post_events, + plugin::plugin_event_kinds, + plugin::plugin_status_items, + plugin::plugin_cancel_task, + plugin::plugin_view_render, + plugin::plugin_view_event, + plugin::plugin_line_markers, + plugin::plugin_market, + plugin::plugin_market_url, + plugin::plugin_set_market_url, + plugin::plugin_market_download, + plugin::plugin_unpack_package, + plugin::plugin_roll_back, + plugin::plugin_secrets, + plugin::plugin_set_secret, plugin::plugin_set_active_file, plugin::plugin_set_theme, plugin::plugin_set_theme_frame, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 6c91ad079..15685257d 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -1,13 +1,18 @@ -//! WASM plugins (`ide-plugin`). Each window subscribes once with -//! `plugin_subscribe(channel)` and receives [`PluginEvent`]s addressed to it -//! (insert-text requests, notifications) plus broadcasts (list/theme changes). +//! Plugins: Tauri adapter over `ide_host::plugins::PluginHost`. Each window +//! subscribes once with `plugin_subscribe(channel)` and receives +//! [`PluginEvent`]s addressed to it (notifications, consent requests, tasks) +//! plus broadcasts (list/theme/status item changes). use crate::state::AppState; use ide_core::{IdeError, IdeResult}; -use ide_plugin::{ - ActiveTheme, CatalogPlugin, Invocation, Limits, PluginEvent, PluginHost, PluginInfo, PluginsSnapshot, +use ide_extension::ActivationEvent; +use ide_host::plugins::{ + ActiveTheme, CatalogPlugin, Consent, EventKind, HostWorkspace, InstallPreview, PluginEditor, PluginEvent, + PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent, }; +use ide_plugin::{Invocation, Limits}; use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock}; @@ -17,6 +22,16 @@ use tauri::{AppHandle, Manager, State, Window}; /// How long `plugin_list` waits for the startup load before answering with what it has. const INITIAL_LOAD_WAIT: Duration = Duration::from_secs(5); +/// `on-startup-finished` fires this long after the installed plugins are loaded. +const STARTUP_FINISHED_DELAY: Duration = Duration::from_millis(1500); +/// How often the marketplace index is read again (revocations). +const MARKET_REFRESH: Duration = Duration::from_secs(24 * 60 * 60); +/// Period of [`PluginHost::flush_events`]; [`PluginHost::tick`] runs every +/// [`TICKS_PER_SUPERVISION`] of them. +const EVENT_FLUSH: Duration = Duration::from_millis(250); +const TICKS_PER_SUPERVISION: u32 = 20; +/// Plugin changes (install, enable, grants) reach remote servers after this quiet time. +const REMOTE_SYNC_DELAY: Duration = Duration::from_millis(800); /// Window label → its event channel. static SUBSCRIBERS: LazyLock<Mutex<HashMap<String, Channel<PluginEvent>>>> = LazyLock::new(Default::default); @@ -35,19 +50,100 @@ fn deliver(client: Option<&str>, event: PluginEvent) { } } -/// Creates the host over `<app-data>/plugins` and loads installed plugins in the background. -pub fn create_host( - _app: &AppHandle, - data_dir: &Path, - project_host: &Arc<ide_host::Host>, -) -> IdeResult<Arc<PluginHost>> { - let host = Arc::new(PluginHost::new(data_dir.join("plugins"), Arc::new(deliver), Limits::default())?); +/// Delivers a plugin event to one window (remote windows: from their server). +pub(crate) fn deliver_to(window: &str, event: PluginEvent) { + deliver(Some(window), event); +} + +/// Re-syncs remote servers' plugins once plugin changes settle. +fn schedule_remote_sync(app: &AppHandle) { + static SCHEDULED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if SCHEDULED.swap(true, std::sync::atomic::Ordering::AcqRel) { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(REMOTE_SYNC_DELAY).await; + SCHEDULED.store(false, std::sync::atomic::Ordering::Release); + let Some(state) = app.try_state::<crate::state::AppState>() else { return }; + for (window, connection) in state.remote_dev.all() { + crate::remote_dev::sync_plugin_packages(&state.plugins, &connection, &window).await; + } + }); +} + +/// Creates the host over `<app-data>/plugins` and loads installed plugins in +/// the background (no Wasm is instantiated until an activation event). +pub fn create_host(app: &AppHandle, data_dir: &Path, project_host: &Arc<ide_host::Host>) -> IdeResult<Arc<PluginHost>> { + let projects = Arc::downgrade(project_host); + let app = app.clone(); + let sink = move |client: Option<&str>, event: PluginEvent| { + if matches!(event, PluginEvent::CommandsChanged) + && app.try_state::<crate::state::AppState>().is_some_and(|s| !s.remote_dev.all().is_empty()) + { + schedule_remote_sync(&app); + } + if matches!(event, PluginEvent::LanguagesChanged) { + if let Some(projects) = projects.upgrade() { + projects.plugin_languages_changed(); + } + // Remote windows: their servers run the languages. + if let Some(state) = app.try_state::<crate::state::AppState>() { + let plugins = state.plugins.clone(); + for (window, connection) in state.remote_dev.all() { + let plugins = plugins.clone(); + tauri::async_runtime::spawn(async move { + crate::remote_dev::sync_plugin_languages(&plugins, &connection, &window).await + }); + } + } + } + deliver(client, event); + }; + let host = Arc::new(PluginHost::new(data_dir.join("plugins"), Arc::new(sink), Limits::default())?); + // Plugin languages join detection, LSP and DAP of every project (forks share the store), + // and the syntax of the ones without a built-in grammar comes from their plugin. + project_host.languages.attach_plugins(host.language_store().clone()); + ide_language::set_external_syntax(Some(host.syntax_provider())); + ide_run::set_external_detection(Some(host.run_detection())); + host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(project_host))))); + #[cfg(target_os = "macos")] + host.set_secret_store(Arc::new(KeychainSecrets)); + ide_host::plugins::set_editor_providers(Some(&host)); let weak = Arc::downgrade(project_host); host.set_project_policy(Arc::new(move |root| { weak.upgrade().is_some_and(|host| host.trust.trusted(root).unwrap_or(false)) })); - let loader = host.clone(); - std::thread::Builder::new().name("plugin-load".into()).spawn(move || loader.load_all())?; + let loader = Arc::downgrade(&host); + std::thread::Builder::new().name("plugin-load".into()).spawn(move || { + let Some(host) = loader.upgrade() else { return }; + host.load_all(); + drop(host); + std::thread::sleep(STARTUP_FINISHED_DELAY); + let Some(host) = loader.upgrade() else { return }; + host.startup_finished(); + host.warm_component_cache(); + drop(host); + // Event delivery; supervision (crash reports, idle reclaim, `on-schedule`); + // the daily marketplace check (revocations). + let mut market_due = std::time::Instant::now() + Duration::from_secs(60); + for n in 1u32.. { + std::thread::sleep(EVENT_FLUSH); + let Some(host) = loader.upgrade() else { return }; + host.flush_events(); + if n % TICKS_PER_SUPERVISION == 0 { + host.tick(); + } + if std::time::Instant::now() >= market_due { + market_due = std::time::Instant::now() + MARKET_REFRESH; + if host.market_url().is_some() + && let Err(e) = host.refresh_market() + { + tracing::info!("plugin marketplace: {e}"); + } + } + } + })?; Ok(host) } @@ -66,8 +162,9 @@ pub fn plugin_subscribe(window: Window, on_event: Channel<PluginEvent>) { SUBSCRIBERS.lock().insert(window.label().to_owned(), on_event); } -/// Installed plugins plus the dev plugins of this window's project -/// (`<project>/.openidea/plugins/*`, rescanned on every call). +/// Installed and linked plugins plus the project plugins of this window's +/// project (`<project>/.openidea/plugins/*`, rescanned on every call), with +/// the UI contributions to project. #[tauri::command] #[specta::specta] pub async fn plugin_list(window: Window, state: State<'_, AppState>) -> IdeResult<PluginsSnapshot> { @@ -76,6 +173,7 @@ pub async fn plugin_list(window: Window, state: State<'_, AppState>) -> IdeResul blocking(move || { // A window opened at startup would otherwise see no plugins and drop the selected theme. host.wait_initial_load(INITIAL_LOAD_WAIT); + host.sync_links(); if let Some(root) = &root { host.sync_project(root); } @@ -91,25 +189,111 @@ pub async fn plugin_set_enabled(state: State<'_, AppState>, id: String, enabled: blocking(move || host.set_enabled(&id, enabled)).await } -/// Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. +/// Validates the package in `path` for the install page (nothing is copied). #[tauri::command] #[specta::specta] -pub async fn plugin_install_dir(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PluginInfo> { +pub async fn plugin_preview_install(state: State<'_, AppState>, path: PathBuf) -> IdeResult<InstallPreview> { let host = state.plugins.clone(); - blocking(move || host.install_from_path(&path)).await + blocking(move || host.preview_install(&path)).await +} + +/// Installs the plugin package in `path` (a folder with `plugin.toml`) with +/// the capabilities the user accepted on the install page. +#[tauri::command] +#[specta::specta] +pub async fn plugin_install_dir(state: State<'_, AppState>, path: PathBuf, consent: Consent) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.install_from_dir(&path, &consent)).await +} + +/// Confirms a plugin waiting for consent (project plugins, new capabilities). +#[tauri::command] +#[specta::specta] +pub async fn plugin_grant(state: State<'_, AppState>, id: String, consent: Consent) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.grant(&id, &consent)).await +} + +/// Grants or revokes one declared capability. +#[tauri::command] +#[specta::specta] +pub async fn plugin_set_capability( + state: State<'_, AppState>, + id: String, + capability: String, + granted: bool, +) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.set_capability(&id, &capability, granted)).await +} + +/// Links a development plugin folder (`openidea-plugin link` does the same from a terminal). +#[tauri::command] +#[specta::specta] +pub async fn plugin_link(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.link(&path)).await +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_unlink(state: State<'_, AppState>, id: String) -> IdeResult<()> { + let host = state.plugins.clone(); + blocking(move || host.unlink(&id)).await +} + +/// Validated assets of a plugin whose id starts with `prefix` (icon theme mapping and SVGs). +#[tauri::command] +#[specta::specta] +pub async fn plugin_assets( + state: State<'_, AppState>, + plugin_id: String, + prefix: String, +) -> IdeResult<Vec<(String, String)>> { + let host = state.plugins.clone(); + blocking(move || host.assets(&plugin_id, &prefix)).await +} + +/// An activation event from the UI: `kind` is `language`, `file` (project-relative path) or `view`. +#[tauri::command] +#[specta::specta] +pub async fn plugin_activation_event( + window: Window, + state: State<'_, AppState>, + kind: String, + value: String, +) -> IdeResult<()> { + let event = match kind.as_str() { + "language" => ActivationEvent::Language(value), + "file" => ActivationEvent::File(value), + "view" => ActivationEvent::View(value), + _ => return Err(IdeError::invalid(format!("unknown activation event kind {kind:?}"))), + }; + let project = state.project(window.label()).ok(); + if project.as_ref().is_some_and(|p| !p.is_trusted()) { + return Ok(()); // Restricted Mode: no plugin code runs for this project. + } + let host = state.plugins.clone(); + blocking(move || { + host.activation_event(&event, project.as_ref().map(|p| p.root.as_path())); + Ok(()) + }) + .await } /// Plugin packages available locally for installation: `<resources>/plugins` /// when bundled, plus the repository's sample `plugins/` in debug builds. -/// There is no network marketplace. #[tauri::command] #[specta::specta] -pub async fn plugin_catalog(app: AppHandle) -> IdeResult<Vec<CatalogPlugin>> { +pub async fn plugin_catalog(app: AppHandle, state: State<'_, AppState>) -> IdeResult<Vec<CatalogPlugin>> { let mut dirs: Vec<PathBuf> = app.path().resource_dir().map(|d| vec![d.join("plugins")]).unwrap_or_default(); if cfg!(debug_assertions) { - dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugins")); + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + dirs.push(repo.join("plugins")); + dirs.push(repo.join("plugins/official")); } - blocking(move || Ok(ide_plugin::scan_catalog(&dirs))).await + let host = state.plugins.clone(); + blocking(move || Ok(host.catalog(&dirs))).await } #[tauri::command] @@ -127,7 +311,7 @@ pub async fn plugin_reload(state: State<'_, AppState>, id: String) -> IdeResult< blocking(move || host.reload(&id)).await } -/// Runs a plugin command in the context of this window's project. +/// Runs a plugin command in the context of this window's project and focused editor. #[tauri::command] #[specta::specta] pub async fn plugin_run_command( @@ -136,21 +320,206 @@ pub async fn plugin_run_command( plugin_id: String, command_id: String, arg: Option<String>, + editor: Option<PluginEditor>, ) -> IdeResult<Option<String>> { let host = state.plugins.clone(); let project = state.project(window.label()).ok(); let invocation = Invocation { client: Some(window.label().to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), - buffer_text: project.map(|p| { - let documents = p.documents.clone(); - Arc::new(move |path: &Path| documents.text_of(path)) as ide_plugin::BufferText - }), + editor: editor.map(PluginEditor::context), }; blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await } -/// The UI reports the focused editor's file (`active-file` for plugins). +/// Workbench events from this window (only the kinds of `plugin_event_kinds`). +#[tauri::command] +#[specta::specta] +pub fn plugin_post_events(window: Window, state: State<'_, AppState>, events: Vec<WorkbenchEvent>) { + state.plugins.post_events(Some(window.label()), events); +} + +/// Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). +#[tauri::command] +#[specta::specta] +pub async fn plugin_event_kinds(app: tauri::AppHandle) -> Vec<EventKind> { + let host = app.state::<AppState>().plugins.clone(); + // Asked once at startup: before the first load, the declared `on-event:` kinds + // would be missing, and the `commandsChanged` that load sends may precede the window's subscription. + let kinds = tauri::async_runtime::spawn_blocking(move || { + host.wait_initial_load(INITIAL_LOAD_WAIT); + host.event_kinds().into_iter().collect() + }); + kinds.await.unwrap_or_default() +} + +/// Every plugin status bar item (later changes arrive as `statusItemChanged`). +#[tauri::command] +#[specta::specta] +pub fn plugin_status_items(state: State<'_, AppState>) -> Vec<PluginStatusItem> { + state + .plugins + .status_items() + .into_iter() + .map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item }) + .collect() +} + +/// Cancels a plugin task (`taskStarted` with `cancellable`). +#[tauri::command] +#[specta::specta] +pub fn plugin_cancel_task(state: State<'_, AppState>, task_id: String) -> bool { + state.plugins.cancel_task(&task_id) +} + +/// Gutter markers of plugin `line-markers` providers for an open document. +#[tauri::command] +#[specta::specta] +pub async fn plugin_line_markers( + window: Window, + state: State<'_, AppState>, + doc: ide_core::DocId, +) -> IdeResult<Vec<ide_host::plugins::LineMarkerInfo>> { + ide_host::ops::lsp::plugin_line_markers(&state.host, window.label(), doc).await +} + +/// Content of a plugin view (`doc`: the document of a custom editor). +#[tauri::command] +#[specta::specta] +pub async fn plugin_view_render( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + view: String, + viewport: Option<ide_plugin::Viewport>, + doc: Option<ide_core::DocId>, +) -> IdeResult<ide_plugin::View> { + let plugins = state.plugins.clone(); + ide_host::plugins::views::render(&state.host, plugins, window.label(), plugin_id, view, viewport, doc).await +} + +/// A user action in a plugin view or custom editor. +#[tauri::command] +#[specta::specta] +pub async fn plugin_view_event( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + view: String, + event: ide_plugin::ViewEvent, + doc: Option<ide_core::DocId>, +) -> IdeResult<()> { + let plugins = state.plugins.clone(); + ide_host::plugins::views::event(&state.host, plugins, window.label(), plugin_id, view, event, doc).await +} + +/// The marketplace's plugins: read again when `refresh`, else the last index read. +#[tauri::command] +#[specta::specta] +pub async fn plugin_market( + state: State<'_, AppState>, + refresh: bool, +) -> IdeResult<Vec<ide_host::plugins::MarketPlugin>> { + let host = state.plugins.clone(); + blocking(move || if refresh { host.refresh_market() } else { Ok(host.market_plugins()) }).await +} + +#[tauri::command] +#[specta::specta] +pub fn plugin_market_url(state: State<'_, AppState>) -> Option<String> { + state.plugins.market_url() +} + +/// Sets the marketplace index URL (an `https://…/index.json` or a local path; `None` for none). +#[tauri::command] +#[specta::specta] +pub fn plugin_set_market_url(state: State<'_, AppState>, url: Option<String>) { + state.plugins.set_market_url(url); +} + +/// Downloads and checks a marketplace plugin; returns the folder for the install page. +#[tauri::command] +#[specta::specta] +pub async fn plugin_market_download(state: State<'_, AppState>, id: String) -> IdeResult<PathBuf> { + let host = state.plugins.clone(); + blocking(move || host.download_market_plugin(&id)).await +} + +/// Unpacks a package file (`.oiplugin`); returns the folder for the install page. +#[tauri::command] +#[specta::specta] +pub async fn plugin_unpack_package(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PathBuf> { + let host = state.plugins.clone(); + blocking(move || host.unpack_package(&path)).await +} + +/// Goes back to the version the last update of `id` replaced. +#[tauri::command] +#[specta::specta] +pub async fn plugin_roll_back(state: State<'_, AppState>, id: String) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.roll_back_update(&id)).await +} + +/// A secret a plugin declares, and whether the user stored it. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginSecretState { + pub name: String, + pub set: bool, +} + +#[tauri::command] +#[specta::specta] +pub fn plugin_secrets(state: State<'_, AppState>, plugin_id: String) -> IdeResult<Vec<PluginSecretState>> { + Ok(state.plugins.secrets(&plugin_id)?.into_iter().map(|(name, set)| PluginSecretState { name, set }).collect()) +} + +/// Stores a plugin secret (`None` or empty deletes it). The value never goes back to the UI or the plugin. +#[tauri::command] +#[specta::specta] +pub fn plugin_set_secret( + state: State<'_, AppState>, + plugin_id: String, + name: String, + value: Option<String>, +) -> IdeResult<()> { + state.plugins.set_secret(&plugin_id, &name, value.as_deref()) +} + +/// Plugin secrets in the login keychain (service `ai.l0ng.openidea.plugin.<id>`). +#[cfg(target_os = "macos")] +struct KeychainSecrets; + +#[cfg(target_os = "macos")] +impl ide_host::plugins::SecretStore for KeychainSecrets { + fn get(&self, plugin: &str, name: &str) -> Option<String> { + let bytes = security_framework::passwords::get_generic_password(&Self::service(plugin), name).ok()?; + String::from_utf8(bytes).ok() + } + + fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> { + let service = Self::service(plugin); + match value { + Some(v) => security_framework::passwords::set_generic_password(&service, name, v.as_bytes()), + None => match security_framework::passwords::delete_generic_password(&service, name) { + // Deleting what isn't there is fine. + Err(e) if e.code() == -25300 => Ok(()), + other => other, + }, + } + .map_err(|e| e.to_string()) + } +} + +#[cfg(target_os = "macos")] +impl KeychainSecrets { + fn service(plugin: &str) -> String { + format!("ai.l0ng.openidea.plugin.{plugin}") + } +} + +/// The UI reports the focused editor's file (`editor-focused` events). #[tauri::command] #[specta::specta] pub fn plugin_set_active_file(window: Window, state: State<'_, AppState>, path: Option<String>) { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 8ad7a7aee..571912328 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -169,6 +169,8 @@ const SEP: Entry = Entry::Separator; /// [`Entry::Dynamic`] slots the frontend fills. pub const DYNAMIC_TOOL_WINDOWS: &str = "toolWindows"; pub const DYNAMIC_LAYOUTS: &str = "toolWindowLayouts"; +/// Tools › entries of plugins (`[[contributes.menus]] menu = "tools"`). +pub const DYNAMIC_PLUGIN_TOOLS: &str = "pluginTools"; /// The menu bar for `os`. Labels are English defaults; the frontend replaces /// them with `menu.<id>` strings from the active locale catalog. @@ -725,6 +727,7 @@ pub fn spec(os: Os) -> Vec<Entry> { ), SEP, act("vim.toggle", "Vim"), + Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS), ], )); @@ -1484,7 +1487,7 @@ mod tests { let spec = spec(os); let mut found = Vec::new(); slots(&spec, &mut found); - assert_eq!(found, [DYNAMIC_TOOL_WINDOWS, DYNAMIC_LAYOUTS], "{os:?}"); + assert_eq!(found, [DYNAMIC_TOOL_WINDOWS, DYNAMIC_PLUGIN_TOOLS, DYNAMIC_LAYOUTS], "{os:?}"); assert!(!action_ids(&spec).iter().any(|id| { id.starts_with("toolwindow.") && *id != "toolwindow.hideAll" diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index e45f7eef7..d9d47b8f6 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -90,6 +90,11 @@ impl Connections { .map(|(label, _)| label.clone()) } + /// Every connected window. + pub fn all(&self) -> Vec<(String, Arc<Connection>)> { + self.0.lock().iter().map(|(w, c)| (w.clone(), c.clone())).collect() + } + pub fn disconnect(&self, window: &str) { if let Some(c) = self.0.lock().remove(window) { c.peer.kill(); @@ -157,6 +162,16 @@ impl Handler for ClientHandler { } Err(e) => tracing::warn!("remote event: {e}"), }, + // Plugin code of this window's project runs on the server (docs/plugin_plan.md 3). + ide_host::plugins::remote_host::EVENT_NOTIFICATION => { + match serde_json::from_value::<ide_host::plugins::remote_host::PluginEventMessage>(params) { + Ok(m) if ide_host::plugins::remote_host::forwarded(&m.event) => { + crate::commands::plugin::deliver_to(&self.window, m.event) + } + Ok(_) => {} + Err(e) => tracing::warn!("remote plugin event: {e}"), + } + } "channel" => match serde_json::from_value::<ChannelMessage>(params) { Ok(m) => { let channel = self.channels.lock().get(&m.id).cloned(); @@ -170,8 +185,18 @@ impl Handler for ClientHandler { } } - fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> { - Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}"))) + fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> { + use ide_host::plugins::remote_host::{SECRET_REQUEST, SecretRequest}; + match method { + // A remote plugin's request header needs a secret from this machine's keychain. + SECRET_REQUEST => { + let r: SecretRequest = + serde_json::from_value(params).map_err(|e| (ide_jsonrpc::codes::INVALID_PARAMS, e.to_string()))?; + let state = self.app.state::<AppState>(); + Ok(serde_json::to_value(state.plugins.declared_secret(&r.plugin, &r.name)).unwrap_or(Value::Null)) + } + _ => Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}"))), + } } } @@ -401,7 +426,12 @@ pub async fn remote_connect( next_channel: std::sync::atomic::AtomicU64::new(1), _session: session, }); - state.remote_dev.0.lock().insert(label, connection); + state.remote_dev.0.lock().insert(label.clone(), connection.clone()); + let plugins = state.plugins.clone(); + tauri::async_runtime::spawn(async move { + sync_plugin_languages(&plugins, &connection, &label).await; + sync_plugin_packages(&plugins, &connection, &label).await; + }); Ok(RemoteProject { ssh_config, root, @@ -413,6 +443,80 @@ pub async fn remote_connect( }) } +/// Sends the desktop's plugin languages, with the programs granted to them, to +/// the window's server: `Workspace` contributions take effect where the project +/// is (docs/plugin_plan.md 3). Servers without the command are left alone. +pub async fn sync_plugin_languages(plugins: &ide_host::plugins::PluginHost, c: &Connection, window: &str) { + use ide_host::plugins::remote::{SYNC_COMMAND, SyncReport}; + if !c.commands.iter().any(|cmd| cmd == SYNC_COMMAND) { + return; + } + let call = Invoke { + window: window.to_owned(), + cmd: SYNC_COMMAND.into(), + args: serde_json::json!({ "languages": plugins.remote_languages() }), + }; + match c.peer.request::<_, SyncReport>("invoke", call, None).await { + Ok(report) => { + for program in &report.unresolved { + tracing::info!("{}: plugin program {program} is not installed", c.host); + } + for rejected in &report.rejected { + tracing::warn!("{}: plugin language rejected: {rejected}", c.host); + } + } + Err(e) => tracing::warn!("{}: syncing plugin languages: {e}", c.host), + } +} + +/// Sends the desktop's live code plugins to the window's server, which runs +/// them next to the project (docs/plugin_plan.md 3): digests and grants first, +/// then the files of the packages the server lacks, one per request. +pub async fn sync_plugin_packages(plugins: &Arc<ide_host::plugins::PluginHost>, c: &Connection, window: &str) { + use ide_host::plugins::remote_host::{PackageSyncReport, SYNC_PACKAGES}; + if !c.commands.iter().any(|cmd| cmd == SYNC_PACKAGES) { + return; + } + let host = plugins.clone(); + let Ok(packages) = tauri::async_runtime::spawn_blocking(move || host.remote_packages()).await else { return }; + let sync = |files: serde_json::Value| Invoke { + window: window.to_owned(), + cmd: SYNC_PACKAGES.into(), + args: serde_json::json!({ "packages": packages, "files": files }), + }; + let report: PackageSyncReport = match c.peer.request("invoke", sync(Value::Null), None).await { + Ok(r) => r, + Err(e) => return tracing::warn!("{}: syncing plugins: {e}", c.host), + }; + let mut failed = report.failed; + for id in report.missing { + let host = plugins.clone(); + let pid = id.clone(); + let files = match tauri::async_runtime::spawn_blocking(move || host.remote_package_files(&pid)).await { + Ok(Ok(files)) => files, + Ok(Err(e)) => { + failed.push(format!("{id}: {e}")); + continue; + } + Err(e) => { + failed.push(format!("{id}: {e}")); + continue; + } + }; + match c + .peer + .request::<_, PackageSyncReport>("invoke", sync(serde_json::json!({ (id.clone()): files })), None) + .await + { + Ok(r) => failed.extend(r.failed), + Err(e) => failed.push(format!("{id}: {e}")), + } + } + for f in failed { + tracing::warn!("{}: plugin not available remotely: {f}", c.host); + } +} + #[derive(Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct RemoteSession { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e3ad4ea39..b205e44c6 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -16,8 +16,8 @@ pub struct AppState { pub host: Arc<Host>, pub startup: Startup, pub recent: RecentProjects, - /// WASM plugins (`ide-plugin`), shared by all windows. - pub plugins: Arc<ide_plugin::PluginHost>, + /// Plugins (`ide_host::plugins`), shared by all windows. + pub plugins: Arc<ide_host::plugins::PluginHost>, /// The built-in web server (Open In › Browser, the editor's browser bar). pub web: ide_web::WebServer, /// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start). diff --git a/src/editor/api.ts b/src/editor/api.ts index 45b2a70fd..fe71e2c48 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -585,12 +585,25 @@ export function isLibraryPath(path: string): boolean { */ export interface DocumentAttachment { path: string; + /** The backend document (for document-scoped commands). */ + doc: number; /** Current document text; build the view's state from it. */ text(): string; readOnly: boolean; /** Add to the view (one view at a time). */ extension: Extension; save(options?: { exact?: boolean }): Promise<void>; + /** Resolves once local edits have reached the backend. */ + synced(): Promise<void>; + /** Unsaved changes. */ + dirty(): boolean; + /** + * Undo / redo of the document's history: the text editor tab's when one + * shows the file (backend edits are recorded there), else this view's. + * Returns whether anything changed. + */ + undo(): boolean; + redo(): boolean; /** Detach; the document closes when nothing else shows it. */ release(): void; } diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 2b8231d76..ddff2757a 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -21,8 +21,10 @@ import { _languageMode } from "@/editor/api"; let infos: Map<string, LanguageInfo> | null = null; let infosPromise: Promise<Map<string, LanguageInfo>> | null = null; +let infosBackend: DocBackend | null = null; export function loadLanguageInfos(backend: DocBackend): Promise<Map<string, LanguageInfo>> { + infosBackend = backend; return (infosPromise ??= backend .languages() .then((list) => (infos = new Map(list.map((l) => [l.id, l])))) @@ -37,6 +39,15 @@ export function languageInfo(id: string): LanguageInfo | undefined { return infos?.get(id); } +/** + * The language list changed (a plugin added or removed languages): fetch it + * again. Open documents follow through their `docLanguage` events. + */ +export function refreshLanguageInfos(): Promise<unknown> { + infosPromise = null; + return infosBackend ? loadLanguageInfos(infosBackend) : Promise.resolve(); +} + /** Test hook. */ export function _resetLanguages() { infos = null; diff --git a/src/editor/core/model/attachment.test.ts b/src/editor/core/model/attachment.test.ts index 1be4ef1dc..b733293b9 100644 --- a/src/editor/core/model/attachment.test.ts +++ b/src/editor/core/model/attachment.test.ts @@ -110,4 +110,33 @@ describe("attached document views (editable diff side)", () => { att.release(); tab.dispose(); }); + + it("undoes backend edits in the document's history: the text tab's when one is open (custom editors)", async () => { + const { backend, manager, frame } = setup({ "/p/c.csv": "a,b\n" }); + const att = await attachDocument(manager, "/p/c.csv"); + const view = new EditorView({ doc: att.text(), extensions: [att.extension, history()] }); + const model = manager.get("/p/c.csv")!; + expect(att.doc).toBe(model.doc); + + // No tab: the attachment holds the history. + backend.patch(model.doc, [{ from: 0, to: 1, insert: "X" }]); + expect(att.undo()).toBe(true); + expect(view.state.doc.toString()).toBe("a,b\n"); + expect(att.redo()).toBe(true); + expect(view.state.doc.toString()).toBe("X,b\n"); + await frame(); + await att.synced(); + expect(backend.docs.get(model.doc)?.text).toBe("X,b\n"); + + // With a text tab, backend edits are recorded there: undo from the custom editor reaches it. + const tab = new EditorSlot(model, "t1", host); + backend.patch(model.doc, [{ from: 2, to: 3, insert: "Y" }]); + expect(tab.currentState().doc.toString()).toBe("X,Y\n"); + expect(att.undo()).toBe(true); + expect(tab.currentState().doc.toString()).toBe("X,b\n"); + expect(view.state.doc.toString()).toBe("X,b\n"); + view.destroy(); + att.release(); + tab.dispose(); + }); }); diff --git a/src/editor/core/model/attachment.ts b/src/editor/core/model/attachment.ts index be24aed91..51c656d02 100644 --- a/src/editor/core/model/attachment.ts +++ b/src/editor/core/model/attachment.ts @@ -3,7 +3,8 @@ * diff): local edits flow view → model → backend and other editors; edits * from elsewhere are mirrored into the view. See `attachDocument` in editor/api. */ -import { Transaction, type Annotation, type ChangeSet } from "@codemirror/state"; +import { redo, undo } from "@codemirror/commands"; +import { Transaction, type Annotation, type ChangeSet, type StateCommand } from "@codemirror/state"; import { EditorView, ViewPlugin } from "@codemirror/view"; import { _addEditor, @@ -65,8 +66,24 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr }; }); + /** Runs a history command where the document's history is (see `DocumentAttachment.undo`). */ + const historyCommand = (command: StateCommand): boolean => { + const slot = model.primarySlot(); + if (slot?.view) return command(slot.view); + if (slot) + return command({ + state: slot.state, + dispatch: (tr) => { + slot.state = tr.state; + if (tr.docChanged) model.onLocal(tr.changes, slot); + }, + }); + return target.view ? command(target.view) : false; + }; + return { path, + doc: model.doc, text: () => model.sync.localText.toString(), readOnly: model.getMeta().readOnly || model.virtual, extension: [ @@ -81,6 +98,10 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr }), ], save: (options) => model.save(options?.exact ? "exact" : "explicit"), + synced: () => model.sync.whenIdle(), + dirty: () => model.getMeta().dirty, + undo: () => historyCommand(undo), + redo: () => historyCommand(redo), release: () => { if (released) return; released = true; diff --git a/src/features/app/menuBridge.ts b/src/features/app/menuBridge.ts index 7b5d4f84c..1cb210867 100644 --- a/src/features/app/menuBridge.ts +++ b/src/features/app/menuBridge.ts @@ -7,13 +7,13 @@ * shortcut text of every keymap binding, so the menu shows the JS keymap's * shortcuts without registering native accelerators (see src-tauri/src/menu.rs). */ -import { executeAction, getAction, isActionEnabled, useActions } from "@/platform/actions"; +import { executeActionFrom, getAction, isActionEnabled, useActions } from "@/platform/actions"; import { useContextKeys } from "@/platform/context"; import { keysWithPrefix, t, tx, useI18n } from "@/platform/i18n"; import { commands, isTauri, type DynamicItem, type MenuConfig, type MenuNode } from "@/platform/ipc"; import { effectiveBindings, formatBinding, primaryKeyBinding, useKeymap } from "@/platform/keymap"; import { useProject } from "@/platform/project"; -import { useContributions } from "@/workbench/contributions"; +import { useContributions, useMenuSlotItems } from "@/workbench/contributions"; import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree"; import { useToolWindowLayouts } from "@/workbench/toolwindows/layouts"; import { hostOs, type HostOs } from "./host"; @@ -30,7 +30,10 @@ export function isMenuRoute(v: unknown): v is MenuRoute { } /** Runs the action a menu item stands for. Returns whether it ran. */ -export function routeMenuEvent(payload: unknown, run = executeAction): Promise<boolean> { +export function routeMenuEvent( + payload: unknown, + run = (id: string, arg?: unknown) => executeActionFrom("menu", id, arg), +): Promise<boolean> { if (!isMenuRoute(payload) || !payload.action) return Promise.resolve(false); return run(payload.action, payload.arg ?? undefined); } @@ -62,7 +65,15 @@ export function dynamicMenuItems(): Record<string, DynamicItem[]> { const layouts = useToolWindowLayouts .getState() .layouts.map((l) => ({ action: "layout.restore", arg: l.name, label: l.name })); - return { toolWindows, toolWindowLayouts: layouts }; + const slots = Object.fromEntries( + Object.entries(useMenuSlotItems.getState().slots).map(([slot, items]) => [ + slot, + [...items] + .map((i) => ({ action: i.action, arg: i.arg ?? null, label: tx(i.label) ?? i.label })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]), + ); + return { ...slots, toolWindows, toolWindowLayouts: layouts }; } /** Action ids of every IDE item in a menu model. */ @@ -150,6 +161,7 @@ export function installMenuBridge(): () => void { if (s.toolWindows !== prev.toolWindows) push(); }), useToolWindowLayouts.subscribe(push), + useMenuSlotItems.subscribe(push), useContextKeys.subscribe(() => pushEnablement()), useActions.subscribe(() => pushEnablement()), // Opening / closing a project changes the Open Recent list. diff --git a/src/features/plugins/PluginsPage.module.css b/src/features/plugins/PluginsPage.module.css index 1f03caa78..f54100aa9 100644 --- a/src/features/plugins/PluginsPage.module.css +++ b/src/features/plugins/PluginsPage.module.css @@ -198,11 +198,13 @@ .header { display: flex; align-items: flex-start; + /* In a narrow window the actions go below rather than over the name. */ + flex-wrap: wrap; gap: 14px; } .headerText { display: flex; - flex: 1; + flex: 1 1 160px; flex-direction: column; gap: 2px; min-width: 0; @@ -222,6 +224,11 @@ flex-wrap: wrap; gap: 8px; } +/* Badges are counter pills (`min-width: 16px`): in a narrow window they wrap instead of shrinking under their text. */ +.titleLine > * { + flex: none; + white-space: nowrap; +} .title { margin: 0; color: var(--fg-strong); @@ -313,3 +320,67 @@ text-overflow: ellipsis; white-space: nowrap; } + +/* Permission dialog and notices */ +.consent { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.consentWarning { + margin: 0; + color: var(--fg-warning, var(--fg)); +} + +.consentRequired { + width: 16px; + text-align: center; + color: var(--fg-muted); +} + +.consentNotice { + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 12px; +} + +.settingsPage { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 560px; +} + +.settingRow { + display: flex; + flex-direction: column; + gap: 4px; +} + +.marketItem { + list-style: none; + padding: 4px 8px; +} + +.marketBar { + display: flex; + gap: 4px; + align-items: center; +} + +.marketBar > :first-child { + flex: 1; + min-width: 0; +} + +.marketOverview { + padding: 12px 16px; + overflow: auto; +} diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 8d496a3fb..1bdddb992 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -8,7 +8,7 @@ */ import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; import clsx from "clsx"; -import type { CatalogPlugin, Permission, PluginInfo, PluginStatus } from "@/bindings"; +import type { CapabilityInfo, CatalogPlugin, MarketPlugin, PluginInfo, PluginStatus } from "@/bindings"; import { Badge, Button, @@ -16,7 +16,7 @@ import { EmptyState, Icon, IconButton, - Kbd, + Input, SearchField, Spinner, Tabs, @@ -24,16 +24,21 @@ import { type MenuItem, T, } from "@/ui"; -import { pluginActionId } from "./bridge"; +import { RISK_LABEL, sortByRisk } from "./consent"; +import { itemsOf, uiPoint } from "./projection"; import { compareVersions, + grantPlugin, installFromDisk, + installFromMarket, + refreshMarket, + rollBackPlugin, + setMarketUrl, refreshCatalog, refreshPlugins, reloadPlugin, - runPluginCommand, - selectPluginTheme, setAllInstalledEnabled, + setPluginCapability, setPluginEnabled, uninstallPlugin, usePlugins, @@ -41,19 +46,26 @@ import { import styles from "./PluginsPage.module.css"; import { tx } from "@/platform/i18n"; -const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" }> = { +const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" | "warning" }> = { active: { label: "Running", tone: "success" }, + inactive: { label: "Enabled", tone: "accent" }, static: { label: "Enabled", tone: "accent" }, disabled: { label: "Disabled", tone: "neutral" }, + needsConsent: { label: "Needs permission", tone: "warning" }, failed: { label: "Failed", tone: "danger" }, }; -export const PERMISSION_TEXT: Record<Permission, string> = { - "project-read": "Read files inside the open project", - "editor-read": "See which file is open in the editor", - "editor-write": "Insert text into the active editor", +/** Human names of the built-in contribution points (others show their id). */ +const POINT_TITLES: Record<string, string> = { + commands: "Commands", + themes: "Themes", + iconThemes: "File Icon Themes", + languages: "Languages", + providers: "Editor Providers", }; +const pointTitle = (point: string) => uiPoint(point)?.title ?? POINT_TITLES[point] ?? point; + export const NO_REPOSITORY = "There is no online plugin repository"; type Tab = "marketplace" | "installed"; @@ -65,16 +77,14 @@ export function filterPlugins(plugins: readonly PluginInfo[], query: string): Pl const q = query.trim().toLowerCase(); if (!q) return [...plugins]; return plugins.filter((p) => - matches([p.name, p.id, p.description, ...p.authors, ...p.commands.map((c) => c.title)], q), + matches([p.name, p.id, p.description, ...p.authors, ...p.contributions.map((c) => c.label)], q), ); } export function filterCatalog(catalog: readonly CatalogPlugin[], query: string): CatalogPlugin[] { const q = query.trim().toLowerCase(); if (!q) return [...catalog]; - return catalog.filter((p) => - matches([p.name, p.id, p.description, ...p.authors, ...p.commands.map((c) => c.title)], q), - ); + return catalog.filter((p) => matches([p.name, p.id, p.description, ...p.authors], q)); } /** What the Available tab offers for a catalog package given what is installed. */ @@ -84,7 +94,7 @@ export function catalogState( ): "install" | "update" | "installed" | "dev" { const have = installed.find((p) => p.id === entry.id); if (!have) return "install"; - if (have.source === "dev") return "dev"; + if (have.source !== "installed") return "dev"; return compareVersions(entry.version, have.version) > 0 ? "update" : "installed"; } @@ -124,16 +134,19 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele <span className={styles.rowMeta}> <span className={styles.rowVendor}>{vendorOf(plugin.authors) || plugin.id}</span> <span>{plugin.version}</span> - {plugin.source === "dev" && ( - <span className={styles.devTag}> - <T>dev</T> - </span> + {plugin.source !== "installed" && ( + <span className={styles.devTag}>{plugin.source === "linked" ? tx("linked") : tx("dev")}</span> )} {plugin.status === "failed" && ( <Badge dot tone="danger"> <T>Failed</T> </Badge> )} + {plugin.status === "needsConsent" && ( + <Badge dot tone="warning"> + <T>Needs permission</T> + </Badge> + )} </span> </span> </button> @@ -234,12 +247,73 @@ function DetailTabs({ notes, overview }: { notes: string | null; overview: React ); } +/** Contribution points of a plugin, in the order they were committed. */ +function pointsOf(plugin: PluginInfo): string[] { + return [...new Set(plugin.contributions.map((c) => c.point))]; +} + +function PointSection({ plugin, point }: { plugin: PluginInfo; point: string }) { + const ui = usePlugins((s) => s.ui); + const Details = uiPoint(point)?.Details; + const items = itemsOf(ui, point, plugin.id); + return ( + <Section title={pointTitle(point)}> + {Details && items.length > 0 ? ( + <Details plugin={plugin} items={items as never} /> + ) : ( + <ul className={styles.items}> + {plugin.contributions + .filter((c) => c.point === point) + .map((c, i) => ( + <li key={`${c.label}-${i}`} className={styles.item}> + <span className={styles.itemMain}>{c.label}</span> + </li> + ))} + </ul> + )} + </Section> + ); +} + +function CapabilityRow({ cap, children }: { cap: CapabilityInfo; children?: ReactNode }) { + return ( + <li className={styles.item}> + {children} + <span className={styles.itemMain}>{cap.description}</span> + <span className={styles.mono}>{cap.id}</span> + <Badge tone={RISK_LABEL[cap.risk].tone}>{RISK_LABEL[cap.risk].label}</Badge> + </li> + ); +} + +function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) { + if (plugin.capabilities.length === 0) { + return ( + <div className={styles.muted}> + <T>No special permissions.</T> + </div> + ); + } + const confirmed = plugin.status !== "needsConsent"; + return ( + <ul className={styles.items} aria-label={tx("Permissions")}> + {sortByRisk(plugin.capabilities).map((c) => ( + <CapabilityRow key={c.id} cap={c}> + <Checkbox + checked={c.granted} + disabled={busy || !confirmed} + onChange={(on) => void setPluginCapability(plugin.id, c.id, on)} + aria-label={c.granted ? tx`Revoke ${c.id}` : tx`Allow ${c.id}`} + /> + </CapabilityRow> + ))} + </ul> + ); +} + function InstalledDetails({ plugin }: { plugin: PluginInfo }) { const busy = usePlugins((s) => !!s.busy[plugin.id]); - const activeTheme = usePlugins((s) => s.activeTheme); const status = STATUS[plugin.status]; - const running = plugin.status === "active"; - const live = running || plugin.status === "static"; const [confirmUninstall, setConfirmUninstall] = useState(false); const overview = ( @@ -276,95 +350,41 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) { )} </dl> - {plugin.hasCode && ( - <Section title="Permissions"> - {plugin.permissions.length === 0 ? ( - <div className={styles.muted}> - <T>No special permissions.</T> - </div> - ) : ( - <ul className={styles.items}> - {plugin.permissions.map((p) => ( - <li key={p} className={styles.item}> - <span className={styles.itemMain}>{PERMISSION_TEXT[p] ?? p}</span> - <span className={styles.mono}>{p}</span> - </li> - ))} - </ul> - )} - </Section> + {plugin.status === "needsConsent" && ( + <div className={styles.consentNotice} role="note"> + <span> + {plugin.source === "installed" + ? tx("This version asks for new permissions. It stays off until you allow them.") + : tx("Plugins from a project folder run only after you allow them.")} + </span> + <Button size="small" variant="primary" disabled={busy} onClick={() => void grantPlugin(plugin.id)}> + <T>Review Permissions…</T> + </Button> + </div> )} - {plugin.commands.length > 0 && ( - <Section title="Commands"> - <ul className={styles.items}> - {plugin.commands.map((c) => { - const actionId = pluginActionId(plugin.id, c.id); - return ( - <li key={c.id} className={styles.item}> - <span className={styles.itemMain}>{c.title}</span> - <Kbd action={actionId} /> - <span className={styles.mono} title={tx("Action id (bind it in Keymap)")}> - {actionId} - </span> - <Button - size="small" - variant="ghost" - icon="run" - disabled={!running} - onClick={() => void runPluginCommand(plugin.id, c.id)} - aria-label={tx`Run ${c.title}`} - > - <T>Run</T> - </Button> - </li> - ); - })} - </ul> - </Section> - )} + {plugin.warnings.map((w) => ( + <div key={w} className={styles.muted}> + {w} + </div> + ))} - {plugin.themes.length > 0 && ( - <Section title="Themes"> - <ul className={styles.items}> - {plugin.themes.map((t) => { - const isActive = activeTheme?.pluginId === plugin.id && activeTheme.theme.id === t.id; - return ( - <li key={t.id} className={styles.item}> - <span className={styles.itemMain}>{t.name}</span> - <span className={styles.muted}>{t.kind === "dark" ? tx("Dark") : tx("Light")}</span> - {isActive ? ( - <Button size="small" variant="secondary" onClick={() => void selectPluginTheme(null, null)}> - <T>Use Default Theme</T> - </Button> - ) : ( - <Button - size="small" - variant="ghost" - disabled={!live} - onClick={() => void selectPluginTheme(plugin.id, t.id)} - aria-label={tx`Apply theme ${t.name}`} - > - <T>Apply</T> - </Button> - )} - </li> - ); - })} - </ul> - </Section> - )} + <Section title="Permissions"> + <CapabilityList plugin={plugin} busy={busy} /> + </Section> - {plugin.languages.length > 0 && ( - <Section title="Languages"> - <ul className={styles.items}> - {plugin.languages.map((l) => ( - <li key={l.id} className={styles.item}> - <span className={styles.itemMain}>{l.name}</span> - <span className={styles.mono}>{l.extensions.map((e) => `.${e}`).join(" ") || l.id}</span> - </li> - ))} - </ul> + {pointsOf(plugin).map((point) => ( + <PointSection key={point} plugin={plugin} point={point} /> + ))} + + {plugin.hasCode && plugin.stats.activations > 0 && ( + <Section title="Statistics"> + <div className={styles.muted}> + {tx`Activated ${plugin.stats.activations}× (last in ${(plugin.stats.activationMs ?? 0).toFixed(1)} ms), ${plugin.stats.calls} calls, ${plugin.stats.errors} errors, slowest ${(plugin.stats.maxCallMs ?? 0).toFixed(1)} ms`} + </div> + <div className={styles.muted}> + {tx`Recent calls p95 ${(plugin.stats.p95CallMs ?? 0).toFixed(1)} ms; ${plugin.stats.crashes} crashes, ${plugin.stats.slowCalls} editor results too late, reclaimed ${plugin.stats.reclaims}× when idle`} + </div> </Section> )} </> @@ -380,11 +400,15 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) { <div className={styles.titleLine}> <span className={styles.version}>{plugin.version}</span> <Badge tone={status.tone}>{status.label}</Badge> - {plugin.source === "dev" && ( - <Badge tone="warning"> - <T>Dev plugin</T> - </Badge> + {plugin.source !== "installed" && ( + <Badge tone="warning">{plugin.source === "linked" ? tx("Linked plugin") : tx("Project plugin")}</Badge> )} + {plugin.source === "installed" && + (plugin.publisher ? ( + <Badge tone="success">{tx`Signed by ${plugin.publisher}`}</Badge> + ) : ( + <Badge tone="neutral">{tx("Unsigned")}</Badge> + ))} </div> </div> <div className={styles.headerActions}> @@ -404,6 +428,11 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) { disabled={busy} onClick={() => void reloadPlugin(plugin.id)} /> + {plugin.previousVersion && ( + <Button size="small" disabled={busy} onClick={() => void rollBackPlugin(plugin.id)}> + {tx`Roll Back to ${plugin.previousVersion}`} + </Button> + )} {plugin.source === "installed" && (confirmUninstall ? ( <> @@ -433,11 +462,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) { } function CatalogDetails({ entry }: { entry: CatalogPlugin }) { - const contributes = [ - entry.commands.length && `${entry.commands.length} ${entry.commands.length === 1 ? "command" : "commands"}`, - entry.themeCount && `${entry.themeCount} ${entry.themeCount === 1 ? "theme" : "themes"}`, - entry.languageCount && `${entry.languageCount} ${entry.languageCount === 1 ? "language" : "languages"}`, - ].filter(Boolean); + const contributes = entry.contributions.map((c) => `${pointTitle(c.point)}: ${c.count}`); const overview = ( <> {entry.problem && ( @@ -470,14 +495,11 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) { </> )} </dl> - {entry.permissions.length > 0 && ( + {entry.capabilities.length > 0 && ( <Section title="Permissions"> <ul className={styles.items}> - {entry.permissions.map((p) => ( - <li key={p} className={styles.item}> - <span className={styles.itemMain}>{PERMISSION_TEXT[p] ?? p}</span> - <span className={styles.mono}>{p}</span> - </li> + {sortByRisk(entry.capabilities).map((c) => ( + <CapabilityRow key={c.id} cap={c} /> ))} </ul> </Section> @@ -504,10 +526,11 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) { ); } -function gearMenu(plugins: readonly PluginInfo[], install: () => void): MenuItem[] { +function gearMenu(plugins: readonly PluginInfo[], install: (packageFile: boolean) => void): MenuItem[] { const installed = plugins.filter((p) => p.source === "installed"); return [ - { label: "Install Plugin from Disk…", icon: "package", run: install }, + { label: "Install Plugin from Disk…", icon: "package", run: () => install(false) }, + { label: "Install Plugin from Package File…", run: () => install(true) }, { kind: "separator" }, { label: "Disable All Downloaded Plugins", @@ -526,13 +549,127 @@ function gearMenu(plugins: readonly PluginInfo[], install: () => void): MenuItem run: () => { void refreshPlugins(); void refreshCatalog(); + void refreshMarket(true); }, }, ]; } +/** The marketplace index URL, editable in place. */ +function MarketUrlBar() { + const current = usePlugins((s) => s.marketUrl); + const [value, setValue] = useState(current ?? ""); + useEffect(() => setValue(current ?? ""), [current]); + return ( + <form + className={styles.marketBar} + onSubmit={(e) => { + e.preventDefault(); + void setMarketUrl(value); + }} + > + <Input + value={value} + placeholder="Marketplace index URL (https://…/index.json or a folder path)" + aria-label="Marketplace index URL" + onChange={(e) => setValue(e.target.value)} + /> + <IconButton icon="refresh" title="Refresh Marketplace" type="submit" /> + </form> + ); +} + +function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) { + const update = entry.installed && compareVersions(entry.version, entry.installed) > 0; + return ( + <li className={clsx(styles.row, selected && styles.rowSelected)}> + <button + type="button" + className={styles.rowMain} + aria-current={selected || undefined} + onClick={onSelect} + title={entry.description || entry.name} + > + <PluginIcon /> + <span className={styles.rowText}> + <span className={styles.rowName}>{entry.name}</span> + <span className={styles.rowMeta}> + <span className={styles.rowVendor}>{entry.publisher}</span> + <span>{entry.version}</span> + </span> + </span> + </button> + {update ? ( + <Badge tone="accent">{tx("Update")}</Badge> + ) : entry.installed ? ( + <Badge tone="neutral">{tx("Installed")}</Badge> + ) : null} + </li> + ); +} + +function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalled: (id: string) => void }) { + const installing = usePlugins((s) => s.installing); + const update = entry.installed && compareVersions(entry.version, entry.installed) > 0; + const install = async () => { + const id = await installFromMarket(entry.id); + if (id) onInstalled(id); + }; + return ( + <div className={styles.details} aria-label={`${entry.name} details`}> + <header className={styles.header}> + <PluginIcon large /> + <div className={styles.headerText}> + <h2 className={styles.title}>{entry.name}</h2> + <div className={styles.authors}>{entry.authors?.length ? vendorOf(entry.authors) : entry.publisher}</div> + <div className={styles.titleLine}> + <span className={styles.version}>{entry.version}</span> + <Badge tone="success">{tx`Signed by ${entry.publisher}`}</Badge> + </div> + </div> + <div className={styles.headerActions}> + {(!entry.installed || update) && ( + <Button size="small" variant="primary" disabled={installing} onClick={() => void install()}> + {update ? tx`Update to ${entry.version}` : tx("Install")} + </Button> + )} + </div> + </header> + <div className={styles.marketOverview}> + {entry.description && <p className={styles.description}>{entry.description}</p>} + <dl className={styles.facts}> + <dt> + <T>Id</T> + </dt> + <dd className={styles.mono}>{entry.id}</dd> + {entry.installed && ( + <> + <dt> + <T>Installed</T> + </dt> + <dd>{entry.installed}</dd> + </> + )} + </dl> + {(entry.capabilities?.length ?? 0) > 0 && ( + <Section title="Permissions"> + <ul className={styles.items}> + {(entry.capabilities ?? []).map((c) => ( + <li key={c} className={clsx(styles.item, styles.mono)}> + {c} + </li> + ))} + </ul> + </Section> + )} + </div> + </div> + ); +} + export function PluginsPage() { - const { plugins, loaded, loading, error, installing, catalog, catalogError } = usePlugins(); + const { plugins, loaded, loading, error, installing, catalog, catalogError, market, marketUrl, marketError } = + usePlugins(); const [tab, setTab] = useState<Tab>("installed"); const [query, setQuery] = useState(""); const [selectedId, setSelectedId] = useState<string | null>(null); @@ -541,18 +678,30 @@ export function PluginsPage() { useEffect(() => { void refreshPlugins(); void refreshCatalog(); + void refreshMarket(false); }, []); + const [selectedMarketId, setSelectedMarketId] = useState<string | null>(null); + const marketShown = useMemo( + () => + (market ?? []).filter((m) => { + const q = query.trim().toLowerCase(); + return !q || matches([m.name, m.id, m.description, m.publisher, ...(m.authors ?? [])], q); + }), + [market, query], + ); + const selectedMarket = marketShown.find((m) => m.id === selectedMarketId) ?? null; const shown = useMemo(() => filterPlugins(plugins, query), [plugins, query]); const downloaded = shown.filter((p) => p.source === "installed"); const dev = shown.filter((p) => p.source === "dev"); + const linked = shown.filter((p) => p.source === "linked"); const selected = shown.find((p) => p.id === selectedId) ?? shown[0] ?? null; const catalogShown = useMemo(() => filterCatalog(catalog ?? [], query), [catalog, query]); const selectedEntry = catalogShown.find((c) => c.id === selectedCatalogId) ?? catalogShown[0] ?? null; - const install = async () => { - const id = await installFromDisk(); + const install = async (packageFile = false) => { + const id = await installFromDisk(undefined, packageFile); if (id) { setTab("installed"); setSelectedId(id); @@ -563,7 +712,7 @@ export function PluginsPage() { const r = e.currentTarget.getBoundingClientRect(); showContextMenu( { x: r.left, y: r.bottom + 2 }, - gearMenu(plugins, () => void install()), + gearMenu(plugins, (packageFile) => void install(packageFile)), ); }; @@ -616,6 +765,15 @@ export function PluginsPage() { onSelect={() => setSelectedId(p.id)} /> ))} + {linked.length > 0 && <ListHeader title="Linked for development" count={linked.length} />} + {linked.map((p) => ( + <InstalledRow + key={p.id} + plugin={p} + selected={p.id === selected?.id} + onSelect={() => setSelectedId(p.id)} + /> + ))} {dev.length > 0 && <ListHeader title="Project (.openidea/plugins)" count={dev.length} />} {dev.map((p) => ( <InstalledRow @@ -633,17 +791,35 @@ export function PluginsPage() { </ul> ) : ( <ul className={styles.list} aria-label={tx("Available plugins")}> - <li className={styles.notice} role="note"> - <Icon name="info" size={14} /> - <span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span> + <li className={styles.marketItem}> + <MarketUrlBar /> </li> + {!marketUrl && ( + <li className={styles.notice} role="note"> + <Icon name="info" size={14} /> + <span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span> + </li> + )} + {marketError && <li className={clsx(styles.listEmpty, styles.errorText)}>{marketError}</li>} + {marketShown.length > 0 && <ListHeader title="Marketplace" count={marketShown.length} />} + {marketShown.map((m) => ( + <MarketRow + key={`market:${m.id}`} + entry={m} + selected={m.id === selectedMarket?.id} + onSelect={() => setSelectedMarketId(m.id)} + /> + ))} {catalogShown.length > 0 && <ListHeader title="Local" count={catalogShown.length} />} {catalogShown.map((c) => ( <CatalogRow key={c.id} entry={c} selected={c.id === selectedEntry?.id} - onSelect={() => setSelectedCatalogId(c.id)} + onSelect={() => { + setSelectedMarketId(null); + setSelectedCatalogId(c.id); + }} /> ))} {catalog && catalogShown.length === 0 && ( @@ -666,6 +842,15 @@ export function PluginsPage() { <T>inside your project.</T> </EmptyState> ) + ) : selectedMarket ? ( + <MarketDetails + key={`market:${selectedMarket.id}`} + entry={selectedMarket} + onInstalled={(id) => { + setTab("installed"); + setSelectedId(id); + }} + /> ) : selectedEntry ? ( <CatalogDetails key={selectedEntry.id} entry={selectedEntry} /> ) : ( diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 7e637357a..7dae1fc7e 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -5,28 +5,37 @@ import { EditorSelection, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import type { PluginInfo } from "@/bindings"; import { _setActiveEditor } from "@/editor/api"; -import { executeAction, getAction } from "@/platform/actions"; +import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions"; +import { useProject } from "@/platform/project"; import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme"; import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend"; import { applyPluginTheme, - applyTextToActiveEditor, PLUGIN_THEME_STYLE_ID, handlePluginEvent, pluginActionId, - pluginActions, startPluginBridge, } from "../bridge"; +import { iconResolver } from "../iconThemes"; +import { markerIcon, placeMarkers } from "../lineMarkerGutter"; +import { SecretsDetails } from "../secrets"; +import { usePluginStatusItems } from "../runtimeUi"; +import { useTasks } from "@/workbench/tasks"; +import { defineBuiltinUiPoints } from "../points"; +import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection"; import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage"; import { compareVersions, + installFromDisk, refreshPlugins, runPluginCommand, selectPluginTheme, setAllInstalledEnabled, + setConsentPrompt, setPluginEnabled, usePlugins, + type ConsentRequest, } from "../store"; type Note = Parameters<NotificationService["notify"]>[0]; @@ -48,10 +57,23 @@ function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): PluginIn status: "active", error: null, hasCode: true, - permissions: [], - commands: [], - languages: [], - themes: [], + capabilities: [], + contributions: [], + activation: [], + warnings: [], + stats: { + activations: 0, + activationMs: 0, + calls: 0, + errors: 0, + maxCallMs: 0, + p95CallMs: 0, + slowCalls: 0, + crashes: 0, + reclaims: 0, + }, + publisher: null, + previousVersion: null, ...partial, }; } @@ -63,13 +85,17 @@ function editor(doc: string, anchor: number, head = anchor, readOnly = false) { return view; } +let undefinePoints: () => void = () => {}; + beforeEach(() => { + undefinePoints = defineBuiltinUiPoints(); notes = []; provideNotificationService({ notify: (n) => void notes.push(n) }); backend = createMemoryPluginsBackend(); setPluginsBackend(backend); usePlugins.setState({ plugins: [], + ui: [], activeTheme: null, loaded: false, loading: false, @@ -82,6 +108,9 @@ beforeEach(() => { }); afterEach(() => { + clearUiContributions(); + undefinePoints(); + setConsentPrompt(null); setPluginsBackend(null); _setActiveEditor(null); view?.destroy(); @@ -90,31 +119,124 @@ afterEach(() => { }); describe("plugin actions", () => { - it("maps commands of running plugins to plugin.<plugin>.<command> actions", () => { - const actions = pluginActions([ - plugin({ id: "a", name: "Alpha", commands: [{ id: "go", title: "Alpha: Go", category: null }] }), - plugin({ id: "b", status: "disabled", commands: [{ id: "x", title: "X", category: "Cat" }] }), - plugin({ id: "c", status: "failed", commands: [{ id: "y", title: "Y", category: null }] }), - ]); - expect(actions.map((a) => a.id)).toEqual(["plugin.a.go"]); - expect(actions[0]!.category).toBe("Alpha"); + it("projects command contributions to plugin.<plugin>.<command> actions", () => { + const cmd = (pluginId: string, id: string, extra: Record<string, unknown> = {}) => ({ + pluginId, + point: "commands", + key: id, + decl: { id, title: `T ${id}`, ...extra }, + }); + syncUiContributions( + [cmd("a", "go"), cmd("a", "when", { when: "editorFocus", category: "Cat" }), cmd("gone", "x")], + [plugin({ id: "a", name: "Alpha" })], + ); + expect(getAction("plugin.a.go")).toMatchObject({ title: "T go", category: "Alpha" }); + expect(getAction("plugin.a.when")).toMatchObject({ when: "editorFocus", category: "Cat" }); + expect(getAction("plugin.gone.x")).toBeUndefined(); expect(pluginActionId("p", "c.d")).toBe("plugin.p.c.d"); + // Changed declarations are re-projected; removed ones disposed. + syncUiContributions([cmd("a", "go", { title: "Renamed" })], [plugin({ id: "a", name: "Alpha" })]); + expect(getAction("plugin.a.go")?.title).toBe("Renamed"); + expect(getAction("plugin.a.when")).toBeUndefined(); + }); + + it("isolates a failing projection (a defect) from the other items", () => { + const errors: unknown[] = []; + const original = console.error; + console.error = (...args: unknown[]) => void errors.push(args); + const live: string[] = []; + const undefine = defineUiPoint<{ n: number }>("test.items", { + project: ({ decl }) => { + if (decl.n === 2) throw new Error("bug"); + live.push(String(decl.n)); + return () => live.splice(live.indexOf(String(decl.n)), 1); + }, + }); + const item = (n: number) => ({ pluginId: "a", point: "test.items", key: String(n), decl: { n } }); + syncUiContributions([item(1), item(2), item(3)], [plugin({ id: "a" })]); + expect(live).toEqual(["1", "3"]); + expect(errors).toHaveLength(1); + undefine(); + expect(live).toEqual([]); + console.error = original; }); it("keeps the registry in sync and runs commands through the backend", async () => { + // The bridge defines the points itself (main window). + undefinePoints(); + undefinePoints = () => {}; const stop = startPluginBridge(); await refreshPlugins(); expect(getAction("plugin.hello.insert-date")?.title).toBe("Hello: Insert Date"); expect(getAction("plugin.hello.word-count")).toBeDefined(); - const v = editor("ab", 1); + // Edits come back as document patches; here the command's result shows it ran. + const ran: unknown[] = []; + const run = backend.runCommand; + backend.runCommand = async (...args) => { + const out = await run(...args); + ran.push(out); + return out; + }; + editor("ab", 1); await act(() => executeAction("plugin.hello.insert-date")); - expect(v.state.doc.toString()).toMatch(/^a\d{4}-\d{2}-\d{2}b$/); + expect(ran).toEqual([expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)]); await setPluginEnabled("hello", false); expect(getAction("plugin.hello.insert-date")).toBeUndefined(); stop(); }); + + it("reports actions to on-event:action-performed plugins once the project opens and when plugins change", async () => { + // Like `authorize_command`: refused until the window's project is open and trusted. + let kinds: Awaited<ReturnType<PluginsBackend["eventKinds"]>> = []; + const posted: unknown[] = []; + setPluginsBackend({ + ...backend, + eventKinds: async () => { + if (!useProject.getState().project?.trusted) throw new Error("no project is open in this window"); + return kinds; + }, + postEvents: async (events) => void posted.push(...events), + }); + const offAction = registerAction({ id: "test.columnMode", title: "Column Mode", run: () => {} }); + const performed = (n: number) => + Array.from({ length: n }, () => ({ + kind: "actionPerformed", + value: { action: "test.columnMode", title: "Column Mode", source: "menu", shortcut: null }, + })); + undefinePoints(); + undefinePoints = () => {}; + kinds = ["action-performed"]; + const stop = startPluginBridge(); + try { + // Asked at startup, before the project is open: refused. + await new Promise((r) => setTimeout(r, 20)); + await executeActionFrom("menu", "test.columnMode"); + await new Promise((r) => setTimeout(r, 600)); + expect(posted).toEqual([]); + + useProject.setState({ project: { root: "/p", trusted: true } as never }); + await new Promise((r) => setTimeout(r, 20)); + await executeActionFrom("menu", "test.columnMode"); + await waitFor(() => expect(posted).toEqual(performed(1))); + + // Declared kinds change with the installed plugins (`commandsChanged`). + kinds = []; + handlePluginEvent({ type: "commandsChanged" }); + await new Promise((r) => setTimeout(r, 60)); + await executeActionFrom("menu", "test.columnMode"); + kinds = ["action-performed"]; + handlePluginEvent({ type: "commandsChanged" }); + await new Promise((r) => setTimeout(r, 60)); + await executeActionFrom("menu", "test.columnMode"); + await waitFor(() => expect(posted).toEqual(performed(2))); + } finally { + stop(); + offAction(); + useProject.setState({ project: null }); + } + }); }); describe("store", () => { @@ -150,9 +272,26 @@ describe("store", () => { expect(usePlugins.getState().activeTheme).toBeNull(); }); - it("filters by name, id, author and command title", () => { + it("asks for consent before installing and passes the answer on", async () => { + const asked: ConsentRequest[] = []; + setConsentPrompt(async (req) => { + asked.push(req); + return { optional: [] }; + }); + setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog())); + expect(await installFromDisk("plugins/hello")).toBe("hello"); + expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install", unsigned: true }); + expect(asked[0]!.capabilities.map((c) => c.id)).toContain("editor.write"); + + setConsentPrompt(async () => null); + setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog())); + expect(await installFromDisk("plugins/hello")).toBeNull(); + expect(usePlugins.getState().plugins).toEqual([]); + }); + + it("filters by name, id, author and contribution", () => { const list = [ - plugin({ id: "a", name: "Alpha", commands: [{ id: "c", title: "Count Words", category: null }] }), + plugin({ id: "a", name: "Alpha", contributions: [{ point: "commands", label: "Count Words" }] }), plugin({ id: "b", name: "Beta", authors: ["Zed"] }), ]; expect(filterPlugins(list, "count").map((p) => p.id)).toEqual(["a"]); @@ -199,22 +338,44 @@ describe("bridge events", () => { useThemeOverride.setState({ theme: null }); }); - it("inserts at the cursor and replaces selections", () => { - const v = editor("hello world", 6, 11); - handlePluginEvent({ type: "replaceSelection", pluginId: "p", text: "there" }); - expect(v.state.doc.toString()).toBe("hello there"); - expect(v.state.selection.main).toMatchObject({ from: 6, to: 11 }); - handlePluginEvent({ type: "insertText", pluginId: "p", text: "!" }); - expect(v.state.doc.toString()).toBe("hello !"); - expect(v.state.selection.main.head).toBe(7); + it("runs commands with the focused editor's path, language and UTF-16 selections", async () => { + const calls: unknown[] = []; + setPluginsBackend({ + ...backend, + runCommand: async (...args) => { + calls.push(args); + return null; + }, + }); + editor("ab\n😀cd", 3, 7); + await runPluginCommand("p", "c"); + expect(calls).toEqual([ + [ + "p", + "c", + null, + { + path: "/p/a.txt", + language: "plaintext", + selections: [{ start: { line: 1, col: 0 }, end: { line: 1, col: 4 } }], + }, + ], + ]); }); - it("warns instead of editing read-only or missing editors", () => { - expect(applyTextToActiveEditor("x", false, "p")).toBe(false); - const v = editor("abc", 0, 0, true); - expect(applyTextToActiveEditor("x", false, "p")).toBe(false); - expect(v.state.doc.toString()).toBe("abc"); - expect(notes.map((n) => n.level)).toEqual(["warning", "warning"]); + it("shows status items and tasks of running plugin code", () => { + const item = { text: "main ↑1", tooltip: "Branch", command: "refresh" }; + handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item }); + expect(usePluginStatusItems.getState().items.get("git/branch")).toEqual({ pluginId: "git", item }); + handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item: null }); + expect(usePluginStatusItems.getState().items.size).toBe(0); + + handlePluginEvent({ type: "taskStarted", pluginId: "git", taskId: "7", title: "Fetching", cancellable: true }); + handlePluginEvent({ type: "taskProgress", pluginId: "git", taskId: "7", done: 1, total: 4, message: "origin" }); + const task = useTasks.getState().tasks.find((t) => t.id === "plugin:7")!; + expect([task.title, task.fraction, task.message, !!task.cancel]).toEqual(["Fetching", 0.25, "origin", true]); + handlePluginEvent({ type: "taskEnded", pluginId: "git", taskId: "7" }); + expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true); }); it("shows plugin notifications and tracks theme changes", () => { @@ -235,11 +396,12 @@ describe("PluginsPage", () => { // Sorted as returned; first one selected by default. expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Hello"); expect(screen.getByText("plugin.hello.insert-date")).toBeInTheDocument(); - expect(screen.getByText("Insert text into the active editor")).toBeInTheDocument(); + expect(screen.getByText("Change text in the editor")).toBeInTheDocument(); + expect(screen.getByText("Medium")).toBeInTheDocument(); await user.click(screen.getByRole("checkbox", { name: "Disable Hello" })); await waitFor(() => expect(screen.getByRole("checkbox", { name: "Enable Hello" })).toBeInTheDocument()); - expect(screen.getByRole("button", { name: "Run Hello: Insert Date" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Run Hello: Insert Date" })).not.toBeInTheDocument(); await user.click(within(list).getByText("Harbor Night Theme")); await user.click(screen.getByRole("button", { name: "Apply theme Harbor Night" })); @@ -248,7 +410,7 @@ describe("PluginsPage", () => { await user.type(screen.getByLabelText("Filter plugins"), "toy"); await waitFor(() => expect(within(list).getAllByRole("listitem")).toHaveLength(1)); expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Toy Language"); - expect(screen.getByText(".toy")).toBeInTheDocument(); + expect(screen.getByText("Toy (.toy)")).toBeInTheDocument(); }); it("restarts a failed plugin instead of disabling it", async () => { @@ -256,7 +418,7 @@ describe("PluginsPage", () => { const user = userEvent.setup(); render(<PluginsPage />); await user.click(await screen.findByRole("button", { name: "Restart" })); - await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "active" })); + await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "inactive" })); }); it("uninstalls after confirmation", async () => { @@ -299,6 +461,140 @@ describe("PluginsPage", () => { }); }); +describe("secrets", () => { + it("stores and removes a declared secret without showing its value", async () => { + const user = userEvent.setup(); + const items = [{ pluginId: "ai", point: "secrets", key: "api-key", decl: { name: "api-key", label: "API key" } }]; + render(<SecretsDetails plugin={plugin({ id: "ai" })} items={items} />); + expect(await screen.findByText("Not set")).toBeInTheDocument(); + await user.type(screen.getByLabelText("Value of API key"), "sk-1{Enter}"); + await waitFor(() => expect(screen.getByText("Stored")).toBeInTheDocument()); + expect(screen.getByLabelText("Value of API key")).toHaveValue(""); + expect(await backend.secrets("ai")).toEqual([{ name: "api-key", set: true }]); + await user.click(screen.getByRole("button", { name: "Remove API key" })); + await waitFor(() => expect(screen.getByText("Not set")).toBeInTheDocument()); + expect(await backend.secrets("ai")).toEqual([]); + }); +}); + +describe("plugin line markers", () => { + it("places markers on existing lines and maps their icons", () => { + const doc = EditorState.create({ doc: "a\nbb\nccc" }).doc; + const marker = (line: number, icon: string | null) => ({ pluginId: "m", line, icon, tooltip: "t", target: null }); + expect(placeMarkers(doc, [marker(1, "run"), marker(7, null)]).map((m) => m.pos)).toEqual([2]); + expect(markerIcon("run")).toBe("gutter-run"); + expect(markerIcon("implemented")).toBe("gutter-implemented"); + expect(markerIcon(null)).toBe("gutter-implementing"); + expect(markerIcon("unknown")).toBe("gutter-implementing"); + }); +}); + +describe("marketplace", () => { + it("lists the index's plugins and installs one through the install page", async () => { + const asked: ConsentRequest[] = []; + setConsentPrompt(async (req) => { + asked.push(req); + return { optional: [] }; + }); + const base = createMemoryPluginsBackend([], sampleCatalog()); + const downloads: string[] = []; + setPluginsBackend({ + ...base, + marketUrl: async () => "https://plugins.example/index.json", + market: async () => [ + { + id: "hello", + name: "Hello", + version: "1.0.0", + description: "From the marketplace", + authors: [], + publisher: "acme", + download: "hello.oiplugin", + digest: "x", + capabilities: ["editor.write"], + installed: null, + }, + ], + // The memory backend installs its sample packages by path. + marketDownload: async (id) => { + downloads.push(id); + return "plugins/hello"; + }, + }); + const user = userEvent.setup(); + render(<PluginsPage />); + await user.click(screen.getByRole("tab", { name: /Available/ })); + const list = await screen.findByRole("list", { name: "Available plugins" }); + await waitFor(() => expect(within(list).getByText("Marketplace")).toBeInTheDocument()); + expect(within(list).queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument(); + await user.click(within(list).getAllByText("Hello")[0]!); + expect(screen.getByText("Signed by acme")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Install" })); + await waitFor(() => expect(usePlugins.getState().plugins.map((p) => p.id)).toEqual(["hello"])); + expect(downloads).toEqual(["hello"]); + expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install" }); + }); + + it("offers to roll back an update", async () => { + const base = createMemoryPluginsBackend([ + plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }), + ]); + const rolled: string[] = []; + setPluginsBackend({ + ...base, + rollBack: async (id) => { + rolled.push(id); + return plugin({ id, name: "P", version: "1.0.0" }); + }, + }); + const user = userEvent.setup(); + render(<PluginsPage />); + expect(await screen.findByText("Unsigned")).toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: "Roll Back to 1.0.0" })); + await waitFor(() => expect(rolled).toEqual(["p"])); + expect(notes.at(-1)).toMatchObject({ title: "Rolled back P to 1.0.0" }); + }); +}); + +describe("icon themes", () => { + it("resolves file names, then the longest extension, then the default icon", () => { + const theme = iconResolver( + { + file: "file", + fileNames: { "cargo.toml": "cargo" }, + fileExtensions: { "d.ts": "dts", ts: "ts", toml: "toml" }, + }, + (icon) => `ext:p/t/${icon}`, + (name) => name !== "ext:p/t/toml", + ); + expect(theme.file("Cargo.toml")).toBe("ext:p/t/cargo"); + expect(theme.file("a.d.ts")).toBe("ext:p/t/dts"); + expect(theme.file("a.ts")).toBe("ext:p/t/ts"); + expect(theme.file("x.toml")).toBe("ext:p/t/file"); + // No folder icons in this theme: folders keep the built-in ones. + expect(theme.folder("src", false)).toBeNull(); + }); + + it("resolves folders by name, then the default (expanded) folder icon", () => { + const theme = iconResolver( + { + file: null, + folder: "folder", + folderExpanded: "folder-open", + fileNames: {}, + fileExtensions: {}, + folderNames: { src: "folder-src" }, + }, + (icon) => `ext:p/t/${icon}`, + () => true, + ); + expect(theme.folder("SRC", true)).toBe("ext:p/t/folder-src"); + expect(theme.folder("lib", false)).toBe("ext:p/t/folder"); + expect(theme.folder("lib", true)).toBe("ext:p/t/folder-open"); + expect(theme.file("a.txt")).toBeNull(); + }); +}); + describe("catalog helpers", () => { it("compares versions numerically", () => { expect(compareVersions("1.10.0", "1.9.2")).toBe(1); diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx new file mode 100644 index 000000000..a78fce47e --- /dev/null +++ b/src/features/plugins/__tests__/views.test.tsx @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { View, ViewEvent } from "@/bindings"; +import { registerAction } from "@/platform/actions"; +import { bindingsFor, effectiveBindings, registerExtensionBindings, useKeymap } from "@/platform/keymap"; +import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend"; +import { defineBuiltinUiPoints } from "../points"; +import { clearUiContributions, syncUiContributions } from "../projection"; +import { pluginMenuItems, withPluginSetting } from "../uiPoints"; +import { matchesPattern } from "../views/customEditor"; +import { FRAME_CSP, frameDocument } from "../views/frame"; +import { PluginView, treeNodes, wantedViewport } from "../views/PluginView"; +import { bumpView } from "../views/viewStore"; + +let undefinePoints: () => void = () => {}; + +beforeEach(() => { + undefinePoints = defineBuiltinUiPoints(); +}); + +afterEach(() => { + clearUiContributions(); + undefinePoints(); + setPluginsBackend(null); +}); + +function backendWith(render: (view: string) => View, events: ViewEvent[] = []): PluginsBackend { + const base = createMemoryPluginsBackend([]); + return { + ...base, + renderView: vi.fn(async (_p: string, view: string) => render(view)), + viewEvent: vi.fn(async (_p: string, _v: string, e: ViewEvent) => void events.push(e)), + }; +} + +describe("view helpers", () => { + it("builds the tree from the flat item list", () => { + const item = (key: string, parent: string | null = null) => ({ + key, + parent, + label: key, + icon: null, + detail: null, + expandable: false, + expanded: false, + }); + const roots = treeNodes([item("a"), item("a/1", "a"), item("b"), item("x", "missing")]); + expect(roots.map((n) => n.item.key)).toEqual(["a", "b", "x"]); + expect(roots[0]!.children.map((n) => n.item.key)).toEqual(["a/1"]); + }); + + it("asks for the visible table rows plus some around them", () => { + expect(wantedViewport(0, 20, 10)).toEqual({ firstRow: 0, rows: 10 }); + expect(wantedViewport(1000, 20, 5000)).toEqual({ firstRow: 950, rows: 120 }); + }); + + it("confines HTML bodies: CSP without network, a message bridge", () => { + const doc = frameDocument("<p>hi</p>", true); + expect(doc).toContain(`content="${FRAME_CSP}"`); + expect(FRAME_CSP).toContain("default-src 'none'"); + expect(FRAME_CSP).not.toMatch(/https?:|connect-src/); + expect(doc).toContain("window.openidea = Object.freeze"); + expect(doc.indexOf("Content-Security-Policy")).toBeLessThan(doc.indexOf("<p>hi</p>")); + }); + + it("matches custom editor file name patterns", () => { + expect(matchesPattern("*.csv", "People.CSV")).toBe(true); + expect(matchesPattern("*.{csv,tsv}", "a.tsv")).toBe(true); + expect(matchesPattern("data?.csv", "data1.csv")).toBe(true); + expect(matchesPattern("*.csv", "a.csv.bak")).toBe(false); + }); + + it("stores plugin settings under the plugin's id, dropping defaults", () => { + const one = withPluginSetting(undefined, "p.x", "watch", false); + expect(one).toEqual({ "p.x": { watch: false } }); + expect(withPluginSetting(one, "p.x", "watch", null)).toEqual({}); + }); +}); + +describe("plugin keymaps", () => { + afterEach(() => useKeymap.setState({ extensions: {}, overrides: {} })); + + it("adds a plugin's shortcut below the IDE's, never taking a used keystroke", () => { + const taken = effectiveBindings()[0]!; + const offFree = registerExtensionBindings("plugin.p.free", ["Ctrl+Alt+Shift+F11"]); + const offTaken = registerExtensionBindings("plugin.p.taken", [taken[0]]); + expect(bindingsFor("plugin.p.free")).toEqual(["Ctrl+Alt+Shift+F11"]); + expect(bindingsFor("plugin.p.taken")).toEqual([]); + useKeymap.setState({ overrides: { "plugin.p.free": [] } }); + expect(bindingsFor("plugin.p.free")).toEqual([]); + offFree(); + offTaken(); + expect(useKeymap.getState().extensions).toEqual({}); + }); +}); + +describe("plugin menus", () => { + it("projects context menu entries for their command's action", () => { + const off = registerAction({ id: "plugin.p.check", title: "Check", run: () => {} }); + const plugin = { + id: "p", + name: "P", + status: "active", + enabled: true, + } as unknown as Parameters<typeof syncUiContributions>[1][number]; + syncUiContributions( + [ + { + pluginId: "p", + point: "menus", + key: "projectView.context/check", + decl: { menu: "projectView.context", command: "check" }, + }, + { + pluginId: "p", + point: "menus", + key: "editor.context/check", + decl: { menu: "editor.context", command: "check", when: "false" }, + }, + ], + [plugin], + ); + expect(pluginMenuItems("projectView", ["/p/a.java"])).toEqual([ + { kind: "action", action: "plugin.p.check", arg: "/p/a.java" }, + ]); + expect(pluginMenuItems("editor", ["/p/a.java"])).toEqual([]); + off(); + }); +}); + +describe("PluginView", () => { + it("renders a tree and sends expand / activate", async () => { + const events: ViewEvent[] = []; + const tree: View = { + toolbar: [{ id: "refresh", label: "Refresh", icon: "refresh", enabled: true }], + body: { + kind: "tree", + value: [ + { + key: "ns", + parent: null, + label: "default", + icon: null, + detail: "3 pods", + expandable: true, + expanded: false, + }, + ], + }, + status: "1 namespace", + }; + setPluginsBackend(backendWith(() => tree, events)); + const user = userEvent.setup(); + render(<PluginView pluginId="k8s" view="clusters" label="Kubernetes" />); + expect(await screen.findByText("default")).toBeInTheDocument(); + expect(screen.getByText("1 namespace")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(events).toContainEqual({ kind: "button", id: "refresh" })); + }); + + it("edits a table cell and renders again when the plugin refreshes", async () => { + const events: ViewEvent[] = []; + let city = "London"; + const table = (): View => ({ + toolbar: [], + body: { + kind: "table", + value: { columns: ["name", "city"], totalRows: 1, firstRow: 0, rows: [["Ada", city]], editable: true }, + }, + status: null, + }); + const backend = backendWith(table, events); + setPluginsBackend(backend); + const user = userEvent.setup(); + render(<PluginView pluginId="csv" view="table" doc={7} />); + await user.dblClick(await screen.findByText("London")); + const input = screen.getByLabelText("Cell value"); + await user.clear(input); + await user.type(input, "Paris{Enter}"); + await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" })); + expect(backend.viewEvent).toHaveBeenCalledWith("csv", "table", expect.anything(), 7); + city = "Paris"; + bumpView("csv", "table"); + expect(await screen.findByText("Paris")).toBeInTheDocument(); + }); + + it("shows a message when the plugin fails", async () => { + const base = createMemoryPluginsBackend([]); + setPluginsBackend({ ...base, renderView: () => Promise.reject(new Error("boom")) }); + render(<PluginView pluginId="x" view="v" />); + expect(await screen.findByText("boom")).toBeInTheDocument(); + }); +}); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 4030550d3..399225e48 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -4,26 +4,84 @@ * development and tests. */ import { Channel } from "@tauri-apps/api/core"; -import type { ActiveTheme, CatalogPlugin, PluginEvent, PluginInfo, PluginsSnapshot } from "@/bindings"; -import { tx } from "@/platform/i18n"; +import type { + ActiveTheme, + CapabilityInfo, + CatalogPlugin, + Consent, + EventKind, + InstallPreview, + MarketPlugin, + PluginEditor, + PluginEvent, + PluginInfo, + PluginsSnapshot, + PluginSecretState, + PluginStatusItem, + UiContribution, + View, + ViewEvent, + Viewport, + WorkbenchEvent, +} from "@/bindings"; import { call, commands, IpcError, isTauri } from "@/platform/ipc"; export interface PluginsBackend { list(): Promise<PluginsSnapshot>; setEnabled(id: string, enabled: boolean): Promise<void>; - installDir(path: string): Promise<PluginInfo>; + /** Validates a package for the install page; nothing is copied. */ + previewInstall(path: string): Promise<InstallPreview>; + installDir(path: string, consent: Consent): Promise<PluginInfo>; + /** Confirms a plugin waiting for consent. */ + grant(id: string, consent: Consent): Promise<PluginInfo>; + setCapability(id: string, capability: string, granted: boolean): Promise<PluginInfo>; + /** Validated assets (icon theme mapping and SVGs) whose id starts with `prefix`. */ + assets(pluginId: string, prefix: string): Promise<[string, string][]>; + /** `language` / `file` / `view` activation events. */ + activationEvent(kind: "language" | "file" | "view", value: string): Promise<void>; /** Plugin packages available locally (no network marketplace). */ catalog(): Promise<CatalogPlugin[]>; uninstall(id: string): Promise<void>; reload(id: string): Promise<PluginInfo>; - runCommand(pluginId: string, commandId: string, arg: string | null): Promise<string | null>; + /** Runs a command with the focused editor (path, language, selections) as its context. */ + runCommand( + pluginId: string, + commandId: string, + arg: string | null, + editor: PluginEditor | null, + ): Promise<string | null>; + /** Workbench events plugins subscribed to (see `eventKinds`). */ + postEvents(events: WorkbenchEvent[]): Promise<void>; + /** Kinds of workbench events plugins want; re-fetch on `subscriptionsChanged`. */ + eventKinds(): Promise<EventKind[]>; + /** Plugin status bar items (later changes arrive as `statusItemChanged`). */ + statusItems(): Promise<PluginStatusItem[]>; + cancelTask(taskId: string): Promise<void>; + /** Marketplace plugins: the index read again (`refresh`) or the last one read. */ + market(refresh: boolean): Promise<MarketPlugin[]>; + marketUrl(): Promise<string | null>; + setMarketUrl(url: string | null): Promise<void>; + /** Downloads and checks a marketplace plugin; the folder to install from. */ + marketDownload(id: string): Promise<string>; + /** Unpacks a `.oiplugin` package file; the folder to install from. */ + unpackPackage(path: string): Promise<string>; + /** Goes back to the version the last update replaced. */ + rollBack(id: string): Promise<PluginInfo>; + /** File picker for a package file; `null` when cancelled. */ + pickPackage(): Promise<string | null>; + /** Content of a plugin view (`doc`: the document of a custom editor). */ + renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>; + viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>; + /** Which of a plugin's declared secrets are stored (values never come back). */ + secrets(pluginId: string): Promise<PluginSecretState[]>; + /** Stores a secret (`null` removes it). */ + setSecret(pluginId: string, name: string, value: string | null): Promise<void>; setActiveFile(path: string | null): Promise<void>; setTheme(pluginId: string | null, themeId: string | null): Promise<ActiveTheme | null>; /** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */ setThemeFrame(color: string | null): Promise<void>; /** Folder picker; `null` when cancelled. */ - /** A package to install: a `.zip`, or the `plugin.toml` of a folder. */ - pickPackage(): Promise<string | null>; + pickFolder(): Promise<string | null>; /** Subscribes to backend plugin events (this window only). */ onEvent(handler: (e: PluginEvent) => void): () => void; } @@ -33,13 +91,53 @@ export const tauriPluginsBackend: PluginsBackend = { setEnabled: async (id, enabled) => { await call(commands.pluginSetEnabled(id, enabled)); }, - installDir: (path) => call(commands.pluginInstallDir(path)), + previewInstall: (path) => call(commands.pluginPreviewInstall(path)), + installDir: (path, consent) => call(commands.pluginInstallDir(path, consent)), + grant: (id, consent) => call(commands.pluginGrant(id, consent)), + setCapability: (id, capability, granted) => call(commands.pluginSetCapability(id, capability, granted)), + assets: (pluginId, prefix) => call(commands.pluginAssets(pluginId, prefix)), + activationEvent: async (kind, value) => { + await call(commands.pluginActivationEvent(kind, value)); + }, catalog: () => call(commands.pluginCatalog()), uninstall: async (id) => { await call(commands.pluginUninstall(id)); }, reload: (id) => call(commands.pluginReload(id)), - runCommand: (pluginId, commandId, arg) => call(commands.pluginRunCommand(pluginId, commandId, arg)), + runCommand: (pluginId, commandId, arg, editor) => call(commands.pluginRunCommand(pluginId, commandId, arg, editor)), + postEvents: async (events) => { + await commands.pluginPostEvents(events); + }, + eventKinds: () => commands.pluginEventKinds(), + statusItems: () => commands.pluginStatusItems(), + cancelTask: async (taskId) => { + await commands.pluginCancelTask(taskId); + }, + market: (refresh) => call(commands.pluginMarket(refresh)), + marketUrl: () => commands.pluginMarketUrl(), + setMarketUrl: async (url) => { + await commands.pluginSetMarketUrl(url); + }, + marketDownload: (id) => call(commands.pluginMarketDownload(id)), + unpackPackage: (path) => call(commands.pluginUnpackPackage(path)), + rollBack: (id) => call(commands.pluginRollBack(id)), + pickPackage: async () => { + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ + multiple: false, + title: "Install Plugin from Package File", + filters: [{ name: "OpenIDEA plugin", extensions: ["oiplugin"] }], + }); + return (Array.isArray(picked) ? picked[0] : picked) ?? null; + }, + renderView: (pluginId, view, viewport, doc) => call(commands.pluginViewRender(pluginId, view, viewport, doc)), + viewEvent: async (pluginId, view, event, doc) => { + await call(commands.pluginViewEvent(pluginId, view, event, doc)); + }, + secrets: (pluginId) => call(commands.pluginSecrets(pluginId)), + setSecret: async (pluginId, name, value) => { + await call(commands.pluginSetSecret(pluginId, name, value)); + }, setActiveFile: async (path) => { await commands.pluginSetActiveFile(path); }, @@ -47,13 +145,9 @@ export const tauriPluginsBackend: PluginsBackend = { setThemeFrame: async (color) => { await call(commands.pluginSetThemeFrame(color)); }, - pickPackage: async () => { + pickFolder: async () => { const { open } = await import("@tauri-apps/plugin-dialog"); - const picked = await open({ - multiple: false, - title: tx("Install Plugin from Disk"), - filters: [{ name: tx("Plugin package"), extensions: ["zip", "toml"] }], - }); + const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" }); return (Array.isArray(picked) ? picked[0] : picked) ?? null; }, onEvent: (handler) => { @@ -66,7 +160,7 @@ export const tauriPluginsBackend: PluginsBackend = { const tauriListeners = new Set<(e: PluginEvent) => void>(); let subscribed = false; -/** One channel per window; the backend addresses insert-text requests to the invoking window. */ +/** One channel per window; the backend addresses notifications and consent requests to the invoking window. */ function ensureSubscribed() { if (subscribed) return; subscribed = true; @@ -101,6 +195,27 @@ const SAMPLE_THEMES: ActiveTheme["theme"][] = [ }, ]; +const cap = (id: string, risk: CapabilityInfo["risk"], description: string): CapabilityInfo => ({ + id, + risk, + description, + optional: false, + granted: true, +}); + +const HELLO_COMMANDS = [ + { id: "insert-date", title: "Hello: Insert Date", category: "Hello" }, + { id: "word-count", title: "Hello: Word Count of Active File", category: "Hello" }, +]; + +/** UI contributions of the sample plugins (what the backend would project). */ +function sampleUi(id: string): UiContribution[] { + if (id === "hello") return HELLO_COMMANDS.map((c) => ({ pluginId: id, point: "commands", key: c.id, decl: c })); + if (id === "theme-harbor-night") + return SAMPLE_THEMES.map((t) => ({ pluginId: id, point: "themes", key: t.id, decl: t })); + return []; +} + function samplePlugins(): PluginInfo[] { const base = { authors: ["OpenIDEA contributors"], @@ -109,10 +224,23 @@ function samplePlugins(): PluginInfo[] { source: "installed" as const, enabled: true, error: null, - languages: [], - themes: [], - commands: [], - permissions: [], + capabilities: [], + contributions: [], + activation: [], + warnings: [], + stats: { + activations: 0, + activationMs: 0, + calls: 0, + errors: 0, + maxCallMs: 0, + p95CallMs: 0, + slowCalls: 0, + crashes: 0, + reclaims: 0, + }, + publisher: null, + previousVersion: null, }; return [ { @@ -122,13 +250,15 @@ function samplePlugins(): PluginInfo[] { version: "0.1.0", description: "Sample plugin: inserts today's date and counts words in the active file.", path: "~/Library/Application Support/ai.l0ng.openidea/plugins/hello", - status: "active", + status: "inactive", hasCode: true, - permissions: ["project-read", "editor-read", "editor-write"], - commands: [ - { id: "insert-date", title: "Hello: Insert Date", category: "Hello" }, - { id: "word-count", title: "Hello: Word Count of Active File", category: "Hello" }, + activation: ["on-startup-finished"], + capabilities: [ + cap("editor.write", "medium", "Change text in the editor"), + cap("project.read", "low", "Read files in the open project"), + cap("editor.read", "low", "Read the focused editor's file and selection"), ], + contributions: HELLO_COMMANDS.map((c) => ({ point: "commands", label: c.title })), }, { ...base, @@ -139,7 +269,7 @@ function samplePlugins(): PluginInfo[] { path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-harbor-night", status: "static", hasCode: false, - themes: SAMPLE_THEMES.map(({ id, name, kind }) => ({ id, name, kind })), + contributions: SAMPLE_THEMES.map((t) => ({ point: "themes", label: t.name })), }, { ...base, @@ -150,31 +280,35 @@ function samplePlugins(): PluginInfo[] { path: "~/Library/Application Support/ai.l0ng.openidea/plugins/language-toy", status: "static", hasCode: false, - languages: [{ id: "toy", name: "Toy", extensions: ["toy"] }], + contributions: [{ point: "languages", label: "Toy (.toy)" }], }, ]; } /** The sample packages as a local catalog (the desktop app scans `plugins/`). */ export function sampleCatalog(): CatalogPlugin[] { - return samplePlugins().map((p) => ({ - id: p.id, - name: p.name, - version: p.version, - description: p.description, - authors: p.authors, - homepage: p.homepage, - changeNotes: `${p.version} — first release.`, - path: `plugins/${p.id}`, - hasCode: p.hasCode, - permissions: p.permissions, - commands: p.commands, - languageCount: p.languages.length, - themeCount: p.themes.length, - problem: null, - })); + return samplePlugins().map((p) => { + const counts = new Map<string, number>(); + for (const c of p.contributions) counts.set(c.point, (counts.get(c.point) ?? 0) + 1); + return { + id: p.id, + name: p.name, + version: p.version, + description: p.description, + authors: p.authors, + homepage: p.homepage, + changeNotes: `${p.version} — first release.`, + path: `plugins/${p.id}`, + hasCode: p.hasCode, + capabilities: p.capabilities.map((c) => ({ ...c, granted: false })), + contributions: [...counts].map(([point, count]) => ({ point, count })), + problem: null, + }; + }); } +const live = (p: PluginInfo) => p.status === "active" || p.status === "inactive" || p.status === "static"; + export function createMemoryPluginsBackend( initial: PluginInfo[] = samplePlugins(), catalog: CatalogPlugin[] = sampleCatalog(), @@ -183,6 +317,8 @@ export function createMemoryPluginsBackend( const templates = samplePlugins(); let theme: { pluginId: string; themeId: string } | null = null; let activeFile: string | null = null; + const secrets = new Map<string, string>(); + let marketUrl: string | null = null; const listeners = new Set<(e: PluginEvent) => void>(); const emit = (e: PluginEvent) => listeners.forEach((l) => l(e)); const find = (id: string) => { @@ -197,16 +333,36 @@ export function createMemoryPluginsBackend( if (!p?.enabled || !t) return null; return { pluginId: p.id, theme: t }; }; + const ui = () => plugins.filter(live).flatMap((p) => sampleUi(p.id)); + const preview = (path: string): InstallPreview => { + const entry = catalog.find((c) => c.path === path); + if (!entry) throw new IpcError({ kind: "unavailable", message: `installing ${path} requires the desktop app` }); + return { + id: entry.id, + name: entry.name, + version: entry.version, + description: entry.description, + authors: entry.authors, + hasCode: entry.hasCode, + capabilities: entry.capabilities, + contributions: entry.contributions, + warnings: [], + replaces: plugins.find((p) => p.id === entry.id)?.version ?? null, + unsigned: true, + publisher: null, + }; + }; return { - list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme() }), + list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme(), ui: ui() }), setEnabled: async (id, enabled) => { const p = find(id); p.enabled = enabled; - p.status = enabled ? (p.hasCode ? "active" : "static") : "disabled"; + p.status = enabled ? (p.hasCode ? "inactive" : "static") : "disabled"; p.error = null; emit({ type: "commandsChanged" }); if (theme?.pluginId === id) emit({ type: "themeChanged", theme: activeTheme() }); }, + previewInstall: async (path) => preview(path), installDir: async (path) => { // Only catalog packages can be "installed" here; there are no files in the browser. const entry = catalog.find((c) => c.path === path); @@ -219,6 +375,22 @@ export function createMemoryPluginsBackend( emit({ type: "commandsChanged" }); return { ...info }; }, + grant: async (id) => { + const p = find(id); + p.status = p.hasCode ? "inactive" : "static"; + p.capabilities = p.capabilities.map((c) => ({ ...c, granted: true })); + emit({ type: "commandsChanged" }); + return { ...p }; + }, + setCapability: async (id, capability, granted) => { + const p = find(id); + p.capabilities = p.capabilities.map((c) => (c.id === capability ? { ...c, granted } : c)); + if (!granted && p.capabilities.some((c) => c.id === capability && !c.optional)) p.status = "needsConsent"; + emit({ type: "commandsChanged" }); + return { ...p }; + }, + assets: async () => [], + activationEvent: async () => {}, catalog: async () => catalog.map((c) => ({ ...c })), uninstall: async (id) => { find(id); @@ -228,13 +400,10 @@ export function createMemoryPluginsBackend( reload: async (id) => find(id), runCommand: async (pluginId, commandId) => { const p = find(pluginId); - if (p.status !== "active") + if (p.status !== "active" && p.status !== "inactive") throw new IpcError({ kind: "unavailable", message: `plugin "${pluginId}" is not running` }); - if (commandId === "insert-date") { - const date = new Date().toISOString().slice(0, 10); - emit({ type: "insertText", pluginId, text: date }); - return date; - } + p.status = "active"; + if (commandId === "insert-date") return new Date().toISOString().slice(0, 10); if (commandId === "word-count") { if (!activeFile) throw new IpcError({ kind: "internal", message: "no file is open in the editor" }); const name = activeFile.split("/").pop() ?? activeFile; @@ -243,6 +412,39 @@ export function createMemoryPluginsBackend( } throw new IpcError({ kind: "internal", message: `unknown command: ${commandId}` }); }, + postEvents: async () => {}, + eventKinds: async () => [], + statusItems: async () => [], + cancelTask: async () => {}, + market: async () => [], + marketUrl: async () => marketUrl, + setMarketUrl: async (url) => { + marketUrl = url; + }, + marketDownload: async (id) => { + throw new IpcError({ kind: "unavailable", message: `installing ${id} requires the desktop app` }); + }, + unpackPackage: async (path) => { + throw new IpcError({ kind: "unavailable", message: `unpacking ${path} requires the desktop app` }); + }, + rollBack: async (id) => { + throw new IpcError({ kind: "notFound", message: `${id} has no earlier version to roll back to` }); + }, + pickPackage: async () => null, + renderView: async (pluginId, view) => ({ + toolbar: [], + body: { kind: "message", value: `${pluginId} · ${view} (plugin views need the desktop app)` }, + status: null, + }), + viewEvent: async () => {}, + secrets: async (pluginId) => + [...secrets.entries()] + .filter(([k]) => k.startsWith(`${pluginId}/`)) + .map(([k]) => ({ name: k.slice(pluginId.length + 1), set: true })), + setSecret: async (pluginId, name, value) => { + if (value === null) secrets.delete(`${pluginId}/${name}`); + else secrets.set(`${pluginId}/${name}`, value); + }, setActiveFile: async (path) => { activeFile = path; }, @@ -253,7 +455,7 @@ export function createMemoryPluginsBackend( return t; }, setThemeFrame: async () => {}, - pickPackage: async () => null, + pickFolder: async () => null, onEvent: (handler) => { listeners.add(handler); return () => void listeners.delete(handler); diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index a5580d7eb..46b11b079 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -1,66 +1,39 @@ /** - * Glue between backend plugins and the workbench: plugin commands become - * actions, editor requests are applied to the active editor, the selected - * theme becomes a stylesheet of CSS variables, notifications become balloons. + * Glue between backend plugins and the workbench: UI contributions are + * projected (`projection.ts` + `points.tsx`), status items, tasks and event + * subscriptions of running code go through `runtimeUi.tsx`, the selected + * theme becomes a stylesheet of CSS + * variables, notifications become balloons, consent requests open the + * permission dialog, and opened files fire `on-language` / `on-file`. */ -import { EditorSelection } from "@codemirror/state"; -import type { ActiveTheme, PluginEvent, PluginInfo } from "@/bindings"; -import { activeEditor, useEditorRegistry } from "@/editor/api"; -import { registerAction, type Action } from "@/platform/actions"; +import type { ActiveTheme, PluginEvent } from "@/bindings"; +import { useEditorRegistry } from "@/editor/api"; +import { refreshLanguageInfos } from "@/editor/core/cm/languages"; import { notifications } from "@/workbench/contributions"; import { applyThemeAttributes, currentThemeId, syncWindowBackground, windowBackgroundHex } from "@/workbench/theme"; +import { useProject } from "@/platform/project"; import { pluginsBackend } from "./backend"; -import { refreshPlugins, runPluginCommand, usePlugins } from "./store"; -import { tx } from "@/platform/i18n"; +import { trackIconTheme } from "./iconThemes"; +import { defineBuiltinUiPoints } from "./points"; +import { clearUiContributions, syncUiContributions } from "./projection"; +import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi"; +import { grantPlugin, refreshPlugins, usePlugins } from "./store"; -export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`; +export { pluginActionId } from "./points"; -/** Actions for every command of every running plugin. */ -export function pluginActions(plugins: readonly PluginInfo[]): Action[] { - return plugins - .filter((p) => p.status === "active") - .flatMap((p) => - p.commands.map((c): Action => ({ - id: pluginActionId(p.id, c.id), - title: c.title, - category: c.category ?? p.name, - icon: "package", - keywords: ["plugin", p.name, p.id], - description: tx`From plugin ${p.name}`, - run: async ({ arg }) => { - await runPluginCommand(p.id, c.id, typeof arg === "string" ? arg : null); - }, - })), - ); -} - -/** Keeps the action registry in sync with the plugin list; re-registers only changed actions. */ -export function syncPluginActions(): () => void { - const registered = new Map<string, { key: string; dispose: () => void }>(); - const apply = (plugins: readonly PluginInfo[]) => { - const next = new Map(pluginActions(plugins).map((a) => [a.id, a])); - for (const [id, entry] of registered) { - if (!next.has(id)) { - entry.dispose(); - registered.delete(id); - } - } - for (const [id, action] of next) { - const key = `${action.title}\u0000${action.category}`; - const prev = registered.get(id); - if (prev?.key === key) continue; - prev?.dispose(); - registered.set(id, { key, dispose: registerAction(action) }); - } +/** Keeps the projected UI contributions in line with the plugin list. */ +export function syncProjection(): () => void { + const apply = () => { + const s = usePlugins.getState(); + syncUiContributions(s.ui, s.plugins); }; - apply(usePlugins.getState().plugins); + apply(); const unsubscribe = usePlugins.subscribe((s, prev) => { - if (s.plugins !== prev.plugins) apply(s.plugins); + if (s.ui !== prev.ui || s.plugins !== prev.plugins) apply(); }); return () => { unsubscribe(); - registered.forEach((e) => e.dispose()); - registered.clear(); + clearUiContributions(); }; } @@ -174,35 +147,7 @@ export function trackPluginTheme(): () => void { }; } -// --- editor ----------------------------------------------------------------- - -/** Inserts (`select: false`) or replaces-and-selects (`select: true`) at every selection range. */ -export function applyTextToActiveEditor(text: string, select: boolean, pluginId: string): boolean { - const editor = activeEditor(); - const name = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId; - if (!editor || editor.ctx.readOnly) { - notifications().notify({ - group: "plugins", - level: "warning", - title: `${name}: ${editor ? "the editor is read-only" : "no editor to insert into"}`, - }); - return false; - } - const { view } = editor; - view.dispatch( - view.state.changeByRange((range) => ({ - changes: { from: range.from, to: range.to, insert: text }, - range: select - ? EditorSelection.range(range.from, range.from + text.length) - : EditorSelection.cursor(range.from + text.length), - })), - { scrollIntoView: true, userEvent: "input.plugin" }, - ); - view.focus(); - return true; -} - -/** Reports the focused editor's file to the backend (`active-file` for plugins). */ +/** Reports the focused editor's file to the backend (`editor-focused` events). */ function trackActiveFile(): () => void { let last: string | null | undefined; const report = (path: string | null) => { @@ -228,15 +173,17 @@ export function handlePluginEvent(e: PluginEvent) { notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined }); break; } - case "insertText": - applyTextToActiveEditor(e.text, false, e.pluginId); - break; - case "replaceSelection": - applyTextToActiveEditor(e.text, true, e.pluginId); - break; case "themeChanged": usePlugins.setState({ activeTheme: e.theme }); break; + case "consentRequested": + void refreshPlugins().then(() => grantPlugin(e.pluginId, e.capabilities)); + break; + case "languagesChanged": + void refreshLanguageInfos(); + break; + default: + handleRuntimeEvent(e); } } @@ -247,15 +194,47 @@ function scheduleRefresh() { refreshTimer = setTimeout(() => { refreshTimer = null; void refreshPlugins(); + refreshEventKinds(); }, 30); } +/** + * Fires `on-language:<id>` and `on-file:<project-relative path>` for files + * opened in the editor; the backend activates the plugins waiting for them. + */ +function trackActivationEvents(): () => void { + const seenLanguages = new Set<string>(); + const report = (ctx: { path: string | null; language: string } | undefined) => { + if (!ctx?.path) return; + if (!seenLanguages.has(ctx.language)) { + seenLanguages.add(ctx.language); + void pluginsBackend() + .activationEvent("language", ctx.language) + .catch(() => seenLanguages.delete(ctx.language)); + } + const root = useProject.getState().project?.root; + if (root && ctx.path.startsWith(`${root}/`)) { + void pluginsBackend() + .activationEvent("file", ctx.path.slice(root.length + 1)) + .catch(() => {}); + } + }; + report(useEditorRegistry.getState().active?.ctx); + return useEditorRegistry.subscribe((s, prev) => { + if (s.active?.ctx.path !== prev.active?.ctx.path) report(s.active?.ctx); + }); +} + export function startPluginBridge(): () => void { const disposers = [ + defineBuiltinUiPoints(), pluginsBackend().onEvent(handlePluginEvent), - syncPluginActions(), + syncProjection(), trackPluginTheme(), + trackIconTheme(), trackActiveFile(), + trackActivationEvents(), + startRuntimeUi(), ]; return () => { if (refreshTimer) clearTimeout(refreshTimer); diff --git a/src/features/plugins/consent.tsx b/src/features/plugins/consent.tsx new file mode 100644 index 000000000..f978e05a0 --- /dev/null +++ b/src/features/plugins/consent.tsx @@ -0,0 +1,141 @@ +/** + * The install / permission dialog (docs/plugin_plan.md 4.2, 4.6): every + * capability with its risk; optional ones can be refused; high-risk ones are + * called out as running code on this machine. + */ +import { useState } from "react"; +import { create } from "zustand"; +import type { CapabilityInfo, Consent } from "@/bindings"; +import { tx } from "@/platform/i18n"; +import { Badge, Checkbox, Dialog, T } from "@/ui"; +import { setConsentPrompt, type ConsentRequest } from "./store"; +import styles from "./PluginsPage.module.css"; + +interface Pending { + req: ConsentRequest; + resolve: (c: Consent | null) => void; +} + +const usePending = create<{ queue: Pending[] }>(() => ({ queue: [] })); + +export const RISK_LABEL: Record<CapabilityInfo["risk"], { label: string; tone: "neutral" | "warning" | "danger" }> = { + low: { label: "Low", tone: "neutral" }, + medium: { label: "Medium", tone: "warning" }, + high: { label: "High", tone: "danger" }, +}; + +const RISK_ORDER = { high: 0, medium: 1, low: 2 } as const; + +export function sortByRisk(caps: readonly CapabilityInfo[]): CapabilityInfo[] { + return [...caps].sort((a, b) => RISK_ORDER[a.risk] - RISK_ORDER[b.risk] || a.id.localeCompare(b.id)); +} + +function ask(req: ConsentRequest): Promise<Consent | null> { + return new Promise((resolve) => usePending.setState((s) => ({ queue: [...s.queue, { req, resolve }] }))); +} + +function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => void }) { + const { req } = pending; + const caps = sortByRisk(req.capabilities); + const optional = caps.filter((c) => c.optional); + const [accepted, setAccepted] = useState<Set<string>>( + () => new Set(optional.filter((c) => c.granted || c.risk === "low").map((c) => c.id)), + ); + const high = caps.some((c) => c.risk === "high" && (!c.optional || accepted.has(c.id))); + const finish = (consent: Consent | null) => { + pending.resolve(consent); + onDone(); + }; + const title = req.kind === "install" ? tx`Install ${req.name} ${req.version}?` : tx`Allow ${req.name} to run?`; + return ( + <Dialog + open + title={title} + icon={high ? "warning" : "question"} + width={520} + onClose={() => finish(null)} + buttons={[ + { label: tx("Cancel"), role: "cancel", onClick: () => finish(null) }, + { + label: req.kind === "install" ? tx("Install") : tx("Allow"), + role: "default", + onClick: () => finish({ optional: [...accepted] }), + }, + ]} + > + <div className={styles.consent}> + {req.unsigned && ( + <p className={styles.consentWarning} role="note"> + <T>This package is not signed. Only continue if you trust where it came from.</T> + </p> + )} + {caps.length === 0 ? ( + <p> + <T>This plugin needs no special permissions.</T> + </p> + ) : ( + <ul className={styles.items} aria-label={tx("Requested permissions")}> + {caps.map((c) => ( + <li key={c.id} className={styles.item}> + {c.optional ? ( + <Checkbox + checked={accepted.has(c.id)} + onChange={(on) => + setAccepted((s) => { + const next = new Set(s); + if (on) next.add(c.id); + else next.delete(c.id); + return next; + }) + } + aria-label={tx`Allow ${c.id}`} + /> + ) : ( + <span className={styles.consentRequired} title={tx("Required")}> + • + </span> + )} + <span className={styles.itemMain}>{c.description}</span> + <span className={styles.mono}>{c.id}</span> + <Badge tone={RISK_LABEL[c.risk].tone}>{RISK_LABEL[c.risk].label}</Badge> + </li> + ))} + </ul> + )} + {high && ( + <p className={styles.consentWarning} role="alert"> + <T>High-risk permissions are equivalent to running arbitrary code on this machine.</T> + </p> + )} + {req.warnings.map((w) => ( + <p key={w} className={styles.muted}> + {w} + </p> + ))} + </div> + </Dialog> + ); +} + +/** Overlay rendering the queued consent dialogs one at a time. */ +export function ConsentDialogHost() { + const first = usePending((s) => s.queue[0]); + if (!first) return null; + return ( + <ConsentDialog + key={first.req.pluginId + first.req.kind} + pending={first} + onDone={() => usePending.setState((s) => ({ queue: s.queue.slice(1) }))} + /> + ); +} + +/** Routes the store's consent requests to the dialog. */ +export function provideConsentDialog(): () => void { + setConsentPrompt(ask); + return () => { + setConsentPrompt(null); + for (const p of usePending.getState().queue) p.resolve(null); + usePending.setState({ queue: [] }); + }; +} diff --git a/src/features/plugins/iconThemes.ts b/src/features/plugins/iconThemes.ts new file mode 100644 index 000000000..198297f02 --- /dev/null +++ b/src/features/plugins/iconThemes.ts @@ -0,0 +1,170 @@ +/** + * File icon themes contributed by plugins (`iconThemes`). The selection is a + * per-user preference kept in this browser profile; the mapping and the + * sanitised SVGs are fetched from the backend only when a theme is selected, + * so installed icon themes cost nothing at start-up. + */ +import { create } from "zustand"; +import { registerExternalIcons, setFileIconTheme, type FileIconTheme } from "@/ui"; +import { pluginsBackend } from "./backend"; + +export const ICON_THEME_KEY = "openidea.fileIconTheme"; + +export interface IconThemeRef { + pluginId: string; + id: string; + label: string; +} + +export interface IconThemeMap { + file: string | null; + folder?: string | null; + folderExpanded?: string | null; + fileExtensions: Record<string, string>; + fileNames: Record<string, string>; + folderNames?: Record<string, string>; +} + +interface IconThemesState { + /** `<pluginId>/<themeId>` → theme. */ + available: ReadonlyMap<string, IconThemeRef>; + /** `<pluginId>/<themeId>` or null for the built-in icons. */ + selected: string | null; + /** The theme whose icons are registered. */ + applied: string | null; +} + +const readSelection = (): string | null => { + try { + return localStorage.getItem(ICON_THEME_KEY); + } catch { + return null; + } +}; + +export const useIconThemes = create<IconThemesState>(() => ({ + available: new Map(), + selected: readSelection(), + applied: null, +})); + +export const iconThemeKey = (pluginId: string, id: string) => `${pluginId}/${id}`; + +/** Called by the `iconThemes` projection: the theme can be selected while its plugin is live. */ +export function addIconTheme(theme: IconThemeRef): () => void { + const key = iconThemeKey(theme.pluginId, theme.id); + useIconThemes.setState((s) => ({ available: new Map(s.available).set(key, theme) })); + return () => + useIconThemes.setState((s) => { + const available = new Map(s.available); + available.delete(key); + return { available }; + }); +} + +export function selectIconTheme(key: string | null) { + try { + if (key) localStorage.setItem(ICON_THEME_KEY, key); + else localStorage.removeItem(ICON_THEME_KEY); + } catch { + /* storage unavailable: the choice lasts for this window */ + } + useIconThemes.setState({ selected: key }); +} + +/** + * The theme's resolver. Files: exact name, then the longest matching + * extension, then its default file icon. Folders: exact name, then its + * default (expanded) folder icon. + */ +export function iconResolver( + map: IconThemeMap, + iconName: (icon: string) => string, + has: (name: string) => boolean, +): FileIconTheme { + const pick = (icon: string | undefined | null) => (icon && has(iconName(icon)) ? iconName(icon) : null); + return { + file(fileName) { + const lower = fileName.toLowerCase(); + const named = pick(map.fileNames[lower]); + if (named) return named; + let rest = lower; + for (let dot = rest.indexOf("."); dot >= 0; dot = rest.indexOf(".")) { + rest = rest.slice(dot + 1); + const byExt = pick(map.fileExtensions[rest]); + if (byExt) return byExt; + } + return pick(map.file); + }, + folder(folderName, expanded) { + const named = pick(map.folderNames?.[folderName.toLowerCase()]); + if (named) return named; + return (expanded && pick(map.folderExpanded)) || pick(map.folder); + }, + }; +} + +let undo: (() => void) | null = null; +let loading: string | null = null; + +async function apply(key: string | null) { + const theme = key ? useIconThemes.getState().available.get(key) : undefined; + if (!theme) { + undo?.(); + undo = null; + useIconThemes.setState({ applied: null }); + return; + } + if (useIconThemes.getState().applied === key || loading === key) return; + loading = key; + try { + const prefix = `iconTheme/${theme.id}/`; + const assets = await pluginsBackend().assets(theme.pluginId, prefix); + if (loading !== key) return; + const mapJson = assets.find(([id]) => id === `${prefix}map`)?.[1]; + if (!mapJson) throw new Error(`icon theme ${key} has no mapping`); + const map = JSON.parse(mapJson) as IconThemeMap; + const svgPrefix = `${prefix}svg/`; + const iconName = (icon: string) => `ext:${key}/${icon}`; + const icons = assets + .filter(([id]) => id.startsWith(svgPrefix)) + .map(([id, svg]) => [iconName(id.slice(svgPrefix.length)), svg] as const); + const names = new Set(icons.map(([n]) => n)); + undo?.(); + const unregister = registerExternalIcons(icons); + setFileIconTheme(iconResolver(map, iconName, (n) => names.has(n))); + undo = () => { + setFileIconTheme(null); + unregister(); + }; + useIconThemes.setState({ applied: key }); + } catch (e) { + console.warn("could not load the file icon theme", e); + } finally { + if (loading === key) loading = null; + } +} + +/** Keeps the registered icons in line with the selection and the available themes. */ +export function trackIconTheme(): () => void { + const sync = () => { + const { selected, available } = useIconThemes.getState(); + void apply(selected && available.has(selected) ? selected : null); + }; + sync(); + const unsubscribe = useIconThemes.subscribe((s, prev) => { + if (s.selected !== prev.selected || s.available !== prev.available) sync(); + }); + // Another window (Settings) changed the selection. + const onStorage = (e: StorageEvent) => { + if (e.key === ICON_THEME_KEY) useIconThemes.setState({ selected: e.newValue }); + }; + window.addEventListener("storage", onStorage); + return () => { + unsubscribe(); + window.removeEventListener("storage", onStorage); + undo?.(); + undo = null; + useIconThemes.setState({ applied: null }); + }; +} diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index a57767231..4a9fa4e19 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -1,18 +1,28 @@ /** * Plugins: Settings › Plugins (installed list, enable/disable, install from - * disk, uninstall, theme selection) and the runtime bridge to the WASM plugin - * host (`ide-plugin`). + * disk with a permission page, uninstall, theme selection) and the runtime + * bridge to the plugin host (`ide_host::plugins`): UI contributions are + * projected by `projection.ts` / `points.tsx`. * * Actions: `plugins.settings`, `plugins.installFromDisk`, `plugins.reloadAll`, * plus one action per plugin command: `plugin.<pluginId>.<commandId>` * (searchable in Search Everywhere › Actions and bindable in the keymap editor; * a string arg is forwarded to the plugin). + * + * Editor contribution `plugins.lineMarkers`: gutter icons from plugin + * `line-markers` providers (`lineMarkerGutter.ts`). */ +import { registerEditorContribution } from "@/editor/api"; import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; -import { registerSettingsPage } from "@/workbench/contributions"; -import { startPluginBridge, trackPluginTheme } from "./bridge"; +import { editorService, registerOverlay, registerSettingsPage } from "@/workbench/contributions"; +import { startPluginBridge, syncProjection, trackPluginTheme } from "./bridge"; +import { ConsentDialogHost, provideConsentDialog } from "./consent"; +import { trackIconTheme } from "./iconThemes"; +import { pluginLineMarkerGutter } from "./lineMarkerGutter"; +import { defineBuiltinUiPoints } from "./points"; +import { startViewUi } from "./uiPoints"; import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store"; function registerPluginsPage(): () => void { @@ -28,7 +38,15 @@ function registerPluginsPage(): () => void { /** Settings › Plugins for the Settings window: the page plus the plugin theme. */ export function activateSettingsPages(): () => void { - const disposers = [registerPluginsPage(), trackPluginTheme()]; + const disposers = [ + registerPluginsPage(), + registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }), + provideConsentDialog(), + defineBuiltinUiPoints(), + syncProjection(), + trackPluginTheme(), + trackIconTheme(), + ]; void refreshPlugins(); return () => disposers.reverse().forEach((d) => d()); } @@ -36,6 +54,8 @@ export function activateSettingsPages(): () => void { export function activate(): () => void { const disposers = [ registerPluginsPage(), + registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }), + provideConsentDialog(), registerActions([ { id: "plugins.settings", @@ -68,6 +88,10 @@ export function activate(): () => void { }, ]), startPluginBridge(), + // Plugin custom editors (tab kind) and context menu entries. + startViewUi((input) => void editorService().openInput(input)), + // Gutter icons from plugins' `line-markers` providers (next to the LSP ones). + registerEditorContribution({ id: "plugins.lineMarkers", order: 57, extensions: pluginLineMarkerGutter }), // Dev plugins (`<project>/.openidea/plugins`) depend on the open project. useProject.subscribe((s, prev) => { if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) void refreshPlugins(); diff --git a/src/features/plugins/lineMarkerGutter.ts b/src/features/plugins/lineMarkerGutter.ts new file mode 100644 index 000000000..23e3149fc --- /dev/null +++ b/src/features/plugins/lineMarkerGutter.ts @@ -0,0 +1,149 @@ +/** + * Gutter icons from plugins' `line-markers` providers (MyBatis' arrows + * between a mapper interface and its XML). Asked once per document version + * (debounced); markers follow their lines in between; a click opens the + * marker's target. The gutter column exists only while there are markers, + * so editors without plugin providers keep their usual width. + */ +import { Compartment, RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import { navigateTo } from "@/editor/lsp/navigation"; +import { commands, type LineMarkerInfo } from "@/platform/ipc"; +import { gutterIconMarkup } from "@/ui"; +import styles from "@/editor/navigation/LineMarkers.module.css"; + +const DEBOUNCE_MS = 400; + +const ICONS: Record<string, string> = { + implemented: "gutter-implemented", + implementing: "gutter-implementing", + overridden: "gutter-overridden", + overriding: "gutter-overriding", + run: "gutter-run", + navigate: "gutter-implementing", +}; + +/** Icon name for a plugin marker (`navigate` when unknown). */ +export function markerIcon(icon: string | null): string { + return ICONS[icon ?? "navigate"] ?? ICONS.navigate!; +} + +interface Placed { + marker: LineMarkerInfo; + pos: number; +} + +const setMarkers = StateEffect.define<Placed[]>(); + +const markersField = StateField.define<Placed[]>({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(setMarkers)) return e.value; + if (!tr.docChanged || !value.length) return value; + return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from })); + }, +}); + +/** Markers on existing lines, at their line starts. */ +export function placeMarkers(doc: Text, markers: readonly LineMarkerInfo[]): Placed[] { + return markers.filter((m) => m.line < doc.lines).map((marker) => ({ marker, pos: doc.line(marker.line + 1).from })); +} + +class PluginMarker extends GutterMarker { + constructor(readonly marker: LineMarkerInfo) { + super(); + } + override eq(other: GutterMarker): boolean { + return ( + other instanceof PluginMarker && + other.marker.tooltip === this.marker.tooltip && + other.marker.icon === this.marker.icon + ); + } + override toDOM(): Node { + const el = document.createElement("div"); + el.className = styles.marker!; + el.title = this.marker.tooltip; + el.setAttribute("role", "button"); + el.setAttribute("aria-label", this.marker.tooltip); + el.innerHTML = gutterIconMarkup(markerIcon(this.marker.icon)) ?? ""; + return el; + } +} + +class MarkersPlugin { + private timer: ReturnType<typeof setTimeout> | null = null; + private generation = 0; + private shown = false; + + constructor( + private view: EditorView, + private doc: number, + private column: Compartment, + ) { + this.schedule(); + } + + update(u: ViewUpdate) { + if (u.docChanged) this.schedule(); + } + + private schedule() { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => void this.run(), DEBOUNCE_MS); + } + + private async run() { + this.timer = null; + const gen = ++this.generation; + const text = this.view.state.doc; + try { + const markers = await commands.pluginLineMarkers(this.doc); + if (gen !== this.generation || this.view.state.doc !== text || !this.view.dom.isConnected) return; + if (markers.status !== "ok") return; + const placed = placeMarkers(text, markers.data); + if (!placed.length && !this.view.state.field(markersField).length) return; + const effects: StateEffect<unknown>[] = [setMarkers.of(placed)]; + if (placed.length > 0 !== this.shown) { + this.shown = placed.length > 0; + effects.push(this.column.reconfigure(this.shown ? markerColumn : [])); + } + this.view.dispatch({ effects }); + } catch (e) { + console.debug("[plugin line markers]", e); + } + } + + destroy() { + if (this.timer) clearTimeout(this.timer); + this.generation++; + } +} + +const markerColumn: Extension = gutter({ + class: `${styles.gutter} cm-openidea-gutter-pluginMarkers`, + markers: (view) => + RangeSet.of( + view.state.field(markersField).map((m) => new PluginMarker(m.marker).range(m.pos)), + true, + ), + domEventHandlers: { + mousedown: (view, block, event) => { + const e = event as MouseEvent; + if (e.button !== 0) return false; + const target = view.state.field(markersField).find((m) => m.pos === block.from && m.marker.target)?.marker.target; + if (!target) return false; + e.preventDefault(); + void navigateTo(target); + return true; + }, + }, +}); + +export function pluginLineMarkerGutter(ctx: EditorDocContext): Extension { + if (ctx.doc < 0) return []; + const doc = ctx.doc; + const column = new Compartment(); + return [markersField, column.of([]), ViewPlugin.define((view) => new MarkersPlugin(view, doc, column))]; +} diff --git a/src/features/plugins/points.tsx b/src/features/plugins/points.tsx new file mode 100644 index 000000000..54e805ec4 --- /dev/null +++ b/src/features/plugins/points.tsx @@ -0,0 +1,158 @@ +/** + * Client side of the first-party UI extension points: how each validated + * declaration is projected onto the workbench, and how its items appear on + * the plugin details page. One `defineUiPoint` per contribution type. + */ +import type { PluginInfo, ThemeDef } from "@/bindings"; +import { registerAction } from "@/platform/actions"; +import { tx } from "@/platform/i18n"; +import { Button, Kbd, T } from "@/ui"; +import { addIconTheme, iconThemeKey, selectIconTheme, useIconThemes } from "./iconThemes"; +import { defineUiPoint, type UiItem } from "./projection"; +import { SecretsDetails, type SecretDecl } from "./secrets"; +import { defineViewUiPoints } from "./uiPoints"; +import { isLive, runPluginCommand, selectPluginTheme, usePlugins } from "./store"; +import styles from "./PluginsPage.module.css"; + +export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`; + +interface CommandDecl { + id: string; + title: string; + category?: string | null; + when?: string | null; +} + +function CommandsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<CommandDecl>[] }) { + const runnable = plugin.status === "active" || plugin.status === "inactive"; + return ( + <ul className={styles.items}> + {items.map(({ decl: c }) => { + const actionId = pluginActionId(plugin.id, c.id); + return ( + <li key={c.id} className={styles.item}> + <span className={styles.itemMain}>{c.title}</span> + <Kbd action={actionId} /> + <span className={styles.mono} title={tx("Action id (bind it in Keymap)")}> + {actionId} + </span> + <Button + size="small" + variant="ghost" + icon="run" + disabled={!runnable} + onClick={() => void runPluginCommand(plugin.id, c.id)} + aria-label={tx`Run ${c.title}`} + > + <T>Run</T> + </Button> + </li> + ); + })} + </ul> + ); +} + +function ThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<ThemeDef>[] }) { + const activeTheme = usePlugins((s) => s.activeTheme); + const live = isLive(plugin.status); + return ( + <ul className={styles.items}> + {items.map(({ decl: t }) => { + const isActive = activeTheme?.pluginId === plugin.id && activeTheme.theme.id === t.id; + return ( + <li key={t.id} className={styles.item}> + <span className={styles.itemMain}>{t.name}</span> + <span className={styles.muted}>{t.kind === "dark" ? tx("Dark") : tx("Light")}</span> + {isActive ? ( + <Button size="small" variant="secondary" onClick={() => void selectPluginTheme(null, null)}> + <T>Use Default Theme</T> + </Button> + ) : ( + <Button + size="small" + variant="ghost" + disabled={!live} + onClick={() => void selectPluginTheme(plugin.id, t.id)} + aria-label={tx`Apply theme ${t.name}`} + > + <T>Apply</T> + </Button> + )} + </li> + ); + })} + </ul> + ); +} + +interface IconThemeDecl { + id: string; + label: string; + icons: number; +} + +function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<IconThemeDecl>[] }) { + const selected = useIconThemes((s) => s.selected); + return ( + <ul className={styles.items}> + {items.map(({ decl: t }) => { + const key = iconThemeKey(plugin.id, t.id); + return ( + <li key={t.id} className={styles.item}> + <span className={styles.itemMain}>{t.label}</span> + <span className={styles.muted}>{tx`${t.icons} icons`}</span> + {selected === key ? ( + <Button size="small" variant="secondary" onClick={() => selectIconTheme(null)}> + <T>Use Default Icons</T> + </Button> + ) : ( + <Button + size="small" + variant="ghost" + onClick={() => selectIconTheme(key)} + aria-label={tx`Apply icon theme ${t.label}`} + > + <T>Apply</T> + </Button> + )} + </li> + ); + })} + </ul> + ); +} + +/** Registers the client side of every first-party UI point. */ +export function defineBuiltinUiPoints(): () => void { + const disposers = [ + defineUiPoint<CommandDecl>("commands", { + title: "Commands", + project: ({ pluginId, decl }, { plugin }) => + registerAction({ + id: pluginActionId(pluginId, decl.id), + title: decl.title, + category: decl.category ?? plugin.name, + icon: "package", + when: decl.when ?? undefined, + keywords: ["plugin", plugin.name, pluginId], + description: tx`From plugin ${plugin.name}`, + run: async ({ arg }) => { + await runPluginCommand(pluginId, decl.id, typeof arg === "string" ? arg : null); + }, + }), + Details: CommandsDetails, + }), + // Applied through `activeTheme` (the backend owns the selection); listed here. + defineUiPoint<ThemeDef>("themes", { title: "Themes", Details: ThemesDetails }), + defineUiPoint<IconThemeDecl>("iconThemes", { + title: "File Icon Themes", + project: ({ pluginId, decl }) => addIconTheme({ pluginId, id: decl.id, label: decl.label }), + Details: IconThemesDetails, + }), + // Stored by the backend; listed here with a field per secret. + defineUiPoint<SecretDecl>("secrets", { title: "Secrets", Details: SecretsDetails }), + defineViewUiPoints(), + ]; + return () => disposers.reverse().forEach((d) => d()); +} diff --git a/src/features/plugins/projection.ts b/src/features/plugins/projection.ts new file mode 100644 index 000000000..b447a2f49 --- /dev/null +++ b/src/features/plugins/projection.ts @@ -0,0 +1,129 @@ +/** + * Projection of plugin UI contributions onto the workbench's own `register*` + * functions (docs/plugin_plan.md 4.1). + * + * The backend validated every declaration completely (Rust extension points); + * a projection maps one onto an existing registration and returns its + * disposer. Projections must not fail: an exception is a defect, logged, and + * that one item is skipped — nothing is half-registered. + * + * Adding a UI contribution type = a Rust extension point + one + * `defineUiPoint` here; the kernel (this file, the store, the bridge) is not + * touched. + */ +import type { ComponentType } from "react"; +import type { PluginInfo, UiContribution } from "@/bindings"; + +export interface UiItem<T = Record<string, unknown>> { + pluginId: string; + point: string; + key: string; + decl: T; +} + +export interface ProjectionContext { + plugin: PluginInfo; +} + +export interface UiPointDefinition<T = Record<string, unknown>> { + /** Makes one item take effect; returns its disposer. */ + project?: (item: UiItem<T>, ctx: ProjectionContext) => (() => void) | void; + /** Optional section in Settings › Plugins › details for this point's items. */ + Details?: ComponentType<{ plugin: PluginInfo; items: UiItem<T>[] }>; + /** Section title for the details page (defaults to the point id). */ + title?: string; +} + +const points = new Map<string, UiPointDefinition<never>>(); +const live = new Map<string, { sig: string; dispose: () => void }>(); +const warned = new Set<string>(); +let lastItems: readonly UiContribution[] = []; +let lastPlugins: readonly PluginInfo[] = []; + +const itemKey = (c: UiContribution) => `${c.pluginId}\u0000${c.point}\u0000${c.key}`; + +/** Registers the client side of an extension point. */ +export function defineUiPoint<T>(point: string, def: UiPointDefinition<T>): () => void { + if (points.has(point)) throw new Error(`UI point "${point}" is already defined`); + points.set(point, def as UiPointDefinition<never>); + // Items that arrived before the definition (lazy features) are projected now. + syncUiContributions(lastItems, lastPlugins); + return () => { + for (const [key, entry] of live) { + if (key.split("\u0000")[1] === point) { + safeDispose(entry.dispose); + live.delete(key); + } + } + points.delete(point); + }; +} + +export function uiPoint(point: string): UiPointDefinition<never> | undefined { + return points.get(point); +} + +function safeDispose(dispose: () => void) { + try { + dispose(); + } catch (e) { + console.error("plugin contribution disposer failed", e); + } +} + +/** + * Brings the registrations in line with `items` (the backend's snapshot): + * removed or changed items are disposed, new or changed ones projected. + */ +export function syncUiContributions(items: readonly UiContribution[], plugins: readonly PluginInfo[]) { + lastItems = items; + lastPlugins = plugins; + const byId = new Map(plugins.map((p) => [p.id, p])); + const next = new Map<string, { item: UiContribution; sig: string }>(); + for (const item of items) { + if (!points.get(item.point)?.project) { + if (!points.has(item.point) && !warned.has(item.point)) { + warned.add(item.point); + console.warn(`plugin UI point "${item.point}" has no client projection`); + } + continue; + } + if (!byId.has(item.pluginId)) continue; + next.set(itemKey(item), { item, sig: JSON.stringify(item.decl) }); + } + for (const [key, entry] of live) { + if (next.get(key)?.sig !== entry.sig) { + safeDispose(entry.dispose); + live.delete(key); + } + } + for (const [key, { item, sig }] of next) { + if (live.has(key)) continue; + const def = points.get(item.point)!; + const plugin = byId.get(item.pluginId)!; + try { + const dispose = def.project!(item as UiItem<never>, { plugin }) ?? (() => {}); + live.set(key, { sig, dispose }); + } catch (e) { + // The backend validated the declaration: a failing projection is a defect. + console.error(`projecting ${item.point} "${item.key}" of plugin ${item.pluginId} failed`, e); + } + } +} + +/** Disposes every projected registration (shutdown, tests). */ +export function clearUiContributions() { + for (const entry of live.values()) safeDispose(entry.dispose); + live.clear(); + lastItems = []; + lastPlugins = []; +} + +/** Items of one point, optionally of one plugin. */ +export function itemsOf<T = Record<string, unknown>>( + items: readonly UiContribution[], + point: string, + pluginId?: string, +): UiItem<T>[] { + return items.filter((c) => c.point === point && (!pluginId || c.pluginId === pluginId)) as UiItem<T>[]; +} diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx new file mode 100644 index 000000000..136bbcf93 --- /dev/null +++ b/src/features/plugins/runtimeUi.tsx @@ -0,0 +1,273 @@ +/** + * What running plugin code shows outside its declarations: status bar items + * (`ui.set-status-item`), background tasks (`tasks.begin`), and the + * workbench events it subscribed to (`events.subscribe`), reported in + * batches. + */ +import { create } from "zustand"; +import type { EventKind, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindings"; +import { onEditorEvent } from "@/editor/api"; +import { executeAction, getAction, onActionExecuted } from "@/platform/actions"; +import { onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { shortcutLabel } from "@/platform/keymap"; +import { BarButton } from "@/ui/BarButton"; +import { registerStatusItem } from "@/workbench/contributions"; +import { removeTask, upsertTask, useTasks } from "@/workbench/tasks"; +import { pluginsBackend } from "./backend"; +import { bumpView, deliverViewMessage } from "./views/viewStore"; +import { runPluginCommand } from "./store"; + +// --- status bar items --------------------------------------------------------- + +type ItemKey = `${string}/${string}`; + +export const usePluginStatusItems = create<{ items: Map<ItemKey, { pluginId: string; item: StatusItem }> }>(() => ({ + items: new Map(), +})); + +function setStatusItem(pluginId: string, id: string, item: StatusItem | null) { + usePluginStatusItems.setState((s) => { + const items = new Map(s.items); + if (item) items.set(`${pluginId}/${id}`, { pluginId, item }); + else items.delete(`${pluginId}/${id}`); + return { items }; + }); +} + +/** Items declared under `[[contributes.statusBar]]`: shown until (and unless) the code sets its own. */ +interface Declared { + item: StatusItem; + align: "left" | "right"; + priority: number; +} + +export const useDeclaredStatusItems = create<{ items: Map<ItemKey, { pluginId: string } & Declared> }>(() => ({ + items: new Map(), +})); + +export function declareStatusItem(pluginId: string, id: string, declared: Declared): () => void { + const key: ItemKey = `${pluginId}/${id}`; + const entry = { pluginId, ...declared }; + useDeclaredStatusItems.setState((s) => ({ items: new Map(s.items).set(key, entry) })); + return () => + useDeclaredStatusItems.setState((s) => { + if (s.items.get(key) !== entry) return s; + const items = new Map(s.items); + items.delete(key); + return { items }; + }); +} + +function PluginStatusItemView({ itemKey }: { itemKey: ItemKey }) { + const running = usePluginStatusItems((s) => s.items.get(itemKey)); + const declared = useDeclaredStatusItems((s) => s.items.get(itemKey)); + const entry = running ?? declared; + if (!entry) return null; + const { pluginId, item } = entry; + const command = item.command; + return ( + <BarButton + tooltip={item.tooltip ?? undefined} + aria-label={item.tooltip ?? item.text} + onClick={command ? () => void runPluginCommand(pluginId, command) : undefined} + > + {item.text} + </BarButton> + ); +} + +/** One status bar contribution per plugin item (declared or set by code), registered while it exists. */ +function projectStatusItems(): () => void { + const registered = new Map<ItemKey, { dispose: () => void; sig: string }>(); + const sync = () => { + const running = usePluginStatusItems.getState().items; + const declared = useDeclaredStatusItems.getState().items; + const live = new Set<ItemKey>([...running.keys(), ...declared.keys()]); + for (const [key, r] of registered) { + const d = declared.get(key); + const sig = d ? `${d.align}/${d.priority}` : ""; + if (!live.has(key) || r.sig !== sig) { + r.dispose(); + registered.delete(key); + } + } + for (const key of live) + if (!registered.has(key)) { + const d = declared.get(key); + const View = () => <PluginStatusItemView itemKey={key} />; + const dispose = registerStatusItem({ + id: `plugin.${key}`, + align: d?.align ?? "right", + priority: d?.priority ?? 2, + component: View, + }); + registered.set(key, { dispose, sig: d ? `${d.align}/${d.priority}` : "" }); + } + }; + sync(); + const unsubscribe = [ + usePluginStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()), + useDeclaredStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()), + ]; + refreshStatusItems(); + return () => { + unsubscribe.forEach((u) => u()); + for (const r of registered.values()) r.dispose(); + registered.clear(); + }; +} + +/** + * Re-reads the status items of running plugin code (later changes arrive as + * `statusItemChanged`). Like the event kinds, the backend answers once this + * window's project is open and trusted, so call it again when that happens. + */ +function refreshStatusItems() { + void pluginsBackend() + .statusItems() + .then((items) => items.forEach((i) => setStatusItem(i.pluginId, i.id, i.item))) + .catch(() => {}); +} + +// --- tasks -------------------------------------------------------------------- + +const taskId = (id: string) => `plugin:${id}`; + +function onTaskEvent(e: Extract<PluginEvent, { type: "taskStarted" | "taskProgress" | "taskEnded" }>) { + const id = taskId(e.taskId); + const current = useTasks.getState().tasks.find((t) => t.id === id); + switch (e.type) { + case "taskStarted": + upsertTask({ + id, + title: e.title, + message: null, + fraction: null, + done: false, + cancel: e.cancellable ? () => void pluginsBackend().cancelTask(e.taskId) : undefined, + }); + break; + case "taskProgress": + if (current) + upsertTask({ + ...current, + message: e.message, + fraction: e.total ? Math.min(1, e.done / e.total) : null, + }); + break; + case "taskEnded": + if (current) upsertTask({ ...current, done: true }); + else removeTask(id); + break; + } +} + +// --- workbench events ----------------------------------------------------------- + +/** Batches are sent at most this often; the backend batches again per plugin. */ +const POST_INTERVAL_MS = 500; + +let wanted = new Set<EventKind>(); +let queue: WorkbenchEvent[] = []; +let postTimer: ReturnType<typeof setTimeout> | null = null; + +/** + * Re-reads the event kinds plugins want. Besides `subscriptionsChanged` + * (run-time subscriptions), declared `on-event:` kinds change whenever plugins + * load, install, toggle or get grants, which the backend reports as `commandsChanged`. + */ +export function refreshEventKinds() { + void pluginsBackend() + .eventKinds() + .then((kinds) => (wanted = new Set(kinds))) + .catch(() => {}); +} + +function post(event: WorkbenchEvent) { + queue.push(event); + if (postTimer) return; + postTimer = setTimeout(() => { + postTimer = null; + const batch = queue; + queue = []; + void pluginsBackend() + .postEvents(batch) + .catch(() => {}); + }, POST_INTERVAL_MS); +} + +/** Reports the events plugins subscribed to: files opened and saved, actions run, repositories changed. */ +function trackWorkbenchEvents(): () => void { + refreshEventKinds(); + const offEditor = onEditorEvent((e) => { + if (e.type === "docOpened" && wanted.has("file-opened")) post({ kind: "fileOpened", value: e.path }); + if (e.type === "docSaved" && wanted.has("file-saved")) post({ kind: "fileSaved", value: e.path }); + }); + const offActions = onActionExecuted((id, _arg, source) => { + if (!wanted.has("action-performed")) return; + const action = getAction(id); + post({ + kind: "actionPerformed", + value: { action: id, title: action?.title ?? id, source, shortcut: shortcutLabel(id) ?? null }, + }); + }); + const offVcs = onIdeEvent("vcsChanged", (e) => { + if (wanted.has("vcs-changed")) post({ kind: "vcsChanged", value: e.root }); + }); + return () => { + offEditor(); + offActions(); + offVcs(); + if (postTimer) clearTimeout(postTimer); + postTimer = null; + queue = []; + }; +} + +/** Handles the runtime UI events; returns whether `e` was one. */ +export function handleRuntimeEvent(e: PluginEvent): boolean { + switch (e.type) { + case "statusItemChanged": + setStatusItem(e.pluginId, e.id, e.item); + return true; + case "taskStarted": + case "taskProgress": + case "taskEnded": + onTaskEvent(e); + return true; + case "subscriptionsChanged": + refreshEventKinds(); + return true; + case "viewChanged": + bumpView(e.pluginId, e.view); + return true; + case "viewMessage": + deliverViewMessage(e.pluginId, e.view, e.message); + return true; + case "openTerminal": + // The program was approved (`process:`) and verified by the host. + void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd }); + return true; + default: + return false; + } +} + +/** + * The backend refuses the event kinds and status items until this window's + * project is open and trusted (`authorize_command`), but startup asks before + * that: ask again when it changes. + */ +function refreshOnProjectChange(): () => void { + return useProject.subscribe((s, prev) => { + if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return; + refreshEventKinds(); + refreshStatusItems(); + }); +} + +export function startRuntimeUi(): () => void { + const disposers = [projectStatusItems(), trackWorkbenchEvents(), refreshOnProjectChange()]; + return () => disposers.reverse().forEach((d) => d()); +} diff --git a/src/features/plugins/secrets.tsx b/src/features/plugins/secrets.tsx new file mode 100644 index 000000000..f6838a85a --- /dev/null +++ b/src/features/plugins/secrets.tsx @@ -0,0 +1,114 @@ +/** + * `[[contributes.secrets]]` on the plugin details page: a password field per + * declared secret. Values go to the backend's secret store (the macOS + * Keychain in the desktop app) and never come back to the UI; plugins use + * them only as `{{secret:<name>}}` placeholders in `net` request headers. + */ +import { useCallback, useEffect, useState } from "react"; +import type { PluginInfo, PluginSecretState } from "@/bindings"; +import { tx } from "@/platform/i18n"; +import { Badge, Button, Input, T } from "@/ui"; +import { pluginsBackend } from "./backend"; +import type { UiItem } from "./projection"; +import styles from "./PluginsPage.module.css"; + +export interface SecretDecl { + name: string; + label: string; + description?: string | null; +} + +function SecretRow({ + plugin, + decl, + isSet, + onChanged, +}: { + plugin: PluginInfo; + decl: SecretDecl; + isSet: boolean; + onChanged: () => void; +}) { + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const save = async (v: string | null) => { + setBusy(true); + setError(null); + try { + await pluginsBackend().setSecret(plugin.id, decl.name, v); + setValue(""); + onChanged(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + return ( + <li className={styles.item}> + <span className={styles.itemMain} title={decl.description ?? undefined}> + {decl.label} + </span> + {isSet ? <Badge tone="success">{tx("Stored")}</Badge> : <Badge tone="neutral">{tx("Not set")}</Badge>} + <Input + type="password" + value={value} + disabled={busy} + placeholder={isSet ? "Replace…" : "Value"} + aria-label={tx`Value of ${decl.label}`} + onChange={(e) => setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && value) void save(value); + }} + /> + <Button size="small" variant="ghost" disabled={busy || !value} onClick={() => void save(value)}> + <T>Save</T> + </Button> + {isSet && ( + <Button + size="small" + variant="ghost" + disabled={busy} + onClick={() => void save(null)} + aria-label={tx`Remove ${decl.label}`} + > + <T>Remove</T> + </Button> + )} + {error && ( + <span className={styles.error} role="alert"> + {error} + </span> + )} + </li> + ); +} + +export function SecretsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<SecretDecl>[] }) { + const [state, setState] = useState<PluginSecretState[]>([]); + const refresh = useCallback(() => { + pluginsBackend() + .secrets(plugin.id) + .then(setState, () => setState([])); + }, [plugin.id]); + useEffect(refresh, [refresh]); + return ( + <> + <ul className={styles.items} aria-label={tx("Secrets")}> + {items.map(({ decl }) => ( + <SecretRow + key={decl.name} + plugin={plugin} + decl={decl} + isSet={state.some((s) => s.name === decl.name && s.set)} + onChanged={refresh} + /> + ))} + </ul> + <div className={styles.muted}> + <T>The plugin can only send these with its network requests; it cannot read them.</T> + </div> + </> + ); +} diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts index 08ec18a63..9d3dd0c63 100644 --- a/src/features/plugins/store.ts +++ b/src/features/plugins/store.ts @@ -1,6 +1,17 @@ -/** Installed plugins, the selected plugin theme, and the operations on them. */ +/** Installed plugins, their UI contributions, the selected plugin theme, and the operations on them. */ import { create } from "zustand"; -import type { ActiveTheme, CatalogPlugin, PluginInfo } from "@/bindings"; +import type { + ActiveTheme, + CapabilityInfo, + CatalogPlugin, + Consent, + MarketPlugin, + PluginEditor, + PluginInfo, + PluginStatus, + UiContribution, +} from "@/bindings"; +import { activeEditor } from "@/editor/api"; import { notifications } from "@/workbench/contributions"; import { pluginsBackend } from "./backend"; @@ -8,6 +19,8 @@ export const SETTINGS_PAGE_ID = "plugins"; interface PluginsState { plugins: PluginInfo[]; + /** UI contributions of live plugins, projected by `projection.ts`. */ + ui: UiContribution[]; activeTheme: ActiveTheme | null; loaded: boolean; loading: boolean; @@ -18,10 +31,16 @@ interface PluginsState { /** Locally available packages (Marketplace tab); `null` until loaded. */ catalog: CatalogPlugin[] | null; catalogError: string | null; + /** The marketplace index URL; `null` when none is configured. */ + marketUrl: string | null; + /** Plugins of the marketplace index; `null` until read. */ + market: MarketPlugin[] | null; + marketError: string | null; } export const usePlugins = create<PluginsState>(() => ({ plugins: [], + ui: [], activeTheme: null, loaded: false, loading: false, @@ -30,10 +49,48 @@ export const usePlugins = create<PluginsState>(() => ({ installing: false, catalog: null, catalogError: null, + marketUrl: null, + market: null, + marketError: null, })); export const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +/** Statuses whose contributions are in effect. */ +export const isLive = (status: PluginStatus) => status === "active" || status === "inactive" || status === "static"; + +// --- consent ------------------------------------------------------------------ + +/** What a consent prompt shows: an install preview or a plugin waiting for permission. */ +export interface ConsentRequest { + pluginId: string; + name: string; + version: string; + /** Install page (new or update) or a permission prompt for an installed/project plugin. */ + kind: "install" | "grant"; + capabilities: CapabilityInfo[]; + /** Unsigned package (from disk or a project folder). */ + unsigned: boolean; + warnings: string[]; +} + +type ConsentPrompt = (req: ConsentRequest) => Promise<Consent | null>; + +let consentPrompt: ConsentPrompt | null = null; + +/** The dialog that asks (registered by `consent.tsx`; tests substitute their own). */ +export function setConsentPrompt(prompt: ConsentPrompt | null) { + consentPrompt = prompt; +} + +/** Without a prompt only plugins asking for nothing are accepted. */ +function askConsent(req: ConsentRequest): Promise<Consent | null> { + if (consentPrompt) return consentPrompt(req); + return Promise.resolve(req.capabilities.length === 0 ? { optional: [] } : null); +} + +// --- loading ------------------------------------------------------------------ + let generation = 0; export async function refreshPlugins(): Promise<void> { @@ -44,6 +101,7 @@ export async function refreshPlugins(): Promise<void> { if (gen !== generation) return; usePlugins.setState({ plugins: snapshot.plugins, + ui: snapshot.ui, activeTheme: snapshot.activeTheme, loaded: true, loading: false, @@ -93,6 +151,29 @@ export function uninstallPlugin(id: string): Promise<boolean> { }); } +/** Asks for (and records) the permissions of a plugin waiting for consent. */ +export async function grantPlugin(id: string, capabilities?: CapabilityInfo[]): Promise<boolean> { + const plugin = usePlugins.getState().plugins.find((p) => p.id === id); + if (!plugin) return false; + const consent = await askConsent({ + pluginId: id, + name: plugin.name, + version: plugin.version, + kind: "grant", + capabilities: capabilities ?? plugin.capabilities, + unsigned: plugin.source !== "installed", + warnings: plugin.warnings, + }); + if (!consent) return false; + return withBusy(id, `Could not enable ${plugin.name}`, () => pluginsBackend().grant(id, consent)); +} + +export function setPluginCapability(id: string, capability: string, granted: boolean): Promise<boolean> { + return withBusy(id, `Could not change ${capability} of ${nameOf(id)}`, () => + pluginsBackend().setCapability(id, capability, granted), + ); +} + export async function refreshCatalog(): Promise<void> { try { usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null }); @@ -101,6 +182,57 @@ export async function refreshCatalog(): Promise<void> { } } +/** The marketplace's plugins: the index read again with `refresh`. */ +export async function refreshMarket(refresh = true): Promise<void> { + const backend = pluginsBackend(); + try { + const marketUrl = await backend.marketUrl(); + usePlugins.setState({ marketUrl }); + if (!marketUrl) return usePlugins.setState({ market: null, marketError: null }); + usePlugins.setState({ market: await backend.market(refresh), marketError: null }); + } catch (e) { + usePlugins.setState({ marketError: errorText(e) }); + } +} + +export async function setMarketUrl(url: string | null): Promise<void> { + await pluginsBackend().setMarketUrl(url?.trim() || null); + await refreshMarket(true); +} + +/** Downloads (and checks) a marketplace plugin, then the install page as from disk. */ +export async function installFromMarket(id: string): Promise<string | null> { + usePlugins.setState({ installing: true }); + let dir: string; + try { + dir = await pluginsBackend().marketDownload(id); + } catch (e) { + usePlugins.setState({ installing: false }); + notifications().notify({ + group: "plugins", + level: "error", + title: "Could not download plugin", + body: errorText(e), + }); + return null; + } + const installed = await installFromDisk(dir); + void refreshMarket(false); + return installed; +} + +/** Settings › Plugins › Roll Back: the version before the last update. */ +export async function rollBackPlugin(id: string): Promise<void> { + try { + const info = await pluginsBackend().rollBack(id); + notifications().notify({ group: "plugins", level: "info", title: `Rolled back ${info.name} to ${info.version}` }); + } catch (e) { + notifications().notify({ group: "plugins", level: "error", title: "Could not roll back", body: errorText(e) }); + } finally { + await refreshPlugins(); + } +} + /** Enables or disables every installed (non-dev) plugin; returns how many changed. */ export async function setAllInstalledEnabled(enabled: boolean): Promise<number> { const targets = usePlugins.getState().plugins.filter((p) => p.source === "installed" && p.enabled !== enabled); @@ -124,25 +256,41 @@ export function compareVersions(a: string, b: string): number { return 0; } -/** Asks for a plugin package (a `.zip` or a `plugin.toml`) and installs it. Returns the new plugin id. */ -export async function installFromDisk(path?: string): Promise<string | null> { +/** + * Asks for a plugin folder (containing plugin.toml), shows what it may do, + * and installs it with the user's consent. Returns the new plugin id. + */ +export async function installFromDisk(path?: string, packageFile = false): Promise<string | null> { const backend = pluginsBackend(); - let source: string | null = path ?? null; + let dir: string | null = path ?? null; try { - source ??= await backend.pickPackage(); + dir ??= packageFile ? await backend.pickPackage() : await backend.pickFolder(); + // A package file (`.oiplugin`) is unpacked first; signed ones are checked on the install page. + if (dir?.endsWith(".oiplugin")) dir = await backend.unpackPackage(dir); } catch (e) { notifications().notify({ group: "plugins", level: "error", - title: "Could not open the file picker", + title: packageFile ? "Could not open the package" : "Could not open the folder picker", body: errorText(e), }); return null; } - if (!source) return null; + if (!dir) return null; usePlugins.setState({ installing: true }); try { - const info = await backend.installDir(source); + const preview = await backend.previewInstall(dir); + const consent = await askConsent({ + pluginId: preview.id, + name: preview.name, + version: preview.version, + kind: "install", + capabilities: preview.capabilities, + unsigned: preview.unsigned, + warnings: preview.warnings, + }); + if (!consent) return null; + const info = await backend.installDir(dir, consent); notifications().notify({ group: "plugins", level: info.status === "failed" ? "warning" : "info", @@ -168,17 +316,43 @@ export async function selectPluginTheme(pluginId: string | null, themeId: string } } +/** Title of a plugin command from its UI contribution. */ +export function commandTitle(pluginId: string, commandId: string): string { + const item = usePlugins + .getState() + .ui.find((c) => c.point === "commands" && c.pluginId === pluginId && c.key === commandId); + const title = (item?.decl as { title?: unknown } | undefined)?.title; + return typeof title === "string" ? title : commandId; +} + +/** The focused text editor as a command's context: path, language, selections (line / UTF-16 column). */ +function focusedEditor(): PluginEditor | null { + const editor = activeEditor(); + if (!editor?.ctx.path) return null; + const { doc, selection } = editor.view.state; + const pos = (offset: number) => { + const line = doc.lineAt(offset); + return { line: line.number - 1, col: offset - line.from }; + }; + return { + path: editor.ctx.path, + language: editor.ctx.language, + selections: selection.ranges.map((r) => ({ start: pos(r.from), end: pos(r.to) })), + }; +} + /** Runs a plugin command from an action; failures become notifications. */ export async function runPluginCommand(pluginId: string, commandId: string, arg: string | null = null) { - const plugin = usePlugins.getState().plugins.find((p) => p.id === pluginId); - const title = plugin?.commands.find((c) => c.id === commandId)?.title ?? commandId; + const title = commandTitle(pluginId, commandId); try { - return await pluginsBackend().runCommand(pluginId, commandId, arg); + return await pluginsBackend().runCommand(pluginId, commandId, arg, focusedEditor()); } catch (e) { await refreshPlugins(); - // A trap disables the plugin and the backend already announced why. - const failed = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status === "failed"; - if (!failed) + const status = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status; + // A trap disables the plugin and the backend already announced why; a + // consent request opens its own dialog. + const permission = (e as { kind?: string }).kind === "permissionDenied"; + if (status !== "failed" && !permission) notifications().notify({ group: "plugins", level: "error", title: `${title} failed`, body: errorText(e) }); return null; } diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx new file mode 100644 index 000000000..a65172b32 --- /dev/null +++ b/src/features/plugins/uiPoints.tsx @@ -0,0 +1,318 @@ +/** + * Client side of the P7 UI points (docs/plugin_plan.md P7): declarations the + * backend validated, projected onto the workbench's own registries. + * + * - `menus`: editor / project view / editor tab context menus, and Tools in the main menu; + * - `keymaps`: default shortcuts below the IDE's (a taken keystroke is dropped); + * - `statusBar`: items shown before the code runs (its `set-status-item` with the same id updates them); + * - `settings`: a page under Settings › Plugins generated from the schema; + * - `views`: tool windows the plugin renders; + * - `customEditors`: files opening in a plugin-rendered editor sharing the text document. + */ +import { useMemo } from "react"; +import type { PluginInfo } from "@/bindings"; +import { getAction } from "@/platform/actions"; +import { evaluateWhen } from "@/platform/context"; +import { tx } from "@/platform/i18n"; +import { isMacHost, registerExtensionBindings } from "@/platform/keymap"; +import { Checkbox, Input, Select, T, type MenuItem } from "@/ui"; +import { + registerEditorKind, + registerFileEditorProvider, + registerMenuGroup, + registerMenuSlotItem, + registerSettingsPage, + registerToolWindow, + useSettingsForm, + type ContextMenuId, + type ToolWindowAnchor, +} from "@/workbench/contributions"; +import { pluginActionId } from "./points"; +import { defineUiPoint } from "./projection"; +import { declareStatusItem } from "./runtimeUi"; +import { + CUSTOM_EDITOR_KIND, + CustomEditor, + basename, + customEditorDocument, + customEditorInput, + matchesPattern, + type CustomEditorDecl, +} from "./views/customEditor"; +import { PluginView } from "./views/PluginView"; +import styles from "./PluginsPage.module.css"; + +// --- menus ------------------------------------------------------------------------ + +interface MenuDecl { + menu: "editor.context" | "projectView.context" | "editorTab.context" | "tools"; + command: string; + when?: string | null; +} + +const CONTEXT_MENUS: Record<string, ContextMenuId> = { + "editor.context": "editor", + "projectView.context": "projectView", + "editorTab.context": "editorTab", +}; + +const contextItems = new Map<string, { pluginId: string; decl: MenuDecl }>(); +let menuSeq = 0; + +/** Plugin items of a context menu: their command's action with the menu's file as argument. */ +export function pluginMenuItems(menu: ContextMenuId, paths: readonly string[]): MenuItem[] { + const out: MenuItem[] = []; + for (const { pluginId, decl } of contextItems.values()) { + if (CONTEXT_MENUS[decl.menu] !== menu || !evaluateWhen(decl.when ?? undefined)) continue; + const action = pluginActionId(pluginId, decl.command); + if (!getAction(action)) continue; + // The editor menu runs with the focused editor; the others name their file. + out.push({ kind: "action", action, arg: menu === "editor" ? undefined : paths[0] }); + } + return out; +} + +function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () => void { + if (decl.menu === "tools") { + const action = pluginActionId(pluginId, decl.command); + const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`; + return registerMenuSlotItem("pluginTools", { action, label: title }); + } + const key = `${++menuSeq}`; + contextItems.set(key, { pluginId, decl }); + return () => void contextItems.delete(key); +} + +// --- keymaps ---------------------------------------------------------------------- + +interface KeymapDecl { + command: string; + key: string; + mac?: string | null; +} + +// --- status bar ------------------------------------------------------------------- + +interface StatusDecl { + id: string; + text: string; + tooltip?: string | null; + command?: string | null; + align: "left" | "right"; + priority: number; +} + +// --- settings --------------------------------------------------------------------- + +export interface SettingSchema { + key: string; + type: "boolean" | "string" | "number" | "integer"; + title: string; + description?: string | null; + default: unknown; + options?: string[] | null; + multiline?: boolean; +} + +interface SettingsDecl { + title: string; + properties: SettingSchema[]; +} + +type PluginSettings = Record<string, Record<string, unknown>>; + +/** `settings` with `key` of `pluginId` set (`null`: back to the default). */ +export function withPluginSetting( + settings: PluginSettings | undefined, + pluginId: string, + key: string, + value: unknown, +): PluginSettings { + const own = { ...(settings?.[pluginId] ?? {}) }; + if (value === null) delete own[key]; + else own[key] = value; + const next = { ...(settings ?? {}) }; + if (Object.keys(own).length) next[pluginId] = own; + else delete next[pluginId]; + return next; +} + +function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema }) { + const form = useSettingsForm(); + const all = form.useValue("plugins") as PluginSettings | undefined; + const value = all?.[pluginId]?.[s.key] ?? s.default; + const set = (v: unknown) => form.set("plugins", withPluginSetting(all, pluginId, s.key, v === s.default ? null : v)); + const id = `plugin-setting-${pluginId}-${s.key}`; + const description = s.description ? <div className={styles.muted}>{s.description}</div> : null; + if (s.type === "boolean") + return ( + <div className={styles.settingRow}> + <Checkbox checked={value === true} label={s.title} onChange={set} /> + {description} + </div> + ); + return ( + <div className={styles.settingRow}> + <label htmlFor={id}>{s.title}</label> + {s.options ? ( + <Select + aria-label={s.title} + value={String(value)} + options={s.options.map((o) => ({ value: o, label: o }))} + onChange={set} + /> + ) : ( + <Input + id={id} + type={s.type === "string" ? "text" : "number"} + value={String(value ?? "")} + onChange={(e) => { + const raw = e.target.value; + if (s.type === "string") set(raw); + else if (raw.trim() !== "" && Number.isFinite(Number(raw))) + set(s.type === "integer" ? Math.trunc(Number(raw)) : Number(raw)); + }} + /> + )} + {description} + </div> + ); +} + +function settingsPage(pluginId: string, decl: SettingsDecl) { + return function PluginSettingsPage() { + const properties = useMemo(() => decl.properties, []); + return ( + <div className={styles.settingsPage}> + <p className={styles.muted}> + <T>Settings of the plugin; projects can override them.</T> + </p> + {properties.map((s) => ( + <SettingControl key={s.key} pluginId={pluginId} s={s} /> + ))} + </div> + ); + }; +} + +/** Settings page id of a plugin (dots nest pages, so they become dashes). */ +export const pluginSettingsPageId = (pluginId: string) => `plugins.${pluginId.replace(/\./g, "-")}`; + +// --- views and custom editors ----------------------------------------------------- + +interface ViewDecl { + id: string; + title: string; + icon?: string | null; + location: ToolWindowAnchor; +} + +export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`; + +/** Registers the client side of the P7 points; returns their disposer. */ +export function defineViewUiPoints(): () => void { + const disposers = [ + defineUiPoint<MenuDecl>("menus", { + title: "Menus", + project: ({ pluginId, decl }, { plugin }) => projectMenu(pluginId, decl, plugin), + }), + defineUiPoint<KeymapDecl>("keymaps", { + title: "Shortcuts", + project: ({ pluginId, decl }) => + registerExtensionBindings(pluginActionId(pluginId, decl.command), [ + isMacHost() ? (decl.mac ?? decl.key) : decl.key, + ]), + }), + defineUiPoint<StatusDecl>("statusBar", { + title: "Status Bar Items", + project: ({ pluginId, decl }) => + declareStatusItem(pluginId, decl.id, { + item: { text: decl.text, tooltip: decl.tooltip ?? null, command: decl.command ?? null }, + align: decl.align, + priority: decl.priority, + }), + }), + defineUiPoint<SettingsDecl>("settings", { + title: "Settings", + project: ({ pluginId, decl }) => + registerSettingsPage({ + id: pluginSettingsPageId(pluginId), + title: decl.title, + projectScope: true, + keywords: ["plugin", pluginId, ...decl.properties.flatMap((p) => [p.key, p.title])], + component: settingsPage(pluginId, decl), + }), + }), + defineUiPoint<ViewDecl>("views", { + title: "Tool Windows", + project: ({ pluginId, decl }) => + registerToolWindow({ + id: pluginToolWindowId(pluginId, decl.id), + title: decl.title, + icon: decl.icon ?? "plugin", + anchor: decl.location, + order: 900, + component: () => <PluginView pluginId={pluginId} view={decl.id} label={decl.title} />, + }), + }), + defineUiPoint<CustomEditorDecl>("customEditors", { + title: "Editors", + project: ({ pluginId, decl }) => { + const matches = (path: string) => decl.patterns.some((p) => matchesPattern(p, basename(path))); + const provider = + decl.priority === "default" + ? registerFileEditorProvider({ + id: `plugin.${pluginId}.${decl.id}`, + order: 40, + resolve: (path) => (matches(path) ? customEditorInput(pluginId, decl, path) : null), + }) + : () => {}; + openWith.set(`${pluginId}/${decl.id}`, { pluginId, decl, matches }); + return () => { + provider(); + openWith.delete(`${pluginId}/${decl.id}`); + }; + }, + }), + ]; + return () => disposers.reverse().forEach((d) => d()); +} + +/** Custom editors by `plugin/id`, for Open With. */ +const openWith = new Map<string, { pluginId: string; decl: CustomEditorDecl; matches: (path: string) => boolean }>(); + +/** One-time registrations behind the points: the editor kind and the context menu group. */ +export function startViewUi(openInput: (input: ReturnType<typeof customEditorInput>) => void): () => void { + const disposers = [ + registerEditorKind({ + kind: CUSTOM_EDITOR_KIND, + component: CustomEditor, + title: (input) => basename(input.path ?? input.key), + icon: () => "db-table", + restorable: true, + document: customEditorDocument, + }), + registerMenuGroup({ + id: "plugins", + menus: ["editor", "projectView", "editorTab"], + order: 90, + items: (menu, ctx) => { + const items = pluginMenuItems(menu, ctx.paths); + const path = ctx.paths[0]; + if (menu !== "editor" && path && !ctx.dirs?.[0]) { + const editors = [...openWith.values()].filter((e) => e.matches(path)); + if (editors.length) + items.push({ + label: tx("Open With"), + submenu: editors.map((e) => ({ + label: e.decl.title, + run: () => openInput(customEditorInput(e.pluginId, e.decl, path)), + })), + }); + } + return items; + }, + }), + ]; + return () => disposers.reverse().forEach((d) => d()); +} diff --git a/src/features/plugins/views/PluginView.module.css b/src/features/plugins/views/PluginView.module.css new file mode 100644 index 000000000..bf9fe66e1 --- /dev/null +++ b/src/features/plugins/views/PluginView.module.css @@ -0,0 +1,175 @@ +.view { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 2px 6px; + border-bottom: 1px solid var(--border); +} + +.textButton { + border: none; + background: none; + color: inherit; + font: inherit; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; +} + +.textButton:hover:not(:disabled) { + background: var(--bg-toolbar-hover); +} + +.textButton:disabled { + opacity: 0.5; + cursor: default; +} + +.body { + flex: 1; + min-height: 0; + overflow: auto; + position: relative; +} + +.status { + padding: 2px 8px; + border-top: 1px solid var(--border); + color: var(--fg-muted); + font-size: 12px; +} + +.error { + padding: 4px 8px; + color: var(--danger); + font-size: 12px; +} + +.empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.list { + list-style: none; + margin: 0; + padding: 0; +} + +.listItem { + display: flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 8px; + cursor: default; +} + +.listItem:hover, +.listItem:focus-visible { + background: var(--bg-toolbar-hover); + outline: none; +} + +.detail { + color: var(--fg-muted); +} + +.form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 12px; + max-width: 520px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.fieldLabel { + color: var(--fg-muted); +} + +.textarea { + min-height: 80px; + font: inherit; + font-family: var(--font-mono, monospace); + resize: vertical; +} + +.tableScroller { + height: 100%; + overflow: auto; + position: relative; + font-variant-numeric: tabular-nums; +} + +.tableCanvas { + position: relative; + min-width: 100%; + width: max-content; +} + +.tableRow { + position: absolute; + left: 0; + display: flex; + height: 24px; + min-width: 100%; + border-bottom: 1px solid var(--border); +} + +.tableRow[aria-selected="true"] { + background: var(--bg-tree-selected); +} + +.tableHeader { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-island); + font-weight: 600; +} + +.rowNumber { + flex: 0 0 48px; + padding: 0 6px; + text-align: right; + color: var(--fg-muted); + line-height: 24px; + border-right: 1px solid var(--border); +} + +.cell { + flex: 0 0 160px; + padding: 0 6px; + line-height: 24px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + border-right: 1px solid var(--border); +} + +.tableEmpty { + padding: 32px 12px; + color: var(--fg-muted); +} + +.frame { + border: none; + width: 100%; + height: 100%; + background: transparent; +} diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx new file mode 100644 index 000000000..343c5005a --- /dev/null +++ b/src/features/plugins/views/PluginView.tsx @@ -0,0 +1,480 @@ +/** + * Renders a plugin's declarative view (docs/plugin_plan.md 4.2, L2): the + * plugin returns data (`View`), this draws it with the workbench's own + * components and sends the user's actions back (`ViewEvent`). Used by plugin + * tool windows and custom editors. A refresh comes from the plugin + * (`viewChanged`), from `revision` (a custom editor's document changed), or + * from the table scrolling to rows it hasn't got. + */ +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings"; +import { tx } from "@/platform/i18n"; +import { + Checkbox, + EmptyState, + Icon, + IconButton, + Input, + Select, + Spinner, + Tree, + showContextMenu, + type MenuItem, +} from "@/ui"; +import { pluginsBackend } from "../backend"; +import { FRAME_TAG, frameDocument } from "./frame"; +import { onViewMessage, useViewRevision } from "./viewStore"; +import styles from "./PluginView.module.css"; + +export interface PluginViewProps { + pluginId: string; + view: string; + /** The document of a custom editor. */ + doc?: number | null; + /** Bumped by the owner when the view must render again (document edits). */ + revision?: number; + /** Called after an event was handled (custom editors re-render on the document instead). */ + label?: string; +} + +/** Table rows asked for beyond the visible ones (each side). */ +const OVERSCAN = 50; +const ROW_HEIGHT = 24; + +export function PluginView({ pluginId, view, doc = null, revision = 0, label }: PluginViewProps) { + const [content, setContent] = useState<View | null>(null); + const [error, setError] = useState<string | null>(null); + const [viewport, setViewport] = useState<Viewport | null>(null); + const pluginRevision = useViewRevision(pluginId, view); + const seq = useRef(0); + + useEffect(() => { + const mine = ++seq.current; + pluginsBackend() + .renderView(pluginId, view, viewport, doc) + .then( + (v) => { + if (mine !== seq.current) return; + setContent(v); + setError(null); + }, + (e: unknown) => { + if (mine !== seq.current) return; + setError(e instanceof Error ? e.message : String(e)); + }, + ); + }, [pluginId, view, doc, viewport, revision, pluginRevision]); + + const send = useCallback( + (event: ViewEvent) => { + pluginsBackend() + .viewEvent(pluginId, view, event, doc) + .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))); + }, + [pluginId, view, doc], + ); + + if (!content) + return error ? ( + <EmptyState className={styles.empty} title={tx("The plugin could not show this view")}> + {error} + </EmptyState> + ) : ( + <div className={styles.empty}> + <Spinner label={tx("Loading")} /> + </div> + ); + + return ( + <div className={styles.view} aria-label={label}> + {content.toolbar.length > 0 && ( + <div className={styles.toolbar} role="toolbar"> + {content.toolbar.map((b) => + b.icon ? ( + <IconButton + key={b.id} + icon={b.icon} + title={b.label} + disabled={!b.enabled} + onClick={() => send({ kind: "button", id: b.id })} + /> + ) : ( + <button + key={b.id} + type="button" + className={styles.textButton} + disabled={!b.enabled} + onClick={() => send({ kind: "button", id: b.id })} + > + {b.label} + </button> + ), + )} + </div> + )} + {error && ( + <div className={styles.error} role="alert"> + {error} + </div> + )} + <div className={styles.body}> + <Body content={content} send={send} pluginId={pluginId} view={view} onViewport={setViewport} /> + </div> + {content.status && <div className={styles.status}>{content.status}</div>} + </div> + ); +} + +interface BodyProps { + content: View; + send: (e: ViewEvent) => void; + pluginId: string; + view: string; + onViewport: (v: Viewport) => void; +} + +function Body({ content, send, pluginId, view, onViewport }: BodyProps) { + const body = content.body; + switch (body.kind) { + case "message": + return <EmptyState className={styles.empty} title={body.value} />; + case "tree": + return <TreeBody items={body.value} send={send} />; + case "list": + return ( + <ul className={styles.list} role="listbox"> + {body.value.map((i) => ( + <li + key={i.key} + role="option" + aria-selected={false} + tabIndex={0} + className={styles.listItem} + onClick={() => send({ kind: "select", key: i.key })} + onDoubleClick={() => send({ kind: "activate", key: i.key })} + onKeyDown={(e) => e.key === "Enter" && send({ kind: "activate", key: i.key })} + > + {i.icon && <Icon name={i.icon} size={14} />} + <span>{i.label}</span> + {i.detail && <span className={styles.detail}>{i.detail}</span>} + </li> + ))} + </ul> + ); + case "form": + return ( + <div className={styles.form}> + {body.value.map((f) => ( + <FormField key={f.key} field={f} send={send} /> + ))} + </div> + ); + case "table": + return <TableBody table={body.value} send={send} onViewport={onViewport} />; + case "html": + return <HtmlBody html={body.value} send={send} pluginId={pluginId} view={view} />; + } +} + +// --- tree ------------------------------------------------------------------------- + +interface Node { + item: ViewTreeItem; + children: Node[]; +} + +/** The flat item list as nodes (items naming an unknown parent are roots). */ +export function treeNodes(items: readonly ViewTreeItem[]): Node[] { + const byKey = new Map<string, Node>(); + const roots: Node[] = []; + for (const item of items) { + const node: Node = { item, children: [] }; + if (!byKey.has(item.key)) byKey.set(item.key, node); + const parent = item.parent != null ? byKey.get(item.parent) : undefined; + (parent ? parent.children : roots).push(node); + } + return roots; +} + +function TreeBody({ items, send }: { items: ViewTreeItem[]; send: (e: ViewEvent) => void }) { + const roots = useMemo(() => treeNodes(items), [items]); + const expanded = useMemo(() => new Set(items.filter((i) => i.expanded).map((i) => i.key)), [items]); + return ( + <Tree<Node> + roots={roots} + getKey={(n) => n.item.key} + getLabel={(n) => n.item.label} + getChildren={(n) => (n.children.length ? n.children : n.item.expandable ? undefined : [])} + isExpandable={(n) => n.item.expandable || n.children.length > 0} + getIcon={(n) => n.item.icon ?? undefined} + getDetail={(n) => n.item.detail ?? undefined} + expanded={expanded} + onExpandedChange={(_, node, isExpanded) => + send(isExpanded ? { kind: "expand", key: node.item.key } : { kind: "collapse", key: node.item.key }) + } + onSelectionChange={(_, lead) => lead && send({ kind: "select", key: lead.item.key })} + onActivate={(n) => send({ kind: "activate", key: n.item.key })} + multiSelect={false} + /> + ); +} + +// --- form ------------------------------------------------------------------------- + +function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) => void }) { + const commit = (value: string) => send({ kind: "field", key: field.key, value }); + const v = field.value; + const id = `plugin-field-${field.key}`; + return ( + <div className={styles.field}> + {v.kind !== "checkbox" && ( + <label className={styles.fieldLabel} htmlFor={id}> + {field.label} + </label> + )} + {v.kind === "text" && <TextField id={id} initial={v.value} onCommit={commit} />} + {v.kind === "multiline" && <TextField id={id} initial={v.value} onCommit={commit} multiline />} + {v.kind === "checkbox" && ( + <Checkbox checked={v.value} label={field.label} onChange={(on) => commit(on ? "true" : "false")} /> + )} + {v.kind === "choice" && ( + <Select + aria-label={field.label} + value={v.value.selected} + options={v.value.options.map((o) => ({ value: o, label: o }))} + onChange={commit} + /> + )} + </div> + ); +} + +function TextField({ + id, + initial, + onCommit, + multiline, +}: { + id: string; + initial: string; + onCommit: (v: string) => void; + multiline?: boolean; +}) { + const [value, setValue] = useState(initial); + useEffect(() => setValue(initial), [initial]); + const commit = () => value !== initial && onCommit(value); + return multiline ? ( + <textarea + id={id} + className={styles.textarea} + value={value} + spellCheck={false} + onChange={(e) => setValue(e.target.value)} + onBlur={commit} + /> + ) : ( + <Input + id={id} + value={value} + onChange={(e) => setValue(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === "Enter" && commit()} + /> + ); +} + +// --- table ------------------------------------------------------------------------ + +/** The rows to ask for when `first..first + visible` is shown. */ +export function wantedViewport(first: number, visible: number, total: number): Viewport { + const start = Math.max(0, first - OVERSCAN); + const end = Math.min(total, first + visible + OVERSCAN); + return { firstRow: start, rows: Math.max(0, end - start) }; +} + +function TableBody({ + table, + send, + onViewport, +}: { + table: ViewTable; + send: (e: ViewEvent) => void; + onViewport: (v: Viewport) => void; +}) { + const scroller = useRef<HTMLDivElement>(null); + const [scrollTop, setScrollTop] = useState(0); + const [height, setHeight] = useState(400); + const [selected, setSelected] = useState<Set<number>>(new Set()); + const [editing, setEditing] = useState<{ row: number; column: number } | null>(null); + + useEffect(() => { + const el = scroller.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => setHeight(el.clientHeight || 400)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const first = Math.floor(scrollTop / ROW_HEIGHT); + const visible = Math.ceil(height / ROW_HEIGHT) + 1; + const loadedEnd = table.firstRow + table.rows.length; + const shownEnd = Math.min(table.totalRows, first + visible); + // Ask for rows the scroll position shows but the last render didn't include. + useEffect(() => { + if (first < table.firstRow || shownEnd > loadedEnd) onViewport(wantedViewport(first, visible, table.totalRows)); + }, [first, visible, shownEnd, loadedEnd, table.firstRow, table.totalRows, onViewport]); + + const rowMenu = (row: number, e: React.MouseEvent) => { + e.preventDefault(); + const rows = selected.has(row) ? [...selected].sort((a, b) => a - b) : [row]; + if (!selected.has(row)) setSelected(new Set([row])); + const items: MenuItem[] = [ + { label: tx("Insert Row Above"), icon: "plus", run: () => send({ kind: "insertRow", before: row }) }, + { label: tx("Insert Row Below"), run: () => send({ kind: "insertRow", before: row + 1 }) }, + { kind: "separator" }, + { + label: rows.length > 1 ? tx`Delete ${rows.length} Rows` : tx("Delete Row"), + icon: "trash", + danger: true, + run: () => send({ kind: "deleteRows", rows }), + }, + ]; + if (table.editable) showContextMenu(e, items); + }; + + const rows: React.ReactNode[] = []; + for (let r = Math.max(first, table.firstRow); r < Math.min(shownEnd, loadedEnd); r++) { + const cells = table.rows[r - table.firstRow] ?? []; + rows.push( + <div + key={r} + role="row" + aria-rowindex={r + 2} + aria-selected={selected.has(r)} + className={styles.tableRow} + style={{ top: (r + 1) * ROW_HEIGHT }} + onClick={(e) => + setSelected((s) => { + if (!(e.metaKey || e.ctrlKey)) return new Set([r]); + const next = new Set(s); + if (next.has(r)) next.delete(r); + else next.add(r); + return next; + }) + } + onContextMenu={(e) => rowMenu(r, e)} + > + <div className={styles.rowNumber} role="rowheader"> + {r + 1} + </div> + {table.columns.map((_, c) => ( + <div + key={c} + role="gridcell" + className={styles.cell} + title={cells[c]} + onDoubleClick={() => table.editable && setEditing({ row: r, column: c })} + > + {editing?.row === r && editing.column === c ? ( + <CellEditor + initial={cells[c] ?? ""} + onDone={(value) => { + setEditing(null); + if (value !== null && value !== (cells[c] ?? "")) send({ kind: "cell", row: r, column: c, value }); + }} + /> + ) : ( + cells[c] + )} + </div> + ))} + </div>, + ); + } + + return ( + <div + ref={scroller} + className={styles.tableScroller} + role="grid" + aria-rowcount={table.totalRows + 1} + aria-colcount={table.columns.length} + onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)} + > + <div className={styles.tableCanvas} style={{ height: (table.totalRows + 1) * ROW_HEIGHT }}> + <div className={`${styles.tableRow} ${styles.tableHeader}`} role="row" aria-rowindex={1}> + <div className={styles.rowNumber} /> + {table.columns.map((c, i) => ( + <div key={i} role="columnheader" className={styles.cell} title={c}> + {c} + </div> + ))} + </div> + {rows} + </div> + {table.totalRows === 0 && <div className={styles.tableEmpty}>{tx("No rows")}</div>} + </div> + ); +} + +function CellEditor({ initial, onDone }: { initial: string; onDone: (value: string | null) => void }) { + const [value, setValue] = useState(initial); + const done = useRef(false); + const finish = (v: string | null) => { + if (done.current) return; + done.current = true; + onDone(v); + }; + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === "Enter") finish(value); + else if (e.key === "Escape") finish(null); + }; + return ( + <Input + autoFocus + variant="bare" + aria-label={tx("Cell value")} + value={value} + onChange={(e) => setValue(e.target.value)} + onKeyDown={onKeyDown} + onBlur={() => finish(value)} + /> + ); +} + +// --- html ------------------------------------------------------------------------- + +function HtmlBody({ + html, + send, + pluginId, + view, +}: { + html: string; + send: (e: ViewEvent) => void; + pluginId: string; + view: string; +}) { + const frame = useRef<HTMLIFrameElement>(null); + const dark = typeof document !== "undefined" && document.documentElement.dataset.theme === "dark"; + const srcDoc = useMemo(() => frameDocument(html, dark), [html, dark]); + + useEffect(() => { + const onMessage = (e: MessageEvent) => { + if (e.source !== frame.current?.contentWindow) return; + const data = e.data as { tag?: unknown; text?: unknown } | null; + if (data?.tag === FRAME_TAG && typeof data.text === "string") send({ kind: "message", text: data.text }); + }; + window.addEventListener("message", onMessage); + const off = onViewMessage(pluginId, view, (text) => + frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, text }, "*"), + ); + return () => { + window.removeEventListener("message", onMessage); + off(); + }; + }, [pluginId, view, send]); + + // No `allow-same-origin`: an opaque origin without access to the IDE. + return <iframe ref={frame} className={styles.frame} title={view} sandbox="allow-scripts" srcDoc={srcDoc} />; +} diff --git a/src/features/plugins/views/customEditor.tsx b/src/features/plugins/views/customEditor.tsx new file mode 100644 index 000000000..c5574e15a --- /dev/null +++ b/src/features/plugins/views/customEditor.tsx @@ -0,0 +1,234 @@ +/** + * Plugin custom editors (`[[contributes.customEditors]]`): a file matching + * the declared patterns opens in a tab whose content the plugin renders from + * the file's document. The tab joins the document shared by every editor of + * the file (`attachDocument`), so: + * + * - the plugin edits the document (versioned `apply-edits`), and a text tab + * of the same file shows the change at once — and the other way round; + * - Undo / Redo work on the document's one history: the text tab's when one + * is open, else this tab's own; + * - unsaved changes, Save, and the close prompts are the document's. + */ +import { history } from "@codemirror/commands"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { useEffect, useState } from "react"; +import { attachDocument, type DocumentAttachment } from "@/editor/api"; +import { tx } from "@/platform/i18n"; +import { EmptyState, Spinner } from "@/ui"; +import { + editorService, + notifyEditorDocumentChanged, + type EditorDocumentLifecycle, + type EditorInput, +} from "@/workbench/contributions"; +import { PluginView } from "./PluginView"; +import styles from "./PluginView.module.css"; + +export const CUSTOM_EDITOR_KIND = "pluginEditor"; + +export interface CustomEditorData { + pluginId: string; + editorId: string; + title: string; +} + +export interface CustomEditorDecl { + id: string; + title: string; + patterns: string[]; + priority: "default" | "option"; +} + +export function customEditorInput(pluginId: string, decl: CustomEditorDecl, path: string): EditorInput { + const data: CustomEditorData = { pluginId, editorId: decl.id, title: decl.title }; + return { kind: CUSTOM_EDITOR_KIND, key: `${CUSTOM_EDITOR_KIND}:${pluginId}/${decl.id}:${path}`, path, data }; +} + +/** File name glob (`*`, `?`, `[abc]`, `{a,b}`), case-insensitive. */ +export function matchesPattern(pattern: string, name: string): boolean { + let re = ""; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]!; + if (c === "*") re += "[^/]*"; + else if (c === "?") re += "[^/]"; + else if (c === "{") { + const end = pattern.indexOf("}", i); + if (end < 0) re += "\\{"; + else { + re += `(?:${pattern + .slice(i + 1, end) + .split(",") + .map((p) => p.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")) + .join("|")})`; + i = end; + } + } else if (c === "[") { + const end = pattern.indexOf("]", i); + if (end < 0) re += "\\["; + else { + re += pattern.slice(i, end + 1); + i = end; + } + } else re += c.replace(/[.+^$()|\\]/g, "\\$&"); + } + return new RegExp(`^${re}$`, "i").test(name); +} + +export function basename(path: string): string { + return path.split(/[\\/]/).pop() ?? path; +} + +// --- sessions: one per document shown by a custom editor (splits share it) ----------- + +interface Session { + attachment: DocumentAttachment; + /** Holds this tab's undo history and receives the document's changes. */ + view: EditorView; + revision: number; + listeners: Set<(revision: number) => void>; + cancelTimer: () => void; +} + +const sessions = new Map<string, Promise<Session>>(); + +/** Edits settle in the backend before the plugin renders the table again. */ +const RENDER_DELAY_MS = 120; + +function session(input: EditorInput): Promise<Session> { + let s = sessions.get(input.key); + if (!s) { + s = attachDocument(input.path ?? "").then((attachment) => { + const listeners = new Set<(revision: number) => void>(); + let timer: ReturnType<typeof setTimeout> | null = null; + const changed = () => { + notifyEditorDocumentChanged(); + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void attachment.synced().then(() => { + state.revision++; + for (const l of listeners) l(state.revision); + }); + }, RENDER_DELAY_MS); + }; + const view = new EditorView({ + state: EditorState.create({ + doc: attachment.text(), + extensions: [attachment.extension, history(), EditorView.updateListener.of((u) => u.docChanged && changed())], + }), + }); + const state: Session = { + attachment, + view, + revision: 0, + listeners, + cancelTimer: () => timer && clearTimeout(timer), + }; + return state; + }); + sessions.set(input.key, s); + s.catch(() => sessions.delete(input.key)); + } + return s; +} + +async function withSession<T>(input: EditorInput, f: (s: Session) => T | Promise<T>): Promise<T | undefined> { + const s = sessions.get(input.key); + return s ? f(await s) : undefined; +} + +const settled = new Map<string, Session>(); + +/** Lifecycle of the tabs' document (dirty mark, Save, close prompts, Undo). */ +export const customEditorDocument: EditorDocumentLifecycle = { + isDirty: (input) => settled.get(input.key)?.attachment.dirty() ?? false, + flush: async (input) => { + await withSession(input, (s) => s.attachment.synced()); + }, + save: async (input) => { + await withSession(input, (s) => s.attachment.save()); + }, + prepareClose: async (input) => { + await withSession(input, (s) => s.attachment.synced()); + }, + cancelClose: () => {}, + discard: async () => { + // The document's unsaved changes go when it closes (`close` releases it). + }, + close: async (input) => { + const s = sessions.get(input.key); + sessions.delete(input.key); + if (!s) return; + const state = await s; + settled.delete(input.key); + state.cancelTimer(); + state.attachment.release(); + state.view.destroy(); + }, + undo: async (input) => { + await withSession(input, (s) => void s.attachment.undo()); + }, + redo: async (input) => { + await withSession(input, (s) => void s.attachment.redo()); + }, +}; + +export function CustomEditor({ input }: { input: EditorInput }) { + const data = input.data as CustomEditorData; + const [state, setState] = useState<{ doc: number; revision: number } | { error: string } | null>(null); + + useEffect(() => { + let live = true; + let off = () => {}; + session(input).then( + (s) => { + if (!live) return; + settled.set(input.key, s); + const listener = (revision: number) => setState({ doc: s.attachment.doc, revision }); + s.listeners.add(listener); + off = () => s.listeners.delete(listener); + setState({ doc: s.attachment.doc, revision: s.revision }); + }, + (e: unknown) => live && setState({ error: e instanceof Error ? e.message : String(e) }), + ); + return () => { + live = false; + off(); + }; + }, [input]); + + if (!state) + return ( + <div className={styles.empty}> + <Spinner label={tx("Loading")} /> + </div> + ); + if ("error" in state) + return ( + <EmptyState className={styles.empty} title={tx`Cannot open ${basename(input.path ?? "")}`}> + {state.error} + </EmptyState> + ); + return ( + <div className={styles.view}> + <PluginView + pluginId={data.pluginId} + view={data.editorId} + doc={state.doc} + revision={state.revision} + label={tx`${data.title}: ${basename(input.path ?? "")}`} + /> + <div className={styles.status}> + <button + type="button" + className={styles.textButton} + onClick={() => input.path && void editorService().openFile(input.path, { asText: true })} + > + {tx("Open as Text")} + </button> + </div> + </div> + ); +} diff --git a/src/features/plugins/views/frame.ts b/src/features/plugins/views/frame.ts new file mode 100644 index 000000000..8ea26b98d --- /dev/null +++ b/src/features/plugins/views/frame.ts @@ -0,0 +1,40 @@ +/** + * The document of a view's `html` body. It runs in an `<iframe sandbox= + * "allow-scripts">` (an opaque origin: no cookies, storage, IDE API or parent + * access) under a CSP that allows only its own inline scripts and styles and + * `data:` images — no network. `openidea.postMessage` / `openidea.onMessage` + * are its only way to the plugin. + */ + +export const FRAME_CSP = + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:"; + +/** Marks frame ↔ workbench messages. */ +export const FRAME_TAG = "openidea-view"; + +const BRIDGE = ` +(() => { + const listeners = []; + window.addEventListener("message", (e) => { + if (e.source !== window.parent || !e.data || e.data.tag !== "${FRAME_TAG}") return; + for (const l of listeners) { try { l(String(e.data.text)); } catch (err) { console.error(err); } } + }); + window.openidea = Object.freeze({ + postMessage: (text) => window.parent.postMessage({ tag: "${FRAME_TAG}", text: String(text) }, "*"), + onMessage: (fn) => { if (typeof fn === "function") listeners.push(fn); }, + }); +})();`; + +/** `html` wrapped with the CSP, the message bridge and base styles. */ +export function frameDocument(html: string, dark: boolean): string { + const scheme = dark ? "dark" : "light"; + return [ + '<!doctype html><html><head><meta charset="utf-8">', + `<meta http-equiv="Content-Security-Policy" content="${FRAME_CSP}">`, + `<style>:root{color-scheme:${scheme}}body{margin:8px;font:13px system-ui,sans-serif}</style>`, + `<script>${BRIDGE}</script>`, + "</head><body>", + html, + "</body></html>", + ].join(""); +} diff --git a/src/features/plugins/views/viewStore.ts b/src/features/plugins/views/viewStore.ts new file mode 100644 index 000000000..e8573d521 --- /dev/null +++ b/src/features/plugins/views/viewStore.ts @@ -0,0 +1,39 @@ +/** + * Client state of plugin views: a revision per (plugin, view) that bumps when + * the plugin asks for a refresh (`viewChanged`), and the messages its code + * sends to the script of an `html` body (`viewMessage`). + */ +import { create } from "zustand"; + +export const viewKey = (pluginId: string, view: string) => `${pluginId}/${view}`; + +export const useViewRevisions = create<{ revisions: Record<string, number> }>(() => ({ revisions: {} })); + +/** The plugin's view content changed: rendered views fetch it again. */ +export function bumpView(pluginId: string, view: string) { + const key = viewKey(pluginId, view); + useViewRevisions.setState((s) => ({ revisions: { ...s.revisions, [key]: (s.revisions[key] ?? 0) + 1 } })); +} + +export function useViewRevision(pluginId: string, view: string): number { + return useViewRevisions((s) => s.revisions[viewKey(pluginId, view)] ?? 0); +} + +type MessageListener = (message: string) => void; +const messageListeners = new Map<string, Set<MessageListener>>(); + +/** Delivers a plugin's message to the frames of its view. */ +export function deliverViewMessage(pluginId: string, view: string, message: string) { + for (const l of messageListeners.get(viewKey(pluginId, view)) ?? []) l(message); +} + +export function onViewMessage(pluginId: string, view: string, listener: MessageListener): () => void { + const key = viewKey(pluginId, view); + const set = messageListeners.get(key) ?? new Set(); + set.add(listener); + messageListeners.set(key, set); + return () => { + set.delete(listener); + if (!set.size) messageListeners.delete(key); + }; +} diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 16002ef12..b0f1fa9d8 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -37,7 +37,9 @@ import { Tree, acquirePopup, fileIconName, + useFileIconRevision, folderIconName, + themedFolderIconName, showContextMenu, SymbolBadge, type DragSession, @@ -125,6 +127,7 @@ function iconFor( root: string | null, build: BuildPresentation, detected: readonly RootFolder[], + expanded: boolean, ): string { if (node.placeholder) return node.placeholder === "dir" ? "folder" : "file"; if (node.icon) return node.icon; @@ -136,7 +139,7 @@ function iconFor( const key = rel !== null && build.modules.has(rel) ? rel : node.path; const module = root ? moduleFolderIcon(key, node.root, build.modules) : null; if (module) return module; - return node.root ? "module" : "folder"; + return node.root ? "module" : (themedFolderIconName(node.label, expanded) ?? "folder"); } return fileIconName(node.label); } @@ -267,15 +270,18 @@ const Row = memo(function Row({ onDoubleClick, onPointerDown, }: RowProps) { + // Memoised: redraw this row itself when the file icon theme changes. + useFileIconRevision(); const deco = useFileDecoration(node.path); const roots = useEffectiveRoots(); const root = useProjectTree((s) => s.root); const detected = useDetectedRoots(); const build = useProjectPresentation(); const file = useJvmPresentation(node.dir || node.placeholder ? "" : node.path, node.entry?.mtimeMs); + const expanded = useProjectTree((s) => node.dir && s.expanded.has(node.key)); const icon = node.dir || node.placeholder || node.icon - ? iconFor(node, roots, root, build, detected) + ? iconFor(node, roots, root, build, detected, expanded) : presentationIcon(node.path, file, roots, root, build.spring); const moduleFolder = node.dir && !node.placeholder && !!root && isModuleFolder(relativePath(root, node.path), node.root, build.modules); @@ -396,6 +402,8 @@ function folderAt(container: HTMLElement | null, x: number, y: number): { key: s export function ProjectView() { const txTemplate = useTxTemplate(); + // Rows compute their file icons while rendering: redraw when the icon theme changes. + useFileIconRevision(); const root = useProjectTree((s) => s.root); const model = useProjectTree(selectModel); const expanded = useProjectTree((s) => s.expanded); diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index c5c50460f..650182818 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -1,7 +1,7 @@ import { useFilePresentation } from "@/platform/filePresentation"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import clsx from "clsx"; -import { actionToggled, executeAction, getAction } from "@/platform/actions"; +import { actionToggled, executeAction, executeActionFrom, getAction } from "@/platform/actions"; import type { ProjectFileSet } from "@/platform/ipc"; import { tx as translate, useTx } from "@/platform/i18n"; import { hasPrimaryModifier, shortcutLabel } from "@/platform/keymap"; @@ -281,10 +281,10 @@ function SearchEverywhereDialog() { if (!row.enabled) return; pushRecentAction(row.id); if (getAction(row.id)?.toggled) { - void executeAction(row.id).then(() => bumpToggles()); + void executeActionFrom("search", row.id).then(() => bumpToggles()); return; } - return closeThen(() => void executeAction(row.id)); + return closeThen(() => void executeActionFrom("search", row.id)); case "setting": return closeThen(() => void executeAction("file.settings", row.pageId)); } @@ -761,7 +761,7 @@ function ResultRow({ aria-label={row.title} onChange={() => { pushRecentAction(row.id); - void executeAction(row.id).then(onToggled); + void executeActionFrom("search", row.id).then(onToggled); }} /> </span> diff --git a/src/platform/actions.ts b/src/platform/actions.ts index b3d017887..19f3ed32c 100644 --- a/src/platform/actions.ts +++ b/src/platform/actions.ts @@ -140,8 +140,13 @@ export function actionToggled(action: Action | undefined): boolean | undefined { } } -type ActionListener = (id: string, arg: unknown) => void; +/** How the user started an action (for listeners such as plugins' `action-performed`). */ +export type ActionSource = "keyboard" | "menu" | "toolbar" | "contextMenu" | "search" | "other"; + +type ActionListener = (id: string, arg: unknown, source: ActionSource) => void; const listeners = new Set<ActionListener>(); +/** Set by `executeActionFrom` for the duration of the synchronous dispatch. */ +let currentSource: ActionSource = "other"; /** Actions running synchronously inside another action's `run`. */ let nesting = 0; @@ -166,7 +171,7 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean> if (nesting === 0) for (const l of listeners) try { - l(id, arg); + l(id, arg, currentSource); } catch (e) { console.error("action listener failed", e); } @@ -184,3 +189,14 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean> } return true; } + +/** `executeAction`, telling listeners how the user started it. */ +export function executeActionFrom(source: ActionSource, id: string, arg?: unknown): Promise<boolean> { + const previous = currentSource; + currentSource = source; + try { + return executeAction(id, arg); + } finally { + currentSource = previous; + } +} diff --git a/src/platform/filePresentation.ts b/src/platform/filePresentation.ts index 26ffe2952..b0e300849 100644 --- a/src/platform/filePresentation.ts +++ b/src/platform/filePresentation.ts @@ -1,7 +1,7 @@ /** Visible-file JVM presentation: one bounded IPC batch, shared cache and watcher. */ import { useEffect, useMemo } from "react"; import { create } from "zustand"; -import { fileIconName, type IconName } from "@/ui/icons"; +import { fileIconName, useFileIconRevision, type IconName } from "@/ui/icons"; import { call, commands, isTauri, onIdeEvent, type JvmPresentation } from "./ipc"; import { basename, relativePath, useProject } from "./project"; import { rootFor, type RootFolder } from "./languageSettings"; @@ -157,6 +157,7 @@ export function useJvmPresentation(path: string, mtime?: number | null) { } export function useFilePresentation(path: string, mtime?: number | null) { + useFileIconRevision(); const info = useJvmPresentation(path, mtime); const roots = useEffectiveRoots(); const root = useProject((s) => s.project?.root ?? null); @@ -173,6 +174,7 @@ export function useFilePresentationIcons(paths: readonly string[]) { return () => disposers.forEach((dispose) => dispose()); }, [key]); const entries = cache((s) => s.entries); + const iconRevision = useFileIconRevision(); const roots = useEffectiveRoots(); const root = useProject((s) => s.project?.root ?? null); const build = useProjectPresentation(); @@ -184,6 +186,7 @@ export function useFilePresentationIcons(paths: readonly string[]) { presentationIcon(path, entries.get(path), roots, root, build.spring), ]), ), - [key, entries, roots, root, build.spring], + // eslint-disable-next-line react-hooks/exhaustive-deps -- iconRevision: the file icon theme changed + [key, entries, roots, root, build.spring, iconRevision], ); } diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index a99bd83c9..91bf254d9 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -11,7 +11,7 @@ * optionally with modifiers (`Alt+Button4`); they are single strokes. */ import { create } from "zustand"; -import { getAction, isActionKeyEnabled, executeAction } from "./actions"; +import { getAction, isActionKeyEnabled, executeActionFrom } from "./actions"; import { getContext } from "./context"; import { intellijMacos } from "./keymaps/intellij-macos"; import { intellijWindows } from "./keymaps/intellij-windows"; @@ -70,9 +70,11 @@ interface KeymapState { preset: string; /** User overrides: action id → bindings (empty array removes defaults). */ overrides: Keymap; + /** Extensions' default bindings (plugins), below the preset. */ + extensions: Keymap; } -export const useKeymap = create<KeymapState>(() => ({ preset: defaultPreset(), overrides: {} })); +export const useKeymap = create<KeymapState>(() => ({ preset: defaultPreset(), overrides: {}, extensions: {} })); const MOUSE_KEY_RE = /^Button(\d+)$/i; @@ -189,17 +191,61 @@ export function suspendKeyboardDispatch(): () => void { } export function bindingsFor(actionId: string): string[] { - const { preset, overrides } = useKeymap.getState(); - return overrides[actionId] ?? PRESETS[preset]?.[actionId] ?? []; + const { preset, overrides, extensions } = useKeymap.getState(); + return overrides[actionId] ?? PRESETS[preset]?.[actionId] ?? usableExtensionBindings(extensions)[actionId] ?? []; } /** Every (binding, actionId) pair of the effective keymap. */ export function effectiveBindings(): Array<[string, string]> { - const { preset, overrides } = useKeymap.getState(); - const merged: Keymap = { ...PRESETS[preset], ...overrides }; + const { preset, overrides, extensions } = useKeymap.getState(); + const merged: Keymap = { ...usableExtensionBindings(extensions), ...PRESETS[preset], ...overrides }; return Object.entries(merged).flatMap(([id, keys]) => keys.map((k) => [normalizeBinding(k), id] as [string, string])); } +let extensionCache: { source: Keymap; preset: string; overrides: Keymap; usable: Keymap } | null = null; + +/** + * Plugin default bindings that stay: those of actions the preset and the + * user leave unbound, on keystrokes nothing else uses (the IDE's own + * shortcuts always win). + */ +function usableExtensionBindings(extensions: Keymap | undefined = {}): Keymap { + const { preset, overrides } = useKeymap.getState(); + if ( + extensionCache?.source === extensions && + extensionCache.preset === preset && + extensionCache.overrides === overrides + ) + return extensionCache.usable; + const builtIn: Keymap = { ...PRESETS[preset], ...overrides }; + const taken = new Set(Object.values(builtIn).flatMap((keys) => keys.map(normalizeBinding))); + const usable: Keymap = {}; + for (const [id, keys] of Object.entries(extensions)) { + if (id in builtIn) continue; + const free = keys.filter((k) => !taken.has(normalizeBinding(k))); + for (const k of free) taken.add(normalizeBinding(k)); + if (free.length) usable[id] = free; + } + extensionCache = { source: extensions, preset, overrides, usable }; + return usable; +} + +/** + * Default bindings contributed by an extension (a plugin's `keymaps`), below + * the preset and the user's: dropped when the keystroke is already taken. + * Returns a disposer. + */ +export function registerExtensionBindings(actionId: string, bindings: string[]): () => void { + const valid = bindings.filter(isValidBinding); + useKeymap.setState((s) => ({ extensions: { ...(s.extensions ?? {}), [actionId]: valid } })); + return () => + useKeymap.setState((s) => { + if (s.extensions?.[actionId] !== valid) return s; + const { [actionId]: _, ...rest } = s.extensions ?? {}; + return { extensions: rest }; + }); +} + const MOD_ORDER = MODIFIERS; /** Canonical form: modifiers in fixed order, so `Shift+Meta+o` == `Meta+Shift+O`. */ @@ -402,7 +448,7 @@ export function isAltGrTyping( function runIfEnabled(id: string): boolean { const action = getAction(id); if (!action || !isActionKeyEnabled(action)) return false; - void executeAction(id); + void executeActionFrom("keyboard", id); return true; } @@ -477,7 +523,7 @@ export function installKeyboardDispatcher( e.preventDefault(); e.stopPropagation(); tap.keyUsed = true; - void executeAction(id); + void executeActionFrom("keyboard", id); return true; } return false; @@ -526,7 +572,7 @@ export function installKeyboardDispatcher( e.preventDefault(); e.stopPropagation(); reset(); - void executeAction(id); + void executeActionFrom("keyboard", id); return; } } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index cafd9a141..b7aa0b6d5 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -670,7 +670,6 @@ "Detected from the toolchain": "从工具链检测到", "Detecting installations": "正在检测安装", "Detecting…": "正在检测…", - "Dev plugin": "开发插件", "Diff": "差异", "Different tool window background": "不同的工具窗口背景", "Direction": "方向", @@ -3730,8 +3729,6 @@ "Macro '{name}' stopped": "宏 '{name}' 已停止", "Id": "ID", "Enable": "启用", - "{name}: the editor is read-only": "{name}:编辑器为只读", - "{name}: no editor to insert into": "{name}:没有可插入的编辑器", "The plugin failed to start.": "插件启动失败。", "Move Directory…": "移动目录…", "Cannot move {name}": "无法移动 {name}", @@ -5710,5 +5707,68 @@ "What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)", "Build and run using": "构建和运行使用", "Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath.": "Gradle 以 JavaExec 任务在源集的运行时类路径上运行主类(包括 Gradle 的资源处理)。OpenIDEA 在 Java 语言服务器的类路径上启动它。", - "Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径" + "Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径", + "Needs permission": "需要授权", + "linked": "已链接", + "Revoke {id}": "撤销 {id}", + "Allow {id}": "允许 {id}", + "This version asks for new permissions. It stays off until you allow them.": "此版本请求了新的权限。在你允许之前它保持停用。", + "Plugins from a project folder run only after you allow them.": "来自项目文件夹的插件需要你允许后才会运行。", + "Review Permissions…": "查看权限…", + "Statistics": "统计", + "Activated {activations}× (last in {toFixed} ms), {calls} calls, {errors} errors, slowest {toFixed} ms": "已激活 {activations} 次(最近一次 {toFixed} 毫秒),调用 {calls} 次,错误 {errors} 次,最慢 {toFixed} 毫秒", + "Recent calls p95 {toFixed} ms; {crashes} crashes, {slowCalls} editor results too late, reclaimed {reclaims}× when idle": "近期调用 p95 {toFixed} 毫秒;崩溃 {crashes} 次,编辑器结果超时 {slowCalls} 次,空闲回收 {reclaims} 次", + "Linked plugin": "链接的插件", + "Project plugin": "项目插件", + "Signed by {publisher}": "由 {publisher} 签名", + "Unsigned": "未签名", + "Roll Back to {previousVersion}": "回滚到 {previousVersion}", + "Install Plugin from Package File…": "从插件包文件安装…", + "Marketplace index URL": "插件市场索引 URL", + "Refresh Marketplace": "刷新插件市场", + "Update to {version}": "更新到 {version}", + "Marketplace": "插件市场", + "Could not download plugin": "无法下载插件", + "Rolled back {name} to {version}": "已将 {name} 回滚到 {version}", + "Could not roll back": "无法回滚", + "Could not open the package": "无法打开插件包", + "Marketplace index URL (https://…/index.json or a folder path)": "插件市场索引 URL(https://…/index.json 或文件夹路径)", + "Settings of the plugin; projects can override them.": "插件的设置;项目可以覆盖。", + "Menus": "菜单", + "Shortcuts": "快捷键", + "Status Bar Items": "状态栏项", + "Editors": "编辑器", + "Open With": "打开方式", + "The plugin could not show this view": "插件无法显示此视图", + "Loading": "加载中", + "Insert Row Above": "在上方插入行", + "Insert Row Below": "在下方插入行", + "Delete {length} Rows": "删除 {length} 行", + "Delete Row": "删除行", + "No rows": "没有行", + "Cell value": "单元格的值", + "{title}: {basename}": "{title}:{basename}", + "Secrets": "密钥", + "Stored": "已保存", + "Not set": "未设置", + "Replace…": "替换…", + "Value of {label}": "{label} 的值", + "The plugin can only send these with its network requests; it cannot read them.": "插件只能在网络请求中发送这些值,无法读取它们。", + "Editor Providers": "编辑器提供者", + "Linked for development": "为开发而链接", + "Low": "低", + "Medium": "中", + "High": "高", + "Install {name} {version}?": "安装 {name} {version}?", + "Allow {name} to run?": "允许 {name} 运行?", + "Allow": "允许", + "This package is not signed. Only continue if you trust where it came from.": "此软件包未签名。只有在信任其来源时才继续。", + "This plugin needs no special permissions.": "此插件不需要特殊权限。", + "Requested permissions": "请求的权限", + "Required": "必需", + "High-risk permissions are equivalent to running arbitrary code on this machine.": "高风险权限相当于在这台计算机上运行任意代码。", + "{icons} icons": "{icons} 个图标", + "Use Default Icons": "使用默认图标", + "Apply icon theme {label}": "应用图标主题 {label}", + "File Icon Themes": "文件图标主题" } diff --git a/src/ui/Menu.tsx b/src/ui/Menu.tsx index 7ccaf8d30..be637b6b6 100644 --- a/src/ui/Menu.tsx +++ b/src/ui/Menu.tsx @@ -13,7 +13,7 @@ import { } from "react"; import clsx from "clsx"; import { create } from "zustand"; -import { getAction, isActionEnabled, executeAction, actionToggled } from "@/platform/actions"; +import { getAction, isActionEnabled, executeActionFrom, actionToggled } from "@/platform/actions"; import { useContextKeys } from "@/platform/context"; import { shortcutLabel } from "@/platform/keymap"; import { Icon, type IconName } from "./icons"; @@ -84,7 +84,7 @@ export function resolveMenuItems(items: MenuItem[]): Resolved[] { shortcut: shortcutLabel(item.action), disabled: !!item.disabled || !isActionEnabled(a), checked: actionToggled(a), - run: () => void executeAction(item.action, item.arg), + run: () => void executeActionFrom("menu", item.action, item.arg), }); return; } diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index c1bafc8f5..6ea79a905 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -21,6 +21,7 @@ */ import { useId, useMemo, type CSSProperties } from "react"; import clsx from "clsx"; +import { create } from "zustand"; import styles from "./icons.module.css"; const RAW = import.meta.glob<string>(["./expui/**/*.svg", "./openidea/**/*.svg"], { @@ -537,10 +538,64 @@ export type IconName = BaseName | `symbol-${SymbolKind}`; export const ICON_NAMES = Object.keys(ICONS) as BaseName[]; function iconPath(name: string): string | undefined { + if (name.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.has(name) ? name : undefined; if (name.startsWith("symbol-")) return SYMBOL_ICONS[name.slice(7) as SymbolKind]; return (ICONS as Record<string, string>)[name]; } +// --------------------------------------------------------------------------- +// Icons supplied at runtime (plugin icon themes). The markup was sanitised by +// the backend (`ide_host::plugins::sanitize_svg`); names are `ext:<source>/<icon>`. + +const EXTERNAL_PREFIX = "ext:"; +const EXTERNAL = new Map<string, string>(); + +/** Registers icons by name (`ext:…`); the returned function removes them. */ +export function registerExternalIcons(icons: ReadonlyArray<readonly [name: string, svg: string]>): () => void { + const names: string[] = []; + for (const [name, svg] of icons) { + if (!name.startsWith(EXTERNAL_PREFIX)) continue; + EXTERNAL.set(name, svg); + prepared.delete(`${name}|16`); + prepared.delete(`${name}|20`); + names.push(name); + } + return () => { + for (const name of names) { + EXTERNAL.delete(name); + prepared.delete(`${name}|16`); + prepared.delete(`${name}|20`); + } + }; +} + +/** A file icon theme (plugin `iconThemes`). Each method returns an icon name, or null for the built-in one. */ +export interface FileIconTheme { + file(fileName: string): string | null; + /** Plain folders only: folders with a role (source root, excluded, …) keep their built-in icons. */ + folder(folderName: string, expanded: boolean): string | null; +} + +let fileIconTheme: FileIconTheme | null = null; + +/** Bumped when the file icon theme changes; file lists subscribe to redraw their icons. */ +export const useFileIconRevision = create<number>(() => 0); + +/** + * Sets the file icon theme: consulted after the user's own file type + * associations and before the built-in icons. + */ +export function setFileIconTheme(theme: FileIconTheme | null) { + if (fileIconTheme === theme) return; + fileIconTheme = theme; + useFileIconRevision.setState((n) => n + 1, true); +} + +/** The icon theme's icon for a plain folder, or null to keep the built-in one. */ +export function themedFolderIconName(folderName: string, expanded = false): IconName | null { + return (fileIconTheme?.folder(folderName, expanded) ?? null) as IconName | null; +} + // --------------------------------------------------------------------------- // SVG processing @@ -562,6 +617,7 @@ interface Prepared { } function raw(path: string): string | undefined { + if (path.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.get(path); return RAW[path.startsWith("openidea/") ? `./${path}.svg` : `./expui/${path}.svg`]; } @@ -934,6 +990,8 @@ export function setFileIconOverride(resolve: ((fileName: string) => IconName | n export function fileIconName(fileName: string): IconName { const custom = iconOverride?.(fileName); if (custom) return custom; + const themed = fileIconTheme?.file(fileName); + if (themed) return themed as IconName; const lower = fileName.toLowerCase(); const named = FILE_NAMES[lower] ?? NAME_PATTERNS.find(([re]) => re.test(lower))?.[1]; if (named) return named; diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 4f8d8df13..62d0823f1 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -941,6 +941,26 @@ export type ContextMenuId = | "selectIn" | "new"; +/** + * Entries of the native main menu's open slots (src-tauri/src/menu.rs + * `Entry::Dynamic`), e.g. `pluginTools` at the end of Tools: each runs `action`. + */ +export interface MenuSlotItem { + action: string; + arg?: string | null; + label: string; +} + +export const useMenuSlotItems = create<{ slots: Record<string, MenuSlotItem[]> }>(() => ({ slots: {} })); + +export function registerMenuSlotItem(slot: string, item: MenuSlotItem): () => void { + useMenuSlotItems.setState((s) => ({ slots: { ...s.slots, [slot]: [...(s.slots[slot] ?? []), item] } })); + return () => + useMenuSlotItems.setState((s) => ({ + slots: { ...s.slots, [slot]: (s.slots[slot] ?? []).filter((i) => i !== item) }, + })); +} + export interface MenuGroupContext { /** Files / directories the menu is for (absolute paths; a virtual URI for virtual documents). */ paths: string[]; diff --git a/wit/events.wit b/wit/events.wit new file mode 100644 index 000000000..8470c298c --- /dev/null +++ b/wit/events.wit @@ -0,0 +1,68 @@ +package openidea:plugin@1.0.0; + +/// Workbench events, delivered in batches to `event-handler.handle`. +interface events { + use types.{error}; + + enum event-kind { + file-opened, + file-saved, + /// The focused editor changed. + editor-focused, + /// An IDE action ran. Requires `events.input`. + action-performed, + /// A repository's state changed (commit, checkout, fetch, …). + vcs-changed, + /// The plugin's `on-schedule` period elapsed. + scheduled, + } + + /// How the user started an action. + enum action-source { + keyboard, + menu, + toolbar, + context-menu, + search, + other, + } + + record action-performed { + /// Action id (`editor.duplicate-line`). + action: string, + title: string, + source: action-source, + /// Its keyboard shortcut, when it has one (`Ctrl+D`). + shortcut: option<string>, + } + + variant event { + file-opened(string), + file-saved(string), + editor-focused(option<string>), + action-performed(action-performed), + vcs-changed(string), + scheduled, + } + + /// Delivers `kind` events from now on, for the rest of the session (also + /// across idle reclaim: an event activates the plugin again). Declare + /// `activation = ["on-event:<kind>"]` to get them before first use. + /// Requires `events` (`events.input` for `action-performed`). + subscribe: func(kind: event-kind) -> result<_, error>; + unsubscribe: func(kind: event-kind); +} + +/// Long-running work with a progress indicator. While a task is open, the +/// call that began it may run for the background budget (60 s) instead of 5 s. +interface tasks { + resource task { + /// `done` of `total` units (`none`: indeterminate), with a short status. + report: func(done: u32, total: option<u32>, message: option<string>); + /// The user cancelled it: stop soon and return. + is-cancelled: func() -> bool; + } + + /// Starts a task shown in the status bar; dropping it ends it. + begin: func(title: string, cancellable: bool) -> task; +} diff --git a/wit/exports.wit b/wit/exports.wit new file mode 100644 index 000000000..f907ff472 --- /dev/null +++ b/wit/exports.wit @@ -0,0 +1,70 @@ +package openidea:plugin@1.0.0; + +interface lifecycle { + use types.{context}; + + /// Called once after the instance is created. Register runtime + /// contributions and subscriptions here; restore state from `storage`. + activate: func(ctx: context) -> result<_, string>; + /// Called before the instance is dropped (disable, uninstall, idle reclaim). + deactivate: func(); +} + +interface commands { + use invocation.{invocation}; + + /// Runs one of the plugin's commands. Returns an optional message. + run: func(id: string, ctx: borrow<invocation>, arg: option<string>) -> result<option<string>, string>; +} + +interface event-handler { + use events.{event}; + use invocation.{invocation}; + + /// A batch of subscribed events, oldest first, from one window (`ctx`). + handle: func(events: list<event>, ctx: borrow<invocation>); +} + +/// Optional export (world `plugin-syntax`): syntax of the plugin's own +/// languages, for languages the IDE has no grammar for. The IDE calls it +/// with the whole document; results are flat. Offsets are UTF-8 byte offsets +/// into `text` (the IDE converts them); lines are 0-based. +interface syntax-provider { + /// One highlighted range. `kind` is a highlight category: keyword, string, + /// string-special, escape, regexp, atom, number, constant, comment, + /// doc-comment, function, function-call, builtin, type, namespace, + /// variable, self-variable, property, attribute, macro, label, tag, + /// operator, punctuation. Unknown kinds are ignored; ranges must be + /// sorted and not overlap. + record token { + start: u32, + end: u32, + kind: string, + } + + /// A symbol of the structure view. `parent` is the index of the enclosing + /// symbol in the same list (which must come first). + record symbol { + name: string, + detail: option<string>, + /// class, interface, struct, enum, function, method, constructor, + /// field, property, variable, constant, module, namespace, event, … + kind: string, + start: u32, + end: u32, + name-start: u32, + name-end: u32, + parent: option<u32>, + } + + /// A foldable line range. `kind`: region, comment or imports. + record fold { + start-line: u32, + end-line: u32, + kind: string, + } + + highlights: func(language: string, text: string) -> list<token>; + outline: func(language: string, text: string) -> list<symbol>; + folding: func(language: string, text: string) -> list<fold>; +} diff --git a/wit/host.wit b/wit/host.wit new file mode 100644 index 000000000..750bfd342 --- /dev/null +++ b/wit/host.wit @@ -0,0 +1,51 @@ +package openidea:plugin@1.0.0; + +/// Writes to the IDE log, tagged with the plugin id. +interface log { + use types.{log-level}; + + log: func(level: log-level, message: string); +} + +/// Contributions made at run time (declare static ones in plugin.toml). +interface contributions { + use types.{error}; + + /// Registers (or updates) a command `plugin.<plugin-id>.<id>`. + /// `id` must match `[a-z0-9][a-z0-9._-]*`. + register-command: func(id: string, title: string, category: option<string>) -> result<_, error>; + /// A language from a `languages/<id>.toml`-style definition. + register-language: func(definition: string) -> result<_, error>; + /// A theme from JSON: {"id","name","kind":"dark"|"light","tokens":{"--var":"value"}}. + register-theme: func(definition: string) -> result<_, error>; +} + +interface ui { + use types.{notification-level}; + + record status-item { + text: string, + tooltip: option<string>, + /// One of the plugin's commands, run when the item is clicked. + command: option<string>, + } + + /// Shows a notification balloon in the window the current command or + /// event came from (every window when there is none). + show-notification: func(level: notification-level, title: string, body: string); + /// Shows or updates the plugin's status bar item `id` in every window. + set-status-item: func(id: string, item: status-item); + remove-status-item: func(id: string); +} + +/// Persistent key-value storage of the plugin (survives restarts and idle +/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB. +interface storage { + use types.{error}; + + get: func(key: string) -> result<option<string>, error>; + set: func(key: string, value: string) -> result<_, error>; + delete: func(key: string) -> result<_, error>; + /// Keys starting with `prefix`, sorted. + keys: func(prefix: string) -> result<list<string>, error>; +} diff --git a/wit/io.wit b/wit/io.wit new file mode 100644 index 000000000..a1aeb8490 --- /dev/null +++ b/wit/io.wit @@ -0,0 +1,85 @@ +package openidea:plugin@1.0.0; + +/// Problems in files, shown in the editor and the Problems view. +interface diagnostics { + use types.{error, range}; + + enum severity { + error, + warning, + info, + hint, + } + + record diagnostic { + range: range, + severity: severity, + message: string, + /// Rule or check id (`LineLength`). + code: option<string>, + } + + /// Replaces the plugin's diagnostics for the file at `path` (absolute, in + /// an open project); an empty list clears them. Requires `project.read`. + publish: func(path: string, diagnostics: list<diagnostic>) -> result<_, error>; +} + +/// HTTP(S) to the hosts granted with `net:<host>`. +interface net { + use types.{error}; + + record header { + name: string, + value: string, + } + + record request { + method: string, + /// `https://…`; plain `http://` only to `localhost` / `127.0.0.1`. + url: string, + /// `{{secret:<name>}}` in a value is replaced by the plugin's secret + /// `name` on the way out (requires `secrets`); the plugin never sees it. + headers: list<header>, + body: option<list<u8>>, + /// Default 30 s; the call's own budget applies too. + timeout-ms: option<u32>, + } + + record response { + status: u16, + headers: list<header>, + /// At most 8 MiB. + body: list<u8>, + } + + /// Fails with `denied` for a host the plugin may not reach, `cancelled` + /// when a newer request replaced the call or the user cancelled. + fetch: func(req: request) -> result<response, error>; +} + +/// Secrets the user stored for the plugin (Settings › Plugins, kept in the +/// OS keychain). Plugins only use them through `{{secret:<name>}}` in +/// `net` requests. Requires `secrets`. +interface secrets { + is-set: func(name: string) -> bool; +} + +/// One-off runs of programs granted with `process:<program>` (the file the +/// user approved, checked before every run). +interface process { + use types.{error}; + use workspace.{project}; + + record output { + /// `none` when killed by a signal. + status: option<s32>, + /// At most 4 MiB each (the rest is dropped). + stdout: string, + stderr: string, + } + + /// Runs `program` with `args` in `cwd`'s root (else the plugin's data + /// directory) and waits; cancelled with the task or call. Call it inside + /// a task for anything slow. + run: func(program: string, args: list<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index 707d8bd91..84b20a900 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -1,84 +1,68 @@ -/// OpenIDEA plugin API, version 0.1. +/// OpenIDEA plugin API, version 1.0 (docs/plugin_plan.md 4.3). Frozen: 1.x +/// only adds (new interfaces, new optional exports); a 2.0 keeps 1.0 running +/// through an adapter in the host. /// -/// A plugin is a WebAssembly component targeting the `plugin` world. It runs -/// sandboxed: no filesystem, network, environment or clock access beyond what -/// WASI preview 2 grants by default (wall/monotonic clocks and random only). -/// Everything else goes through the `host` interface and is gated by the -/// permissions declared in `plugin.toml`. -package openidea:plugin@0.1.0; - -interface types { - enum log-level { - trace, - debug, - info, - warn, - error, - } - - enum notification-level { - info, - warning, - error, - } - - /// Passed to `activate`. - record context { - plugin-id: string, - plugin-version: string, - /// Version of this API the host implements, e.g. "0.1". - api-version: string, - /// Permissions granted from the manifest, e.g. ["project-read"]. - permissions: list<string>, - } -} - -interface host { - use types.{log-level, notification-level}; - - /// Writes to the IDE log (tagged with the plugin id). - log: func(level: log-level, message: string); - - /// Registers (or updates) a command. It appears as action - /// `plugin.<plugin-id>.<id>` in Search Everywhere and the keymap editor. - /// `id` must match `[a-z0-9][a-z0-9._-]*`. - register-command: func(id: string, title: string, category: option<string>); - - /// Shows a notification balloon. - show-notification: func(level: notification-level, title: string, body: string); - - /// Root of the project the current command was invoked from. - /// Requires `project-read`. - workspace-root: func() -> option<string>; - - /// Path of the focused editor's file. Requires `editor-read`. - active-file: func() -> option<string>; - - /// Reads a UTF-8 text file inside the project root (absolute, or relative - /// to the root). Paths escaping the root are rejected. Requires `project-read`. - read-file: func(path: string) -> result<string, string>; - - /// Inserts text at the cursor of the active editor. Requires `editor-write`. - insert-text: func(text: string); - - /// Replaces the active editor's selection(s). Requires `editor-write`. - replace-selection: func(text: string); - - /// Registers a language from a `languages/<id>.toml`-style definition. - register-language: func(definition: string) -> result<_, string>; - - /// Registers a theme from JSON: {"id","name","kind":"dark"|"light","tokens":{"--var":"value"}}. - register-theme: func(definition: string) -> result<_, string>; -} +/// A plugin is a WebAssembly component targeting the `plugin` world (or +/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment +/// or processes beyond WASI's clocks and randomness; everything else goes +/// through the imports below, gated by the capabilities granted in +/// `plugin.toml` (a missing one returns `error::denied`). +package openidea:plugin@1.0.0; world plugin { - use types.{context}; - import host; + import log; + import contributions; + import ui; + import storage; + import workspace; + import editor; + import invocation; + import vcs; + import events; + import tasks; + import diagnostics; + import net; + import secrets; + import process; + import views; + import settings; - /// Called once after loading. Register commands here. - export activate: func(ctx: context) -> result<_, string>; - /// Called before unloading / disabling. - export deactivate: func(); - /// Runs one of the plugin's commands. Returns an optional message. - export run-command: func(id: string, arg: option<string>) -> result<option<string>, string>; + export lifecycle; + export commands; + export event-handler; +} + +/// A plugin that also provides syntax for its languages. +world plugin-syntax { + include plugin; + export syntax-provider; +} + +/// A plugin with editor providers (`[[contributes.providers]]`). +world plugin-providers { + include plugin; + export providers; +} + +/// A plugin with views or custom editors (`[[contributes.views]]`, +/// `[[contributes.custom-editors]]`). +world plugin-views { + include plugin; + export view-provider; +} + +/// Only the `syntax-provider` export: guests generate it next to `plugin` +/// (the component tools merge both into one component). +world syntax { + export syntax-provider; +} + +/// Only the `providers` export, generated next to `plugin` like `syntax`. +world editor-providers { + export providers; +} + +/// Only the `view-provider` export, generated next to `plugin` like `syntax`. +world views-provider { + export view-provider; } diff --git a/wit/providers.wit b/wit/providers.wit new file mode 100644 index 000000000..af3f86eb4 --- /dev/null +++ b/wit/providers.wit @@ -0,0 +1,83 @@ +package openidea:plugin@1.0.0; + +/// Editor features a plugin provides for the languages it declares under +/// `[[contributes.providers]]` (the host calls only declared kinds). They run +/// on the editing path: a result later than ~50 ms is dropped, a call still +/// running at 500 ms is interrupted. +interface providers { + use types.{position, range, text-edit, location}; + + /// The document a request is about. + record document { + path: string, + language: string, + version: u64, + text: string, + } + + enum completion-kind { + text, + method, + function, + %constructor, + field, + variable, + class, + %interface, + module, + property, + keyword, + snippet, + value, + constant, + file, + reference, + } + + record completion-item { + label: string, + detail: option<string>, + kind: option<completion-kind>, + insert-text: string, + /// Replaced on accept; `none`: the word at the caret. + replace: option<range>, + sort-text: option<string>, + documentation: option<string>, + } + + record inline-suggestion { + insert-text: string, + /// Replaced range; `none` inserts at the caret. + replace: option<range>, + } + + record file-edit { + path: string, + edits: list<text-edit>, + } + + record code-action { + title: string, + /// `quickfix`, `refactor`, … + kind: option<string>, + preferred: bool, + edits: list<file-edit>, + } + + record line-marker { + line: u32, + /// Gutter icon: `navigate` (default), `implemented`, `implementing`, `run`. + icon: option<string>, + tooltip: string, + /// Opened on click. + target: option<location>, + } + + completion: func(doc: document, at: position, trigger: option<string>) -> list<completion-item>; + /// Ghost text at the caret (typing replaces an older request: it is cancelled). + inline-completion: func(doc: document, at: position) -> option<inline-suggestion>; + /// Actions for `selection` (quick fixes for the plugin's own diagnostics). + code-actions: func(doc: document, selection: range) -> list<code-action>; + definition: func(doc: document, at: position) -> list<location>; + line-markers: func(doc: document) -> list<line-marker>; +} diff --git a/wit/types.wit b/wit/types.wit new file mode 100644 index 000000000..5c4f237e1 --- /dev/null +++ b/wit/types.wit @@ -0,0 +1,64 @@ +package openidea:plugin@1.0.0; + +/// Shared types. Positions are 0-based lines and UTF-16 code unit columns, +/// as in the editor and LSP. +interface types { + /// Why a host call failed. A capability that is not granted is reported + /// as `denied`, so the plugin can degrade (0.1 trapped instead). + variant error { + /// Not granted; the payload names the capability (`editor.write`). + denied(string), + /// The document changed since the version the edit was made for. + stale, + not-found(string), + invalid(string), + /// Cancelled by the user, or the plugin is being stopped. + cancelled, + failed(string), + } + + enum log-level { + trace, + debug, + info, + warn, + error, + } + + enum notification-level { + info, + warning, + error, + } + + record position { + line: u32, + character: u32, + } + + record range { + start: position, + end: position, + } + + record text-edit { + range: range, + text: string, + } + + /// A range in a file (absolute path). + record location { + path: string, + range: range, + } + + /// Passed to `lifecycle.activate`. + record context { + plugin-id: string, + plugin-version: string, + /// Version of this API the host implements, e.g. "1.0". + api-version: string, + /// Capabilities granted to the plugin (`editor.write`, `process:git`, …). + capabilities: list<string>, + } +} diff --git a/wit/views.wit b/wit/views.wit new file mode 100644 index 000000000..1dfbd1357 --- /dev/null +++ b/wit/views.wit @@ -0,0 +1,146 @@ +package openidea:plugin@1.0.0; + +/// Declarative views (docs/plugin_plan.md 4.2, L2): tool windows declared +/// under `[[contributes.views]]` and custom editors under +/// `[[contributes.customEditors]]`. The plugin describes what to show; the +/// IDE renders it with its own components (theme, keyboard, accessibility) +/// and sends the user's actions back as `view-event`s. Nothing of the plugin +/// runs in the WebView, except a `html` body, which is shown in a sandboxed +/// frame that can only exchange messages with the plugin. +interface view-types { + record button { + id: string, + label: string, + /// An IDE icon name (`refresh`, `add`, `remove`, `run`, …). + icon: option<string>, + enabled: bool, + } + + /// A tree node; `parent` is the key of an earlier item (none: a root). + record tree-item { + key: string, + parent: option<string>, + label: string, + icon: option<string>, + /// Grey text after the label. + detail: option<string>, + /// Shows an expander even before its children are known + /// (`expand` asks for them). + expandable: bool, + expanded: bool, + } + + record list-item { + key: string, + label: string, + icon: option<string>, + detail: option<string>, + } + + variant field-value { + text(string), + multiline(string), + checkbox(bool), + /// The options and the chosen one. + choice(tuple<list<string>, string>), + } + + record field { + key: string, + label: string, + value: field-value, + } + + /// Rows `first-row`..`first-row + len(rows)` of `total-rows`: the IDE + /// asks for the rows it shows (`viewport`), so large tables stay cheap. + record table { + columns: list<string>, + total-rows: u32, + first-row: u32, + rows: list<list<string>>, + editable: bool, + } + + variant body { + /// A centred message (empty state, errors). + message(string), + tree(list<tree-item>), + %list(list<list-item>), + form(list<field>), + table(table), + /// HTML shown in a sandboxed frame: no scripts from elsewhere, no + /// network, no IDE API. Its script talks to the plugin with + /// `openidea.postMessage(text)` / `openidea.onMessage(fn)`. + html(string), + } + + record view { + toolbar: list<button>, + body: body, + /// A line below the body (counts, last refresh…). + status: option<string>, + } + + /// The table rows the IDE shows. + record viewport { + first-row: u32, + rows: u32, + } + + record cell-edit { + row: u32, + column: u32, + value: string, + } + + variant view-event { + /// A toolbar button. + button(string), + /// Double click / Enter on a tree or list item. + activate(string), + select(string), + expand(string), + collapse(string), + /// A form field changed: key and new value (`true`/`false` for checkboxes). + field(tuple<string, string>), + cell(cell-edit), + /// Insert an empty row before this one (the row count: append). + insert-row(u32), + delete-rows(list<u32>), + /// From the `html` body's script. + message(string), + } +} + +/// What a plugin's code can ask of its views. +interface views { + use types.{error}; + + /// The view (or every custom editor of that id) shows outdated content: + /// the IDE renders it again. + refresh: func(view: string); + /// Sends a message to the script of a view's `html` body. + post-message: func(view: string, message: string) -> result<_, error>; + /// Opens a terminal tab in the window the call came from, running + /// `program` (pinned by a `process:<program>` grant) with `args` in the + /// project root: long-running output (logs) and interactive sessions. + open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>; +} + +/// The plugin's own settings (`[contributes.settings]`), as the user set them +/// (project over global) or the schema's default. +interface settings { + /// The value of `key` as JSON (`true`, `3`, `"text"`), none if unknown. + get: func(key: string) -> option<string>; +} + +/// Optional export (world `plugin-views`): content of declared views and +/// custom editors. For a custom editor, `ctx.editor()` is its document. +interface view-provider { + use view-types.{view, viewport, view-event}; + use invocation.{invocation}; + use types.{error}; + + render: func(view: string, viewport: option<viewport>, ctx: borrow<invocation>) -> result<view, error>; + handle: func(view: string, event: view-event, ctx: borrow<invocation>) -> result<_, error>; +} diff --git a/wit/workspace.wit b/wit/workspace.wit new file mode 100644 index 000000000..febca7c6f --- /dev/null +++ b/wit/workspace.wit @@ -0,0 +1,90 @@ +package openidea:plugin@1.0.0; + +interface workspace { + use types.{error}; + + /// A project open in the IDE. + resource project { + /// Absolute path of the project root. Requires `project.read`. + root: func() -> result<string, error>; + /// Reads a UTF-8 text file inside the project (absolute, or relative + /// to the root); paths leaving the root are refused. Unsaved editor + /// contents win over the disk. Requires `project.read`. + read-file: func(path: string) -> result<string, error>; + /// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`), + /// at most `limit`, as absolute paths. Ignored and excluded files are + /// skipped. Requires `project.read`. + find-files: func(glob: string, limit: u32) -> result<list<string>, error>; + } + + /// Projects open in any window. Requires `project.read`. + projects: func() -> result<list<project>, error>; +} + +interface editor { + use types.{error, range, text-edit}; + + record document { + /// Grows with every change; edits name the version they were made for. + version: u64, + text: string, + } + + /// A file open in an editor. + resource text-editor { + /// Absolute path of the file. + path: func() -> string; + /// Language id (`rust`, `solidity`, …). + language: func() -> string; + /// Current version and text. Requires `editor.read`. + document: func() -> result<document, error>; + /// Selections when the command was invoked (a cursor is an empty + /// range). Requires `editor.read`. + selections: func() -> result<list<range>, error>; + /// Applies `edits` as one undoable change, only if the document is + /// still at `version` (else `stale`, and nothing changes). Ranges are + /// in that version and must not overlap. Returns the new version. + /// Requires `editor.write`. + apply-edits: func(version: u64, edits: list<text-edit>) -> result<u64, error>; + } +} + +/// Where a command or event comes from. +interface invocation { + use workspace.{project}; + use editor.{text-editor}; + + resource invocation { + /// The window's project. + project: func() -> option<project>; + /// The window's focused editor. + editor: func() -> option<text-editor>; + } +} + +/// Git, for the repository containing a project. +interface vcs { + use types.{error}; + use workspace.{project}; + + record repository-status { + /// Absolute path of the working tree root. + root: string, + /// `none` when HEAD is detached. + branch: option<string>, + commit: option<string>, + upstream: option<string>, + ahead: u32, + behind: u32, + /// Merge / rebase / cherry-pick in progress. + operation: option<string>, + /// Files with staged or unstaged changes. + changed-files: u32, + } + + /// `none` when the project is not in a repository. Requires `vcs.read`. + status: func(project: borrow<project>) -> result<option<repository-status>, error>; + /// Fetches all remotes (pruning deleted branches). Requires `vcs.write`. + /// Long-running: call it inside a task. + fetch: func(project: borrow<project>) -> result<_, error>; +}