mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)
A plugin system built on WebAssembly components (wasmtime, WASI p2), with a typed WIT API (`openidea:plugin@1.0.0`) and a Rust SDK, shipped as a preview. - Extension points declared in `plugin.toml` (commands, languages, themes, icon themes, menus, key bindings, status items, settings, views, custom editors, file icons and templates, run detectors, editor providers), validated before anything takes effect and projected onto the workbench's own registries: plugin themes and file icon themes are chosen in Settings › Appearance and Quick Switch, plugin shortcuts are edited in Settings › Keymap, plugin problems are grouped in Settings › Inspections. - Capabilities with risk levels and consent at install, pinned `process:` programs, secrets in the OS keychain. - Runtime: one thread per active plugin, fuel/time/memory budgets, soft and hard deadlines on the editing path, cancellation, crash backoff, idle reclaim, a precompiled-component cache; remote projects run code plugins on `openidea-server`. - Web views and custom editors with a sandboxed page, a document bridge joined to the text editor's undo history, and split or preview layouts. - Distribution: signed `.oiplugin` packages, a static marketplace index with revocation, versioned installs with rollback, install from a `.zip` or a folder. The release workflow signs and publishes the official plugins and their index, which released builds use as the default marketplace. - Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion, Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor Night, Excalidraw, Graphviz, Regex Tester, License Header. Closes #732 Co-authored-by: Deng Liu <5108773+dengliu@users.noreply.github.com> Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com>
This commit is contained in:
co-authored by
Deng Liu
florentvdry
parent
cfdbd3872a
commit
1c5a92f952
@@ -71,6 +71,57 @@ jobs:
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
|
||||
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
|
||||
|
||||
# The plugin SDK, the official plugins and the test fixtures build in their
|
||||
# own workspaces (the root excludes them), so the jobs above don't see them.
|
||||
plugins:
|
||||
name: Plugins (SDK, official, fixtures)
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: plugins
|
||||
cache-directories: target-plugins
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Format, lint (host and wasm) and test each crate
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for dir in sdk/rust/openidea-plugin plugins/official/*/ crates/ide-plugin/tests/fixtures/*/; do
|
||||
[ -f "$dir/Cargo.toml" ] || continue
|
||||
echo "::group::$dir"
|
||||
cargo fmt --check --manifest-path "$dir/Cargo.toml"
|
||||
cargo clippy --locked --all-targets --manifest-path "$dir/Cargo.toml" -- -D warnings
|
||||
cargo clippy --locked --target wasm32-wasip2 --manifest-path "$dir/Cargo.toml" -- -D warnings
|
||||
cargo test --locked --manifest-path "$dir/Cargo.toml"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
|
||||
- name: Web pages typecheck and test
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for dir in plugins/official/*/web-src/; do
|
||||
[ -f "$dir/package.json" ] || continue
|
||||
echo "::group::$dir"
|
||||
pnpm --dir "$dir" install --frozen-lockfile --ignore-workspace
|
||||
pnpm --dir "$dir" exec tsc --noEmit
|
||||
pnpm --dir "$dir" test
|
||||
echo "::endgroup::"
|
||||
done
|
||||
# The host's tests run the checked-in components: they must be built from these sources.
|
||||
# Web pages aren't checked in: this builds them and checks the result.
|
||||
- name: Checked-in components are up to date, web pages build
|
||||
run: plugins/build.sh --check
|
||||
|
||||
deny:
|
||||
name: Cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -180,6 +180,69 @@ jobs:
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The official plugins, signed, and the marketplace index released apps read
|
||||
# (releases/latest/download/plugins-index.json). They are not in the
|
||||
# installers: users install them from Settings › Plugins. Without the
|
||||
# signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen`
|
||||
# writes) nothing is published and the release goes on.
|
||||
plugins:
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
steps:
|
||||
- id: key
|
||||
run: |
|
||||
if [ -z "$SIGNING_KEY" ]; then
|
||||
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { targets: wasm32-wasip2 }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { key: release-plugins }
|
||||
- uses: pnpm/action-setup@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
# Builds the web pages; fails when a checked-in component isn't built from these sources.
|
||||
- name: Build
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
run: |
|
||||
plugins/build.sh --check
|
||||
cargo build --release --locked -p ide-host --bin openidea-plugin
|
||||
- name: Pack, index and upload
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tool="$PWD/target/release/openidea-plugin"
|
||||
key="$RUNNER_TEMP/openidea.key"
|
||||
out="$RUNNER_TEMP/market"
|
||||
(umask 077 && printf '%s' "$SIGNING_KEY" > "$key")
|
||||
trap 'rm -f "$key"' EXIT
|
||||
mkdir -p "$out"
|
||||
for dir in plugins/official/*/; do
|
||||
id="$(sed -n 's/^id *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
"$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin"
|
||||
done
|
||||
# The published index's revocations and earlier keys carry over.
|
||||
curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \
|
||||
-o "$out/plugins-index.json" || rm -f "$out/plugins-index.json"
|
||||
"$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json"
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
@@ -243,7 +306,7 @@ jobs:
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
needs: [create-release, build, server, updater-manifest]
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
|
||||
@@ -10,6 +10,9 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
# Plugin components are checked in (plugins/build.sh --check keeps them current).
|
||||
!plugins/official/*/dist
|
||||
!crates/ide-plugin/tests/fixtures/*/dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
"fixtures",
|
||||
"plugins/*/web",
|
||||
"plugins/official/*/web",
|
||||
"crates/**/tests/fixtures"
|
||||
],
|
||||
"overrides": [
|
||||
|
||||
Generated
+39
-3
@@ -4181,6 +4181,26 @@ dependencies = [
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.35"
|
||||
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,10 +4275,12 @@ dependencies = [
|
||||
"specta-typescript",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4316,6 +4346,7 @@ dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
"ide-core",
|
||||
"ide-extension",
|
||||
"ide-text",
|
||||
"parking_lot",
|
||||
"regex-lite",
|
||||
@@ -4417,19 +4448,20 @@ dependencies = [
|
||||
name = "ide-plugin"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
"ide-extension",
|
||||
"parking_lot",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6150,6 +6182,7 @@ dependencies = [
|
||||
name = "openidea"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"block2 0.6.2",
|
||||
"dirs 6.0.0",
|
||||
@@ -6164,6 +6197,7 @@ dependencies = [
|
||||
"ide-dap",
|
||||
"ide-db",
|
||||
"ide-docker",
|
||||
"ide-extension",
|
||||
"ide-fonts",
|
||||
"ide-github",
|
||||
"ide-host",
|
||||
@@ -6195,6 +6229,8 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-web-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"security-framework",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
# Plugins and the guest SDK build for wasm32-wasip2 in their own workspaces.
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.35"
|
||||
@@ -41,6 +43,8 @@ ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
ide-extension = { path = "crates/ide-extension" }
|
||||
ide-plugin = { path = "crates/ide-plugin" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -69,6 +73,7 @@ ropey = "1.6"
|
||||
encoding_rs = "0.8"
|
||||
chardetng = "0.1"
|
||||
ignore = "0.4"
|
||||
base64 = "0.22"
|
||||
globset = "0.4"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.6"
|
||||
|
||||
@@ -61,6 +61,9 @@ pub struct Settings {
|
||||
pub cmake: CMakeSettings,
|
||||
/// Tools › Agents › Chat.
|
||||
pub agents: AgentSettings,
|
||||
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
|
||||
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// What the meters in an agent task's header show.
|
||||
@@ -215,6 +218,7 @@ impl Default for Settings {
|
||||
run_targets: Vec::new(),
|
||||
cmake: CMakeSettings::default(),
|
||||
agents: AgentSettings::default(),
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,7 +327,8 @@ impl Default for NotificationGroupSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AppearanceSettings {
|
||||
/// `dark` | `light` | `system` | `dark-hc` (high-contrast dark)
|
||||
/// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
|
||||
/// "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
|
||||
pub theme: String,
|
||||
pub accent: String,
|
||||
pub ui_font_size: f32,
|
||||
@@ -332,6 +337,8 @@ pub struct AppearanceSettings {
|
||||
pub sync_with_os: bool,
|
||||
pub preferred_light_theme: String,
|
||||
pub preferred_dark_theme: String,
|
||||
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
|
||||
pub file_icon_theme: String,
|
||||
/// IDE zoom in percent (50–300).
|
||||
pub zoom: u32,
|
||||
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
|
||||
@@ -386,6 +393,7 @@ impl Default for AppearanceSettings {
|
||||
sync_with_os: false,
|
||||
preferred_light_theme: "light".into(),
|
||||
preferred_dark_theme: "dark".into(),
|
||||
file_icon_theme: String::new(),
|
||||
zoom: 100,
|
||||
use_custom_font: false,
|
||||
ui_font_family: "Inter".into(),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use ide_core::process::executable_names;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::{DebuggerConfig, LanguageRegistry};
|
||||
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
@@ -44,6 +44,8 @@ pub struct AdapterSpec {
|
||||
/// `host:port` of an adapter inside another program (the Godot editor): connected to
|
||||
/// when it accepts, else `command` is started.
|
||||
pub connect: Option<String>,
|
||||
/// A plugin language's adapter: start exactly this approved program.
|
||||
pub pinned: Option<ProgramPin>,
|
||||
}
|
||||
|
||||
/// Built-in adapters used when no language definition names `adapter`.
|
||||
@@ -55,6 +57,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
transport: transport.to_owned(),
|
||||
connect: None,
|
||||
install_hint: Some(hint.to_owned()),
|
||||
pinned: None,
|
||||
};
|
||||
Some(match adapter {
|
||||
"codelldb" => cfg(
|
||||
@@ -133,6 +136,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
|
||||
env: Vec::new(),
|
||||
server_host: None,
|
||||
connect: config.connect.clone(),
|
||||
pinned: config.pinned.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,12 +430,20 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
if is_plugin_bundle(&spec.command) {
|
||||
return Err(not_standalone(&spec));
|
||||
}
|
||||
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
|
||||
let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| missing(&spec))?;
|
||||
let binary = match spec.pinned.clone() {
|
||||
Some(pin) => tokio::task::spawn_blocking(move || pin.verify().map(Path::to_path_buf))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?,
|
||||
None => {
|
||||
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
|
||||
tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| missing(&spec))?
|
||||
}
|
||||
};
|
||||
let port = match spec.transport {
|
||||
// An embedded adapter started without `${port}` listens on its usual address.
|
||||
Transport::Tcp => match &embedded {
|
||||
|
||||
@@ -915,6 +915,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
transport: "tcp".into(),
|
||||
connect: Some(format!("127.0.0.1:{port}")),
|
||||
install_hint: Some("Open the project in the Godot editor".into()),
|
||||
pinned: None,
|
||||
}),
|
||||
};
|
||||
Arc::new(LanguageRegistry::from_configs(vec![lang]))
|
||||
@@ -1147,3 +1148,58 @@ async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A built-in language definition (GDScript) shipped by a plugin
|
||||
/// instead: its debugger starts the program approved for the plugin's
|
||||
/// `process:godot` grant, and refuses once that file changed.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
use ide_language::ProgramPin;
|
||||
use ide_language::plugin_point::{PluginLanguages, parse_definition};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("openidea-plugin-dap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Stands in for `godot --headless --editor …`.
|
||||
let program = dir.join("godot");
|
||||
std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
|
||||
let debugger = gdscript.debugger.as_mut().unwrap();
|
||||
debugger.args.clear();
|
||||
debugger.connect = Some(format!("127.0.0.1:{port}"));
|
||||
|
||||
// No built-in GDScript: it comes from the plugin.
|
||||
let registry = Arc::new(LanguageRegistry::from_configs(Vec::new()));
|
||||
let store = PluginLanguages::new();
|
||||
registry.attach_plugins(store.clone());
|
||||
let pin = ProgramPin::approve(&program).unwrap();
|
||||
store.set_process_policy(Arc::new(move |plugin, cap| {
|
||||
(plugin == "acme.godot" && cap.to_string() == "process:godot").then(|| pin.clone())
|
||||
}));
|
||||
let _language = store.add("acme.godot".into(), gdscript);
|
||||
|
||||
let log: Log = Arc::default();
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(registry.clone());
|
||||
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
|
||||
assert!(log.lock().iter().any(|m| m["command"] == "launch"));
|
||||
manager.stop(id).await.unwrap();
|
||||
|
||||
// Replaced after approval: refused before anything starts.
|
||||
std::fs::write(&program, "#!/bin/sh\nexec sleep 31\n").unwrap();
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let err = manager.start(godot_launch(&dir), owner).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "ide-extension"
|
||||
description = "Plugin manifest, extension points, contribution registry and capability model (no Wasm runtime)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
semver = "1"
|
||||
blake3.workspace = true
|
||||
zip.workspace = true
|
||||
base64.workspace = true
|
||||
ed25519-dalek = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,277 @@
|
||||
//! What a plugin may do beyond pure computation, and how risky each grant is.
|
||||
//!
|
||||
//! Capabilities are written as strings in the manifest (`project.read`,
|
||||
//! `net:crates.io`, `process:cargo`) and stored the same way in the grant
|
||||
//! ledger. The 0.1 permission names (`project-read`, …) parse as their 0.2
|
||||
//! equivalents.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use specta::Type;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Capability {
|
||||
/// Read files inside the project root; the project root itself.
|
||||
ProjectRead,
|
||||
/// The focused editor's file, selection and text.
|
||||
EditorRead,
|
||||
/// Edit the focused editor (versioned edits).
|
||||
EditorWrite,
|
||||
VcsRead,
|
||||
VcsWrite,
|
||||
/// The plugin's own key-value store and data directory.
|
||||
Storage,
|
||||
/// The plugin's own secrets (kept in the client's OS keychain).
|
||||
Secrets,
|
||||
/// Workbench events (file opened, saved, focus…), not keystrokes.
|
||||
Events,
|
||||
/// Keystrokes and editor input events.
|
||||
InputEvents,
|
||||
/// The system clipboard, from the plugin's web views.
|
||||
Clipboard,
|
||||
/// HTTP(S) to one host (`*.example.com` matches subdomains).
|
||||
Net(String),
|
||||
/// Run one external program. Full code execution: never fine-grained.
|
||||
Process(String),
|
||||
/// Read files outside the project under one path.
|
||||
FsRead(String),
|
||||
}
|
||||
|
||||
/// How a grant is presented (plan 4.2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Risk {
|
||||
/// Listed on the install page, one confirmation.
|
||||
Low,
|
||||
/// Listed separately with its purpose.
|
||||
Medium,
|
||||
/// Separate dialog: "equivalent to running arbitrary code on this machine".
|
||||
High,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
pub fn risk(&self) -> Risk {
|
||||
match self {
|
||||
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
|
||||
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
|
||||
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::Clipboard => "Read and write the clipboard from its web views".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::Clipboard => f.write_str("clipboard"),
|
||||
Self::Net(h) => write!(f, "net:{h}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
Self::FsRead(p) => write!(f, "fs.read:{p}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Capability {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, String> {
|
||||
let s = s.trim();
|
||||
if let Some((kind, arg)) = s.split_once(':') {
|
||||
let arg = arg.trim();
|
||||
if arg.is_empty() {
|
||||
return Err(format!("capability {s:?} needs a value after ':'"));
|
||||
}
|
||||
return match kind {
|
||||
"net" => {
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
&& host.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
});
|
||||
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
|
||||
}
|
||||
"process" => {
|
||||
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
|
||||
return Err(format!("{s:?}: use a program name or an absolute path"));
|
||||
}
|
||||
Ok(Self::Process(arg.to_owned()))
|
||||
}
|
||||
"fs.read" => {
|
||||
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
|
||||
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
|
||||
}
|
||||
Ok(Self::FsRead(arg.to_owned()))
|
||||
}
|
||||
_ => Err(format!("unknown capability {s:?}")),
|
||||
};
|
||||
}
|
||||
Ok(match s {
|
||||
"project.read" | "project-read" => Self::ProjectRead,
|
||||
"editor.read" | "editor-read" => Self::EditorRead,
|
||||
"editor.write" | "editor-write" => Self::EditorWrite,
|
||||
"vcs.read" => Self::VcsRead,
|
||||
"vcs.write" => Self::VcsWrite,
|
||||
"storage" => Self::Storage,
|
||||
"secrets" => Self::Secrets,
|
||||
"events" => Self::Events,
|
||||
"events.input" => Self::InputEvents,
|
||||
"clipboard" => Self::Clipboard,
|
||||
_ => return Err(format!("unknown capability {s:?}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Capability {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Capability {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
String::deserialize(d)?.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// A capability as the UI shows it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CapabilityInfo {
|
||||
/// `project.read`, `net:crates.io`, …
|
||||
pub id: String,
|
||||
pub risk: Risk,
|
||||
pub description: String,
|
||||
/// Declared under `optional`: the plugin runs without it.
|
||||
pub optional: bool,
|
||||
/// Currently granted.
|
||||
pub granted: bool,
|
||||
}
|
||||
|
||||
impl CapabilityInfo {
|
||||
pub fn new(cap: &Capability, optional: bool, granted: bool) -> Self {
|
||||
Self { id: cap.to_string(), risk: cap.risk(), description: cap.describe(), optional, granted }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_prints() {
|
||||
for s in [
|
||||
"project.read",
|
||||
"editor.write",
|
||||
"vcs.write",
|
||||
"storage",
|
||||
"secrets",
|
||||
"events",
|
||||
"events.input",
|
||||
"net:crates.io",
|
||||
"net:*.github.com",
|
||||
"process:cargo",
|
||||
] {
|
||||
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
|
||||
}
|
||||
// Absolute paths are the platform's.
|
||||
let (git, etc) = if cfg!(windows) {
|
||||
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows")
|
||||
} else {
|
||||
("process:/usr/bin/git", "fs.read:/etc")
|
||||
};
|
||||
for s in [git, etc] {
|
||||
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
|
||||
}
|
||||
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
|
||||
assert!("net:".parse::<Capability>().is_err());
|
||||
assert!("net:a b".parse::<Capability>().is_err());
|
||||
assert!("process:./evil".parse::<Capability>().is_err());
|
||||
assert!("fs.read:rel".parse::<Capability>().is_err());
|
||||
assert!("network".parse::<Capability>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_levels() {
|
||||
assert_eq!(Capability::ProjectRead.risk(), Risk::Low);
|
||||
assert_eq!(Capability::Net("x".into()).risk(), Risk::Medium);
|
||||
assert_eq!(Capability::InputEvents.risk(), Risk::Medium);
|
||||
assert_eq!("clipboard".parse::<Capability>().unwrap(), Capability::Clipboard);
|
||||
assert_eq!(Capability::Clipboard.risk(), Risk::Medium);
|
||||
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
|
||||
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_patterns() {
|
||||
assert!(host_matches("crates.io", "crates.io"));
|
||||
assert!(host_matches("crates.io", "CRATES.IO."));
|
||||
assert!(!host_matches("crates.io", "static.crates.io"));
|
||||
assert!(host_matches("*.github.com", "api.github.com"));
|
||||
assert!(!host_matches("*.github.com", "github.com"));
|
||||
assert!(!host_matches("*.github.com", "evilgithub.com"));
|
||||
let any_gh: Capability = "net:*.github.com".parse().unwrap();
|
||||
assert!(any_gh.covers(&"net:api.github.com".parse().unwrap()));
|
||||
assert!(Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etc/hosts".into())));
|
||||
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The contribution index: validated UI
|
||||
//! declarations of installed packages, kept on disk so start-up does not
|
||||
//! validate them again (sanitising thousands of icon SVGs, say).
|
||||
//!
|
||||
//! Only packages with a [`Package::digest`] are indexed: installed copies,
|
||||
//! which the host never modifies after install. An entry is keyed by that
|
||||
//! digest, the extension point, the declaration and a salt naming the
|
||||
//! validation logic (the IDE version), so a new package version, a changed
|
||||
//! declaration or an IDE upgrade simply misses. A miss or an unreadable entry
|
||||
//! only costs the validation.
|
||||
|
||||
use crate::point::Package;
|
||||
use crate::ui::UiDecl;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bumped when [`UiDecl`]'s serialized shape changes.
|
||||
const FORMAT: u32 = 1;
|
||||
|
||||
pub struct ContributionIndex {
|
||||
dir: PathBuf,
|
||||
salt: String,
|
||||
}
|
||||
|
||||
impl ContributionIndex {
|
||||
/// `salt` names the validators' version (the IDE version).
|
||||
pub fn new(dir: PathBuf, salt: &str) -> Self {
|
||||
Self { dir, salt: format!("{FORMAT}:{salt}") }
|
||||
}
|
||||
|
||||
/// Entries start with a prefix of the package digest, for pruning.
|
||||
fn file(&self, digest: &str, point: &str, decl: &toml::Value) -> PathBuf {
|
||||
let mut h = blake3::Hasher::new();
|
||||
for part in [self.salt.as_str(), digest, point, &decl.to_string()] {
|
||||
h.update(part.as_bytes());
|
||||
h.update(&[0]);
|
||||
}
|
||||
let key = h.finalize().to_hex();
|
||||
self.dir.join(format!("{}-{}.json", prefix(digest), &key[..32]))
|
||||
}
|
||||
|
||||
pub fn get(&self, pkg: &Package, point: &str, decl: &toml::Value) -> Option<UiDecl> {
|
||||
let digest = pkg.digest.as_deref()?;
|
||||
let bytes = std::fs::read(self.file(digest, point, decl)).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
pub fn put(&self, pkg: &Package, point: &str, decl: &toml::Value, ui: &UiDecl) {
|
||||
let Some(digest) = pkg.digest.as_deref() else { return };
|
||||
let Ok(bytes) = serde_json::to_vec(ui) else { return };
|
||||
let file = self.file(digest, point, decl);
|
||||
let _ = std::fs::create_dir_all(&self.dir);
|
||||
let tmp = file.with_extension(format!("tmp{}", std::process::id()));
|
||||
if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes entries of packages other than `live` (digests).
|
||||
pub fn prune<'a>(&self, live: impl IntoIterator<Item = &'a str>) {
|
||||
let keep: BTreeSet<String> = live.into_iter().map(|d| prefix(d).to_owned()).collect();
|
||||
let Ok(entries) = std::fs::read_dir(&self.dir) else { return };
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if !name.split_once('-').is_some_and(|(p, _)| keep.contains(p)) {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(digest: &str) -> &str {
|
||||
let d = digest.strip_prefix("blake3:").unwrap_or(digest);
|
||||
let end = d.char_indices().nth(16).map_or(d.len(), |(i, _)| i);
|
||||
&d[..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::Manifest;
|
||||
|
||||
#[test]
|
||||
fn hits_only_the_same_package_point_declaration_and_salt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let index = ContributionIndex::new(tmp.path().join("idx"), "1.0");
|
||||
let manifest =
|
||||
Manifest::parse("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n").unwrap();
|
||||
let pkg = Package::new(manifest, tmp.path().to_path_buf()).with_digest("blake3:00aa11bb22cc33dd44ee");
|
||||
let decl: toml::Value = toml::from_str("id = \"x\"").unwrap();
|
||||
let ui = UiDecl::new("x", "X", serde_json::json!({ "id": "x" }));
|
||||
assert!(index.get(&pkg, "pt", &decl).is_none());
|
||||
index.put(&pkg, "pt", &decl, &ui);
|
||||
assert_eq!(index.get(&pkg, "pt", &decl).unwrap().label, "X");
|
||||
assert!(index.get(&pkg, "other", &decl).is_none());
|
||||
assert!(index.get(&pkg, "pt", &toml::from_str("id = \"y\"").unwrap()).is_none());
|
||||
assert!(ContributionIndex::new(tmp.path().join("idx"), "1.1").get(&pkg, "pt", &decl).is_none());
|
||||
let unhashed = Package { digest: None, ..pkg.clone() };
|
||||
assert!(index.get(&unhashed, "pt", &decl).is_none(), "not an installed copy");
|
||||
|
||||
index.prune(["blake3:ffff"]);
|
||||
assert!(index.get(&pkg, "pt", &decl).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! The grant ledger (plan 4.6): which capabilities the user granted to which
|
||||
//! plugin, saved as `grants.json` next to the installed plugins.
|
||||
//!
|
||||
//! - Installed plugins are keyed by id and remember the version and package
|
||||
//! hash they were confirmed for. An update that needs no new capability is
|
||||
//! accepted silently; one that does must be confirmed again.
|
||||
//! - Linked development plugins and project plugins (`.openidea/plugins/*`)
|
||||
//! are keyed by their directory, not by hash, so rebuilding does not ask
|
||||
//! again; new capabilities still do.
|
||||
//! - `process:<program>` grants record the program's absolute path and file
|
||||
//! hash at grant time. Calls use that path, never a `PATH` lookup, and fail
|
||||
//! when the file changed (re-confirmation needed).
|
||||
|
||||
use crate::capability::{Capability, Risk};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LEDGER_FILE: &str = "grants.json";
|
||||
const FORMAT: u32 = 1;
|
||||
|
||||
/// Whose grant an entry is.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
|
||||
pub enum Subject {
|
||||
/// Installed under the plugins directory, by plugin id.
|
||||
Installed(String),
|
||||
/// A linked development plugin or a project plugin, by package directory.
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BinaryGrant {
|
||||
pub path: PathBuf,
|
||||
/// blake3 of the file when granted.
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Grant {
|
||||
pub plugin_id: String,
|
||||
/// The version and package hash the user confirmed (installed plugins).
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub package_hash: String,
|
||||
/// The user confirmed the plugin (the install page or the project-plugin prompt).
|
||||
#[serde(default)]
|
||||
pub confirmed: bool,
|
||||
/// Granted capabilities (`project.read`, `net:x`, …).
|
||||
#[serde(default)]
|
||||
pub granted: BTreeSet<String>,
|
||||
/// Optional capabilities the user declined (not asked again until re-confirmed).
|
||||
#[serde(default)]
|
||||
pub declined: BTreeSet<String>,
|
||||
/// `process:<program>` → the program confirmed for it.
|
||||
#[serde(default)]
|
||||
pub binaries: BTreeMap<String, BinaryGrant>,
|
||||
/// Migrated from 0.1: capabilities not granted yet are asked for on first
|
||||
/// use instead of blocking the plugin from loading.
|
||||
#[serde(default)]
|
||||
pub ask_on_use: bool,
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
pub fn has(&self, cap: &Capability) -> bool {
|
||||
self.granted.iter().filter_map(|s| s.parse::<Capability>().ok()).any(|g| g.covers(cap))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a plugin (in its current form) needs, to compare with its grant.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Request<'a> {
|
||||
pub subject: Subject,
|
||||
pub plugin_id: &'a str,
|
||||
pub version: &'a str,
|
||||
pub package_hash: &'a str,
|
||||
pub required: Vec<Capability>,
|
||||
pub optional: Vec<Capability>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
/// Load with these capabilities. `pending` required ones are asked for when
|
||||
/// the plugin is first used (plugins migrated from 0.1 only).
|
||||
Allowed { granted: Vec<Capability>, pending: Vec<Capability> },
|
||||
/// Ask the user first. `new` lists what was never granted (all of it on first use).
|
||||
NeedsConfirmation { new: Vec<Capability>, first_time: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
format: u32,
|
||||
/// The one-time migration from 0.1 ("declared means granted") ran.
|
||||
#[serde(default)]
|
||||
migrated: bool,
|
||||
#[serde(default)]
|
||||
grants: Vec<(Subject, Grant)>,
|
||||
}
|
||||
|
||||
/// The ledger, loaded into memory; [`Ledger::save`] writes it atomically.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Ledger {
|
||||
path: Option<PathBuf>,
|
||||
migrated: bool,
|
||||
grants: BTreeMap<Subject, Grant>,
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
/// Loads `<dir>/grants.json`; a missing or unreadable file gives an empty
|
||||
/// ledger with `needs_migration()` true.
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
let path = dir.join(LEDGER_FILE);
|
||||
let file: Option<File> = std::fs::read_to_string(&path).ok().and_then(|s| match serde_json::from_str(&s) {
|
||||
Ok(f) => Some(f),
|
||||
Err(e) => {
|
||||
tracing::warn!("{}: {e}; starting with an empty grant ledger", path.display());
|
||||
None
|
||||
}
|
||||
});
|
||||
let existed = file.is_some();
|
||||
let file = file.unwrap_or_default();
|
||||
Self { path: Some(path), migrated: file.migrated || existed, grants: file.grants.into_iter().collect() }
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Self {
|
||||
Self { path: None, migrated: true, grants: BTreeMap::new() }
|
||||
}
|
||||
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
let Some(path) = &self.path else { return Ok(()) };
|
||||
let file = File {
|
||||
format: FORMAT,
|
||||
migrated: self.migrated,
|
||||
grants: self.grants.iter().map(|(s, g)| (s.clone(), g.clone())).collect(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
pub fn get(&self, subject: &Subject) -> Option<&Grant> {
|
||||
self.grants.get(subject)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, subject: &Subject) -> Option<Grant> {
|
||||
self.grants.remove(subject)
|
||||
}
|
||||
|
||||
/// Puts back an earlier state of `subject`'s grant (`None`: none), e.g.
|
||||
/// after rolling back an update.
|
||||
pub fn restore(&mut self, subject: &Subject, grant: Option<Grant>) {
|
||||
match grant {
|
||||
Some(g) => {
|
||||
self.grants.insert(subject.clone(), g);
|
||||
}
|
||||
None => {
|
||||
self.grants.remove(subject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-time migration of 0.1's "declared means granted" has not run yet.
|
||||
pub fn needs_migration(&self) -> bool {
|
||||
!self.migrated
|
||||
}
|
||||
|
||||
/// Migrates plugins installed before the ledger existed: low-risk
|
||||
/// capabilities are granted, the rest are asked on first use.
|
||||
pub fn migrate(&mut self, installed: &[Request<'_>]) {
|
||||
for req in installed {
|
||||
let granted: BTreeSet<String> =
|
||||
req.required.iter().chain(&req.optional).filter(|c| c.auto_migrates()).map(|c| c.to_string()).collect();
|
||||
self.grants.entry(req.subject.clone()).or_insert_with(|| Grant {
|
||||
plugin_id: req.plugin_id.to_owned(),
|
||||
version: req.version.to_owned(),
|
||||
package_hash: req.package_hash.to_owned(),
|
||||
confirmed: true,
|
||||
granted,
|
||||
ask_on_use: true,
|
||||
..Grant::default()
|
||||
});
|
||||
}
|
||||
self.migrated = true;
|
||||
}
|
||||
|
||||
/// Compares what the plugin needs with what was granted.
|
||||
pub fn decide(&mut self, req: &Request<'_>) -> Decision {
|
||||
let Some(grant) = self.grants.get_mut(&req.subject) else {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
};
|
||||
if !grant.confirmed {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
}
|
||||
let new: Vec<Capability> = req.required.iter().filter(|c| !grant.has(c)).cloned().collect();
|
||||
if !new.is_empty() && !grant.ask_on_use {
|
||||
return Decision::NeedsConfirmation { new, first_time: false };
|
||||
}
|
||||
// Same or fewer needs: accept an update silently (plan 4.6).
|
||||
if grant.version != req.version || grant.package_hash != req.package_hash {
|
||||
grant.version = req.version.to_owned();
|
||||
grant.package_hash = req.package_hash.to_owned();
|
||||
}
|
||||
let granted = req.required.iter().chain(&req.optional).filter(|c| grant.has(c)).cloned().collect();
|
||||
Decision::Allowed { granted, pending: new }
|
||||
}
|
||||
|
||||
/// Records the user's confirmation: `required` plus the accepted optional capabilities.
|
||||
pub fn confirm(
|
||||
&mut self,
|
||||
req: &Request<'_>,
|
||||
accepted_optional: &[Capability],
|
||||
binaries: BTreeMap<String, BinaryGrant>,
|
||||
) {
|
||||
let grant = self.grants.entry(req.subject.clone()).or_default();
|
||||
grant.plugin_id = req.plugin_id.to_owned();
|
||||
grant.version = req.version.to_owned();
|
||||
grant.package_hash = req.package_hash.to_owned();
|
||||
grant.confirmed = true;
|
||||
grant.ask_on_use = false;
|
||||
for cap in req.required.iter().chain(accepted_optional) {
|
||||
grant.granted.insert(cap.to_string());
|
||||
}
|
||||
for cap in &req.optional {
|
||||
let s = cap.to_string();
|
||||
if accepted_optional.contains(cap) {
|
||||
grant.declined.remove(&s);
|
||||
} else if !grant.granted.contains(&s) {
|
||||
grant.declined.insert(s);
|
||||
}
|
||||
}
|
||||
grant.binaries.extend(binaries);
|
||||
}
|
||||
|
||||
/// Grants or revokes one capability (Settings › Plugins › permissions).
|
||||
pub fn set(&mut self, subject: &Subject, cap: &Capability, granted: bool) -> bool {
|
||||
let Some(grant) = self.grants.get_mut(subject) else { return false };
|
||||
let s = cap.to_string();
|
||||
if granted {
|
||||
grant.declined.remove(&s);
|
||||
grant.granted.insert(s)
|
||||
} else {
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
}
|
||||
grant.granted.remove(&s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the program approved for a `process:` grant.
|
||||
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
grant.binaries.insert(cap.to_string(), binary);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks a `process:` grant at call time: the program confirmed for it,
|
||||
/// unchanged since. Errors name what to do.
|
||||
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
|
||||
let Capability::Process(name) = cap else { return Err(format!("{cap} is not a process capability")) };
|
||||
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
|
||||
let key = cap.to_string();
|
||||
if !grant.granted.contains(&key) {
|
||||
return Err(format!("{cap} was not granted"));
|
||||
}
|
||||
let bin = grant.binaries.get(&key).ok_or_else(|| format!("{cap}: no program was confirmed for {name}"))?;
|
||||
match file_hash(&bin.path) {
|
||||
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
|
||||
Ok(_) => Err(format!(
|
||||
"{} changed since it was approved for {cap}; confirm the plugin's permissions again",
|
||||
bin.path.display()
|
||||
)),
|
||||
Err(e) => Err(format!("{}: {e}; confirm the plugin's permissions again", bin.path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The program approved for a granted `process:` capability, without
|
||||
/// checking the file: whoever starts it verifies the hash then
|
||||
/// ([`check_binary`](Self::check_binary) or the pin's own check).
|
||||
pub fn pinned_binary(&self, subject: &Subject, cap: &Capability) -> Option<BinaryGrant> {
|
||||
let grant = self.grants.get(subject)?;
|
||||
let key = cap.to_string();
|
||||
if !grant.granted.contains(&key) {
|
||||
return None;
|
||||
}
|
||||
grant.binaries.get(&key).cloned()
|
||||
}
|
||||
|
||||
pub fn subjects(&self) -> impl Iterator<Item = (&Subject, &Grant)> {
|
||||
self.grants.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a confirmation page shows: every capability with its risk, high risk first.
|
||||
pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
|
||||
let mut groups: BTreeMap<std::cmp::Reverse<Risk>, Vec<Capability>> = BTreeMap::new();
|
||||
for c in caps {
|
||||
groups.entry(std::cmp::Reverse(c.risk())).or_default().push(c.clone());
|
||||
}
|
||||
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
|
||||
}
|
||||
|
||||
/// Resolves a `process:` program to an absolute path once, at grant time:
|
||||
/// absolute names are taken as is, others are looked up in `path_var`.
|
||||
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
|
||||
let candidate = Path::new(name);
|
||||
if candidate.is_absolute() {
|
||||
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
|
||||
}
|
||||
let exts: Vec<String> = if cfg!(windows) {
|
||||
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
|
||||
} else {
|
||||
vec![String::new()]
|
||||
};
|
||||
for dir in std::env::split_paths(path_var.unwrap_or_default()) {
|
||||
if !dir.is_absolute() {
|
||||
continue; // `.` or relative entries could point into the project
|
||||
}
|
||||
for ext in &exts {
|
||||
let p = dir.join(format!("{name}{ext}"));
|
||||
if is_executable(&p) {
|
||||
return Ok(dunce_canonical(&p));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!("{name} was not found on PATH"))
|
||||
}
|
||||
|
||||
fn dunce_canonical(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
let Ok(meta) = std::fs::metadata(p) else { return false };
|
||||
if !meta.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
true
|
||||
}
|
||||
|
||||
/// blake3 of a file, hex.
|
||||
pub fn file_hash(path: &Path) -> std::io::Result<String> {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update_reader(std::fs::File::open(path)?)?;
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
/// blake3 over a package's files (sorted relative paths and contents).
|
||||
pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
let mut sorted: Vec<&PathBuf> = files.iter().collect();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for rel in sorted {
|
||||
hasher.update(rel.to_string_lossy().as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
|
||||
hasher.update(&[0]);
|
||||
}
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn caps(list: &[&str]) -> Vec<Capability> {
|
||||
list.iter().map(|s| s.parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
|
||||
Request {
|
||||
subject: Subject::Installed("p".into()),
|
||||
plugin_id: "p",
|
||||
version,
|
||||
package_hash: hash,
|
||||
required: caps(required),
|
||||
optional: caps(optional),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_then_silent_update_then_reconfirm_on_new_capability() {
|
||||
let mut l = Ledger::in_memory();
|
||||
let r1 = req("1.0.0", "h1", &["project.read"], &["net:crates.io"]);
|
||||
assert_eq!(l.decide(&r1), Decision::NeedsConfirmation { new: caps(&["project.read"]), first_time: true });
|
||||
l.confirm(&r1, &[], BTreeMap::new());
|
||||
assert_eq!(
|
||||
l.decide(&r1),
|
||||
Decision::Allowed { granted: caps(&["project.read"]), pending: vec![] },
|
||||
"declined optional"
|
||||
);
|
||||
assert!(l.get(&r1.subject).unwrap().declined.contains("net:crates.io"));
|
||||
|
||||
// Same needs, new version: silent.
|
||||
let r2 = req("1.1.0", "h2", &["project.read"], &["net:crates.io"]);
|
||||
assert!(matches!(l.decide(&r2), Decision::Allowed { .. }));
|
||||
assert_eq!(l.get(&r2.subject).unwrap().version, "1.1.0");
|
||||
|
||||
// A new required capability: ask again, only for it.
|
||||
let r3 = req("1.2.0", "h3", &["project.read", "editor.write"], &[]);
|
||||
assert_eq!(l.decide(&r3), Decision::NeedsConfirmation { new: caps(&["editor.write"]), first_time: false });
|
||||
l.confirm(&r3, &[], BTreeMap::new());
|
||||
assert!(matches!(l.decide(&r3), Decision::Allowed { .. }));
|
||||
|
||||
// Revoking a required capability blocks loading again.
|
||||
assert!(l.set(&r3.subject, &Capability::EditorWrite, false));
|
||||
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_grants_low_risk_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut l = Ledger::load(tmp.path());
|
||||
assert!(l.needs_migration());
|
||||
let r = req("0.1.0", "h", &["project.read", "editor.write"], &[]);
|
||||
l.migrate(std::slice::from_ref(&r));
|
||||
// Loads with the low-risk part; the rest is asked for on first use.
|
||||
assert_eq!(
|
||||
l.decide(&r),
|
||||
Decision::Allowed { granted: caps(&["project.read"]), pending: caps(&["editor.write"]) }
|
||||
);
|
||||
l.confirm(&r, &[], BTreeMap::new());
|
||||
assert_eq!(
|
||||
l.decide(&r),
|
||||
Decision::Allowed { granted: caps(&["project.read", "editor.write"]), pending: vec![] }
|
||||
);
|
||||
l.save().unwrap();
|
||||
let again = Ledger::load(tmp.path());
|
||||
assert!(!again.needs_migration());
|
||||
assert!(again.get(&r.subject).unwrap().has(&Capability::ProjectRead));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn binaries_are_pinned_by_path_and_hash() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin_dir = tmp.path().join("bin");
|
||||
std::fs::create_dir(&bin_dir).unwrap();
|
||||
let tool = bin_dir.join("tool");
|
||||
std::fs::write(&tool, "#!/bin/sh\necho v1\n").unwrap();
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path_var = std::env::join_paths([PathBuf::from("relative"), bin_dir.clone()]).unwrap();
|
||||
let resolved = resolve_program("tool", Some(&path_var)).unwrap();
|
||||
assert!(resolved.is_absolute());
|
||||
assert!(resolve_program("missing", Some(&path_var)).is_err());
|
||||
|
||||
let mut l = Ledger::in_memory();
|
||||
let r = req("1.0.0", "h", &["process:tool"], &[]);
|
||||
let cap: Capability = "process:tool".parse().unwrap();
|
||||
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
|
||||
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
|
||||
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
|
||||
std::fs::write(&tool, "#!/bin/sh\necho evil\n").unwrap();
|
||||
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_and_grouping() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("a"), "1").unwrap();
|
||||
std::fs::write(tmp.path().join("b"), "2").unwrap();
|
||||
let h1 = package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap();
|
||||
assert_eq!(h1, package_hash(tmp.path(), &["b".into(), "a".into()]).unwrap());
|
||||
std::fs::write(tmp.path().join("b"), "3").unwrap();
|
||||
assert_ne!(h1, package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap());
|
||||
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
|
||||
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! The plugin system's kernel, independent of the Wasm runtime:
|
||||
//!
|
||||
//! - [`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};
|
||||
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
|
||||
pub use when::validate_when;
|
||||
@@ -0,0 +1,501 @@
|
||||
//! `plugin.toml`, the package manifest (plan 4.4).
|
||||
//!
|
||||
//! ```toml
|
||||
//! id = "acme.rust-tools" # optional publisher namespace before the first '.'
|
||||
//! name = "Rust Tools"
|
||||
//! version = "1.2.0"
|
||||
//! engines.openidea = "^1.0"
|
||||
//! wasm = "plugin.wasm"
|
||||
//! activation = ["on-language:rust", "on-command:check"]
|
||||
//!
|
||||
//! [capabilities]
|
||||
//! required = ["project.read", "editor.write"]
|
||||
//! optional = ["net:crates.io", "process:cargo"]
|
||||
//!
|
||||
//! [[contributes.commands]]
|
||||
//! id = "check"
|
||||
//! title = "Check"
|
||||
//! ```
|
||||
//!
|
||||
//! `contributes` is kept as raw TOML: each `contributes.<point>` is parsed and
|
||||
//! validated by the extension point registered under that id
|
||||
//! ([`crate::ExtensionRegistry`]), so new contribution types need no change here.
|
||||
//!
|
||||
//! Manifests in the 0.1 format (`openidea-api = "0.1"`, `permissions = [...]`)
|
||||
//! still parse: permissions become required capabilities and the plugin is
|
||||
//! activated at start-up, as before.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.0.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Manifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub license: Option<String>,
|
||||
/// Release notes of this version (Settings › Plugins › What's New).
|
||||
pub change_notes: Option<String>,
|
||||
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
|
||||
pub engine: String,
|
||||
/// Written in the 0.1 format.
|
||||
pub legacy: bool,
|
||||
/// Component file relative to the package directory.
|
||||
pub wasm: Option<PathBuf>,
|
||||
pub activation: Vec<ActivationEvent>,
|
||||
pub capabilities: Capabilities,
|
||||
/// `contributes.<point>` → its declarations, validated by the extension points.
|
||||
pub contributes: toml::Table,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Capabilities {
|
||||
pub required: Vec<Capability>,
|
||||
pub optional: Vec<Capability>,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
pub fn all(&self) -> impl Iterator<Item = &Capability> {
|
||||
self.required.iter().chain(&self.optional)
|
||||
}
|
||||
|
||||
pub fn is_optional(&self, cap: &Capability) -> bool {
|
||||
self.optional.contains(cap) && !self.required.contains(cap)
|
||||
}
|
||||
}
|
||||
|
||||
/// When a plugin's code is instantiated (plan 4.4). Contributions are live
|
||||
/// without it; code only runs once one of these fires.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ActivationEvent {
|
||||
/// One of the plugin's commands is run (`on-command:<id>`; `*` = any).
|
||||
Command(String),
|
||||
/// A file of this language is opened.
|
||||
Language(String),
|
||||
/// A file matching this glob (project-relative) is opened.
|
||||
File(String),
|
||||
/// One of the plugin's views is shown.
|
||||
View(String),
|
||||
/// Periodically: `on-schedule:<seconds>`. Does not keep the plugin alive.
|
||||
Schedule(u32),
|
||||
/// A workbench event of this kind (`on-event:file-saved`): the plugin is
|
||||
/// subscribed from the start, and activated to receive it.
|
||||
Event(String),
|
||||
/// Shortly after the workbench is ready.
|
||||
StartupFinished,
|
||||
}
|
||||
|
||||
impl ActivationEvent {
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
let s = s.trim();
|
||||
if s == "on-startup-finished" {
|
||||
return Ok(Self::StartupFinished);
|
||||
}
|
||||
let (kind, arg) = s.split_once(':').ok_or_else(|| format!("unknown activation event {s:?}"))?;
|
||||
let arg = arg.trim();
|
||||
if arg.is_empty() {
|
||||
return Err(format!("activation event {s:?} needs a value"));
|
||||
}
|
||||
Ok(match kind {
|
||||
"on-command" => Self::Command(arg.into()),
|
||||
"on-language" => Self::Language(arg.into()),
|
||||
"on-file" => Self::File(arg.into()),
|
||||
"on-view" => Self::View(arg.into()),
|
||||
"on-schedule" => {
|
||||
let secs: u32 =
|
||||
arg.trim_end_matches('s').parse().map_err(|_| format!("{s:?}: expected seconds, e.g. 300"))?;
|
||||
if secs < 60 {
|
||||
return Err(format!("{s:?}: the shortest schedule is 60 seconds"));
|
||||
}
|
||||
Self::Schedule(secs)
|
||||
}
|
||||
"on-event" => {
|
||||
if !EVENT_KINDS.contains(&arg) {
|
||||
return Err(format!("{s:?}: unknown event kind (one of {})", EVENT_KINDS.join(", ")));
|
||||
}
|
||||
Self::Event(arg.into())
|
||||
}
|
||||
_ => return Err(format!("unknown activation event {s:?}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
|
||||
/// `scheduled` comes from `on-schedule` instead.
|
||||
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Command(c) => write!(f, "on-command:{c}"),
|
||||
Self::Language(l) => write!(f, "on-language:{l}"),
|
||||
Self::File(g) => write!(f, "on-file:{g}"),
|
||||
Self::View(v) => write!(f, "on-view:{v}"),
|
||||
Self::Schedule(s) => write!(f, "on-schedule:{s}"),
|
||||
Self::Event(k) => write!(f, "on-event:{k}"),
|
||||
Self::StartupFinished => f.write_str("on-startup-finished"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
struct Raw {
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
license: Option<String>,
|
||||
#[serde(default)]
|
||||
change_notes: Option<String>,
|
||||
#[serde(default)]
|
||||
engines: Option<Engines>,
|
||||
/// 0.1 format.
|
||||
#[serde(default)]
|
||||
openidea_api: Option<String>,
|
||||
#[serde(default)]
|
||||
wasm: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
activation: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
capabilities: Option<RawCapabilities>,
|
||||
/// 0.1 format.
|
||||
#[serde(default)]
|
||||
permissions: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
contributes: toml::Table,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Engines {
|
||||
openidea: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawCapabilities {
|
||||
#[serde(default)]
|
||||
required: Vec<String>,
|
||||
#[serde(default)]
|
||||
optional: Vec<String>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn parse(src: &str) -> Result<Self, String> {
|
||||
let raw: Raw = toml::from_str(src).map_err(|e| e.to_string())?;
|
||||
let (engine, legacy) = match (raw.engines, raw.openidea_api) {
|
||||
(Some(e), None) => (e.openidea, false),
|
||||
(None, Some(api)) => (api, true),
|
||||
(Some(_), Some(_)) => return Err("use `engines.openidea`, not `openidea-api`".into()),
|
||||
(None, None) => return Err("missing `engines.openidea` (e.g. \"^1.0\")".into()),
|
||||
};
|
||||
if legacy && raw.capabilities.is_some() {
|
||||
return Err("`capabilities` requires `engines.openidea`; 0.1 manifests use `permissions`".into());
|
||||
}
|
||||
if !legacy && raw.permissions.is_some() {
|
||||
return Err("`permissions` is the 0.1 format; use `[capabilities] required = [...]`".into());
|
||||
}
|
||||
let parse_caps = |list: Vec<String>| -> Result<Vec<Capability>, String> {
|
||||
let mut out: Vec<Capability> = Vec::new();
|
||||
for s in list {
|
||||
let cap: Capability = s.parse()?;
|
||||
if !out.contains(&cap) {
|
||||
out.push(cap);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let capabilities = match (raw.capabilities, raw.permissions) {
|
||||
(Some(c), _) => Capabilities { required: parse_caps(c.required)?, optional: parse_caps(c.optional)? },
|
||||
(None, Some(p)) => Capabilities { required: parse_caps(p)?, optional: Vec::new() },
|
||||
(None, None) => Capabilities::default(),
|
||||
};
|
||||
let activation = match raw.activation {
|
||||
Some(list) => list.iter().map(|s| ActivationEvent::parse(s)).collect::<Result<Vec<_>, _>>()?,
|
||||
// 0.1 plugins were activated at start-up; keep that until they move to 1.0.
|
||||
None if legacy && raw.wasm.is_some() => vec![ActivationEvent::StartupFinished],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let m = Manifest {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
version: raw.version,
|
||||
description: raw.description,
|
||||
authors: raw.authors,
|
||||
homepage: raw.homepage,
|
||||
license: raw.license,
|
||||
change_notes: raw.change_notes,
|
||||
engine,
|
||||
legacy,
|
||||
wasm: raw.wasm,
|
||||
activation,
|
||||
capabilities,
|
||||
contributes: raw.contributes,
|
||||
};
|
||||
m.validate()?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<Self, String> {
|
||||
let path = dir.join(MANIFEST_FILE);
|
||||
let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_plugin_id(&self.id)?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("`name` must not be empty".into());
|
||||
}
|
||||
if semver::Version::parse(&self.version).is_err() {
|
||||
return Err(format!("`version` must be a semantic version like 1.2.3, got {:?}", self.version));
|
||||
}
|
||||
if !engine_compatible(&self.engine, self.legacy) {
|
||||
return Err(if self.legacy {
|
||||
format!(
|
||||
"unsupported openidea-api {:?} (this IDE implements {LEGACY_API} and {ENGINE_VERSION})",
|
||||
self.engine
|
||||
)
|
||||
} else {
|
||||
format!("engines.openidea {:?} does not match this IDE's plugin API {ENGINE_VERSION}", self.engine)
|
||||
});
|
||||
}
|
||||
if let Some(wasm) = &self.wasm {
|
||||
check_relative(wasm, "wasm")?;
|
||||
}
|
||||
if self.wasm.is_none() && !self.activation.is_empty() {
|
||||
return Err("`activation` needs `wasm`: there is no code to activate".into());
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for event in &self.activation {
|
||||
if !seen.insert(event) {
|
||||
return Err(format!("duplicate activation event {event}"));
|
||||
}
|
||||
}
|
||||
for (point, decls) in &self.contributes {
|
||||
validate_point_id(point)?;
|
||||
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
|
||||
return Err(format!("`contributes.{point}` must be a table or an array of tables"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The declarations of one extension point: `[[contributes.x]]` gives
|
||||
/// several, `[contributes.x]` one.
|
||||
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
|
||||
match self.contributes.get(point) {
|
||||
Some(toml::Value::Array(items)) => items.clone(),
|
||||
Some(single @ toml::Value::Table(_)) => vec![single.clone()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of declarations per extension point (for listings before loading).
|
||||
pub fn declaration_counts(&self) -> Vec<(String, u32)> {
|
||||
self.contributes.keys().map(|p| (p.clone(), self.declarations(p).len() as u32)).collect()
|
||||
}
|
||||
|
||||
/// Publisher namespace: the id's part before the first `.` (`acme` in `acme.tools`).
|
||||
pub fn publisher(&self) -> Option<&str> {
|
||||
self.id.split_once('.').map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Whether `cap` is declared (required or optional).
|
||||
pub fn declares(&self, cap: &Capability) -> bool {
|
||||
self.capabilities.all().any(|c| c.covers(cap))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this host can load a plugin written for `engine`.
|
||||
pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
|
||||
if legacy {
|
||||
return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.'));
|
||||
}
|
||||
let Ok(req) = semver::VersionReq::parse(engine) else { return false };
|
||||
let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version");
|
||||
req.matches(&host)
|
||||
}
|
||||
|
||||
/// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher
|
||||
/// namespace first), at most 64 chars.
|
||||
pub fn validate_plugin_id(id: &str) -> Result<(), String> {
|
||||
let ok = id.len() <= 64 && !id.is_empty() && id.split('.').all(segment_ok);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"invalid plugin id {id:?}: use a-z, 0-9, '-', '_' in '.'-separated parts (e.g. acme.tools), max 64"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars (commands, themes, views…).
|
||||
pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
|
||||
let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
|
||||
let ok_rest = id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
|
||||
if ok_first && ok_rest && id.len() <= 64 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("invalid {what} {id:?}: use a-z, 0-9, '.', '-', '_', starting with a letter or digit (max 64)"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
|
||||
pub fn validate_point_id(id: &str) -> Result<(), String> {
|
||||
let ok = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
|
||||
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
|
||||
}
|
||||
|
||||
fn segment_ok(s: &str) -> bool {
|
||||
s.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Package paths must stay inside the package directory.
|
||||
pub fn check_relative(path: &Path, what: &str) -> Result<(), String> {
|
||||
let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)));
|
||||
if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FULL: &str = r#"
|
||||
id = "acme.rust-tools"
|
||||
name = "Rust Tools"
|
||||
version = "1.2.0"
|
||||
engines.openidea = "^1.0"
|
||||
wasm = "plugin.wasm"
|
||||
activation = ["on-language:rust", "on-command:check", "on-schedule:300"]
|
||||
|
||||
[capabilities]
|
||||
required = ["project.read", "editor.write"]
|
||||
optional = ["net:crates.io", "process:cargo"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "check"
|
||||
title = "%check.title%"
|
||||
|
||||
[[contributes.menus]]
|
||||
menu = "editor.context"
|
||||
command = "check"
|
||||
when = "editorLang == rust"
|
||||
|
||||
[contributes.settings]
|
||||
schema = "settings.schema.json"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn event_activation() {
|
||||
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
|
||||
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
|
||||
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
|
||||
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
|
||||
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_02_format() {
|
||||
let m = Manifest::parse(FULL).unwrap();
|
||||
assert_eq!(m.publisher(), Some("acme"));
|
||||
assert!(!m.legacy);
|
||||
assert_eq!(m.activation.len(), 3);
|
||||
assert_eq!(m.activation[2], ActivationEvent::Schedule(300));
|
||||
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
|
||||
assert!(m.capabilities.is_optional(&Capability::Process("cargo".into())));
|
||||
assert_eq!(m.declarations("commands").len(), 1);
|
||||
assert_eq!(m.declarations("settings").len(), 1, "a single table is one declaration");
|
||||
assert!(m.declarations("nope").is_empty());
|
||||
let counts: Vec<_> = m.declaration_counts();
|
||||
assert!(counts.contains(&("menus".into(), 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_01_format() {
|
||||
let m = Manifest::parse(
|
||||
"id = \"hello\"\nname = \"Hello\"\nversion = \"0.1.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n\
|
||||
permissions = [\"project-read\", \"editor-write\"]\n[[contributes.commands]]\nid = \"x\"\ntitle = \"X\"",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(m.legacy);
|
||||
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
|
||||
assert_eq!(m.activation, vec![ActivationEvent::StartupFinished]);
|
||||
assert_eq!(m.publisher(), None);
|
||||
}
|
||||
|
||||
fn err(src: &str) -> String {
|
||||
Manifest::parse(src).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_manifests() {
|
||||
let base = |extra: &str| format!("id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\n{extra}");
|
||||
assert!(err(&base("")).contains("engines.openidea"));
|
||||
assert!(err(&base("engines.openidea = \"^0.3\"")).contains("does not match"));
|
||||
assert!(err(&base("engines.openidea = \"^2\"")).contains("does not match"));
|
||||
assert!(err(&base("engines.openidea = \"^0.2\"")).contains("does not match"));
|
||||
assert!(err(&base("openidea-api = \"0.2\"")).contains("unsupported"));
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\npermissions = [\"project-read\"]")).contains("0.1 format"));
|
||||
assert!(
|
||||
err(&base("engines.openidea = \"^1.0\"\n[capabilities]\nrequired = [\"network\"]")).contains("unknown")
|
||||
);
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\nwasm = \"../x.wasm\"")).contains("relative path"));
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\nactivation = [\"on-command:x\"]")).contains("needs `wasm`"));
|
||||
assert!(
|
||||
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-boot\"]"))
|
||||
.contains("unknown")
|
||||
);
|
||||
assert!(
|
||||
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-schedule:5\"]"))
|
||||
.contains("60 seconds")
|
||||
);
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\nbogus = 1")).contains("unknown field"));
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\ncontributes.commands = 3")).contains("table"));
|
||||
assert!(
|
||||
err("id = \"Bad\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
|
||||
);
|
||||
assert!(
|
||||
err("id = \"a..b\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
|
||||
);
|
||||
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids() {
|
||||
assert!(validate_plugin_id("openidea.material-icons").is_ok());
|
||||
assert!(validate_plugin_id("hello_2").is_ok());
|
||||
assert!(validate_plugin_id(".x").is_err());
|
||||
assert!(validate_item_id("word-count.v2_x", "command id").is_ok());
|
||||
assert!(validate_item_id("-x", "command id").is_err());
|
||||
assert!(validate_point_id("iconThemes").is_ok());
|
||||
assert!(validate_point_id("Icon").is_err());
|
||||
assert!(engine_compatible("1.0", false));
|
||||
assert!(engine_compatible(">=1.0, <2", false));
|
||||
assert!(!engine_compatible("^0.2", false), "0.x components don't run on 1.0");
|
||||
assert!(engine_compatible("0.1.3", true));
|
||||
assert!(!engine_compatible("0.10", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Plugin distribution: the package file, its
|
||||
//! signature, and the static marketplace index.
|
||||
//!
|
||||
//! - **Package** (`<id>-<version>.oiplugin`): a zip of the manifest, the files
|
||||
//! its declarations reference, the component and licence files, plus
|
||||
//! [`SIGNATURE_FILE`] when signed.
|
||||
//! - **Signature**: Ed25519 over `openidea-plugin/1\n<id>\n<version>\n<digest>`,
|
||||
//! `digest` being [`package_digest`] of the unpacked files. A signature
|
||||
//! proves who published the bytes; whether to trust that publisher is the
|
||||
//! index's call.
|
||||
//! - **Index** (`index.json`): the publishers and their keys (the trust root),
|
||||
//! the published plugins, and revocations. A publisher owns the id prefix
|
||||
//! `<publisher>.`; a revoked key invalidates everything it signed, a
|
||||
//! revoked plugin version is disabled where it is installed.
|
||||
|
||||
use crate::MANIFEST_FILE;
|
||||
use base64::Engine as _;
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const SIGNATURE_FILE: &str = "SIGNATURE.json";
|
||||
pub const PACKAGE_EXTENSION: &str = "oiplugin";
|
||||
pub const INDEX_FORMAT: u32 = 1;
|
||||
/// Limits when unpacking an untrusted package.
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
const MAX_TOTAL: u64 = 512 << 20;
|
||||
|
||||
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
/// Content digest of a package: the manifest plus `files` (relative paths).
|
||||
pub fn package_digest(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
let mut all = files.to_vec();
|
||||
all.push(PathBuf::from(MANIFEST_FILE));
|
||||
crate::ledger::package_hash(dir, &all)
|
||||
}
|
||||
|
||||
fn signed_message(id: &str, version: &str, digest: &str) -> Vec<u8> {
|
||||
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
|
||||
}
|
||||
|
||||
/// `SIGNATURE.json` of a signed package.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackageSignature {
|
||||
pub publisher: String,
|
||||
/// Ed25519 public key, base64.
|
||||
pub key: String,
|
||||
/// Ed25519 signature, base64.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// A publisher's key pair (the 32-byte seed, base64, in a file only the publisher has).
|
||||
pub struct PublisherKey(SigningKey);
|
||||
|
||||
impl PublisherKey {
|
||||
pub fn from_seed(seed: [u8; 32]) -> Self {
|
||||
Self(SigningKey::from_bytes(&seed))
|
||||
}
|
||||
|
||||
/// The seed as written by `openidea-plugin keygen`.
|
||||
pub fn from_base64(text: &str) -> Result<Self, String> {
|
||||
let bytes = B64.decode(text.trim()).map_err(|e| format!("key file: {e}"))?;
|
||||
let seed: [u8; 32] = bytes.try_into().map_err(|_| "key file: expected a 32-byte key".to_owned())?;
|
||||
Ok(Self::from_seed(seed))
|
||||
}
|
||||
|
||||
pub fn seed_base64(&self) -> String {
|
||||
B64.encode(self.0.to_bytes())
|
||||
}
|
||||
|
||||
pub fn public_base64(&self) -> String {
|
||||
B64.encode(self.0.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
|
||||
let sig = self.0.sign(&signed_message(id, version, digest));
|
||||
PackageSignature {
|
||||
publisher: publisher.into(),
|
||||
key: self.public_base64(),
|
||||
signature: B64.encode(sig.to_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackageSignature {
|
||||
/// Whether the signature is valid for `id`, `version` and `digest` (the
|
||||
/// key's validity is checked against an [`Index`]).
|
||||
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
|
||||
let key: [u8; 32] = B64
|
||||
.decode(&self.key)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed public key".to_owned())?;
|
||||
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
|
||||
let sig: [u8; 64] = B64
|
||||
.decode(&self.signature)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed".to_owned())?;
|
||||
key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
|
||||
.map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the package `out` from `dir`: the manifest, `files`, and the signature if any.
|
||||
pub fn pack(dir: &Path, files: &[PathBuf], out: &Path, signature: Option<&PackageSignature>) -> Result<(), String> {
|
||||
let io = |e: std::io::Error| e.to_string();
|
||||
let file = std::fs::File::create(out).map_err(io)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let mut names: Vec<PathBuf> = std::iter::once(PathBuf::from(MANIFEST_FILE)).chain(files.iter().cloned()).collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
for rel in names {
|
||||
let name = rel.to_str().ok_or("non UTF-8 file name")?.replace('\\', "/");
|
||||
zip.start_file(name, options).map_err(|e| e.to_string())?;
|
||||
let bytes = std::fs::read(dir.join(&rel)).map_err(|e| format!("{}: {e}", rel.display()))?;
|
||||
zip.write_all(&bytes).map_err(io)?;
|
||||
}
|
||||
if let Some(sig) = signature {
|
||||
zip.start_file(SIGNATURE_FILE, options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(serde_json::to_string_pretty(sig).map_err(|e| e.to_string())?.as_bytes()).map_err(io)?;
|
||||
}
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What [`unpack`] wrote.
|
||||
#[derive(Debug)]
|
||||
pub struct Unpacked {
|
||||
/// Every file but the manifest and the signature, relative.
|
||||
pub files: Vec<PathBuf>,
|
||||
pub signature: Option<PackageSignature>,
|
||||
}
|
||||
|
||||
/// `name` as a relative path that stays inside the package.
|
||||
fn safe_path(name: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(name);
|
||||
(!name.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))).then(|| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Archiver metadata at the top of an archive, not part of a package
|
||||
/// (`__MACOSX/…`, `.DS_Store`, AppleDouble `._…` files).
|
||||
fn archiver_metadata(name: &str) -> bool {
|
||||
let top = name.split('/').next().unwrap_or_default();
|
||||
top == "__MACOSX" || top == ".DS_Store" || (top.starts_with("._") && top.len() > 2)
|
||||
}
|
||||
|
||||
/// Where the package is inside an archive: at its root (`.oiplugin`, or a zip
|
||||
/// of the package's contents) or in its only folder (a zip of the package
|
||||
/// folder). `None` when neither holds a manifest.
|
||||
fn package_prefix(names: &[String]) -> Option<String> {
|
||||
if names.iter().any(|n| n == MANIFEST_FILE) {
|
||||
return Some(String::new());
|
||||
}
|
||||
let mut tops = names.iter().filter(|n| !archiver_metadata(n)).map(|n| n.split('/').next().unwrap_or_default());
|
||||
let top = tops.next()?;
|
||||
if tops.any(|t| t != top) {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("{top}/");
|
||||
names.iter().any(|n| *n == format!("{prefix}{MANIFEST_FILE}")).then_some(prefix)
|
||||
}
|
||||
|
||||
/// Extracts the package `archive` (an `.oiplugin`, or a `.zip` of a package
|
||||
/// folder or of its contents) into the empty directory `dest`, refusing
|
||||
/// paths leaving it, links, and oversized contents.
|
||||
pub fn unpack(archive: &Path, dest: &Path) -> Result<Unpacked, String> {
|
||||
let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?;
|
||||
let mut zip =
|
||||
zip::ZipArchive::new(file).map_err(|e| format!("{}: not a plugin package ({e})", archive.display()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(format!("more than {MAX_ENTRIES} files"));
|
||||
}
|
||||
let mut names = Vec::with_capacity(zip.len());
|
||||
for i in 0..zip.len() {
|
||||
let entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
if !entry.is_dir() {
|
||||
names.push(entry.name().map_err(|e| e.to_string())?.into_owned());
|
||||
}
|
||||
}
|
||||
// An IntelliJ plugin or any other zip: say what is missing rather than fail later.
|
||||
let prefix = package_prefix(&names).ok_or_else(|| {
|
||||
format!("{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", archive.display())
|
||||
})?;
|
||||
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 full = entry.name().map_err(|e| e.to_string())?.into_owned();
|
||||
// Outside the package folder there is only archiver metadata (`package_prefix` checked).
|
||||
let Some(name) = full.strip_prefix(&prefix).map(str::to_owned) else { continue };
|
||||
if archiver_metadata(&name) {
|
||||
continue;
|
||||
}
|
||||
if entry.is_symlink() {
|
||||
return Err(format!("{full}: links are not allowed in packages"));
|
||||
}
|
||||
let rel = safe_path(&name).ok_or_else(|| format!("invalid path {full:?} in package"))?;
|
||||
let mut bytes = Vec::new();
|
||||
entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
total += bytes.len() as u64;
|
||||
if total > MAX_TOTAL {
|
||||
return Err(format!("the package unpacks to more than {} MiB", MAX_TOTAL >> 20));
|
||||
}
|
||||
if rel == Path::new(SIGNATURE_FILE) {
|
||||
signature = Some(serde_json::from_slice(&bytes).map_err(|e| format!("{SIGNATURE_FILE}: {e}"))?);
|
||||
continue;
|
||||
}
|
||||
let to = dest.join(&rel);
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&to, bytes).map_err(|e| e.to_string())?;
|
||||
if rel == Path::new(MANIFEST_FILE) {
|
||||
manifest = true;
|
||||
} else {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
if !manifest {
|
||||
return Err(format!("the package has no {MANIFEST_FILE}"));
|
||||
}
|
||||
files.sort();
|
||||
Ok(Unpacked { files, signature })
|
||||
}
|
||||
|
||||
// ---- index -------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Index {
|
||||
pub format: u32,
|
||||
#[serde(default)]
|
||||
pub publishers: Vec<Publisher>,
|
||||
#[serde(default)]
|
||||
pub plugins: Vec<IndexEntry>,
|
||||
#[serde(default)]
|
||||
pub revoked: Vec<Revocation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Publisher {
|
||||
/// Owns plugin ids `<id>.…`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub keys: Vec<KeyEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeyEntry {
|
||||
/// Ed25519 public key, base64.
|
||||
pub key: String,
|
||||
/// Packages signed with it are no longer trusted.
|
||||
#[serde(default)]
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
/// A published plugin version.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IndexEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
pub publisher: String,
|
||||
/// The package, relative to the index (or an absolute URL).
|
||||
pub download: String,
|
||||
/// [`package_digest`] of its contents.
|
||||
pub digest: String,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
/// A plugin (all versions when `versions` is empty) that must not run.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Revocation {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub versions: Vec<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
|
||||
if index.format != INDEX_FORMAT {
|
||||
return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// The revocation that applies to `id` at `version`.
|
||||
pub fn revocation(&self, id: &str, version: &str) -> Option<&Revocation> {
|
||||
self.revoked.iter().find(|r| r.id == id && (r.versions.is_empty() || r.versions.iter().any(|v| v == version)))
|
||||
}
|
||||
|
||||
/// Whether `key` (base64) belongs to a publisher and was revoked.
|
||||
pub fn key_revoked(&self, key: &str) -> bool {
|
||||
self.publishers.iter().flat_map(|p| &p.keys).any(|k| k.key == key && k.revoked)
|
||||
}
|
||||
|
||||
/// The trusted publisher of a signed package: the signature is valid, its
|
||||
/// key is one of the publisher's and not revoked, and the publisher owns
|
||||
/// the plugin's id prefix.
|
||||
pub fn verify(&self, sig: &PackageSignature, id: &str, version: &str, digest: &str) -> Result<String, String> {
|
||||
let publisher = self
|
||||
.publishers
|
||||
.iter()
|
||||
.find(|p| p.id == sig.publisher)
|
||||
.ok_or_else(|| format!("unknown publisher {:?}", sig.publisher))?;
|
||||
let key = publisher
|
||||
.keys
|
||||
.iter()
|
||||
.find(|k| k.key == sig.key)
|
||||
.ok_or_else(|| format!("the key is not one of {}'s", publisher.name))?;
|
||||
if key.revoked {
|
||||
return Err(format!("{}'s signing key was revoked", publisher.name));
|
||||
}
|
||||
if !id.starts_with(&format!("{}.", publisher.id)) {
|
||||
return Err(format!("{} may only publish plugins named {}.*", publisher.name, publisher.id));
|
||||
}
|
||||
sig.check(id, version, digest)?;
|
||||
Ok(publisher.id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn package(dir: &Path) -> Vec<PathBuf> {
|
||||
std::fs::create_dir_all(dir.join("dist")).unwrap();
|
||||
std::fs::write(dir.join(MANIFEST_FILE), "id = \"acme.tools\"\nversion = \"1.0.0\"").unwrap();
|
||||
std::fs::write(dir.join("dist/plugin.wasm"), b"\0asm").unwrap();
|
||||
vec![PathBuf::from("dist/plugin.wasm")]
|
||||
}
|
||||
|
||||
fn index(key: &PublisherKey) -> Index {
|
||||
Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers: vec![Publisher {
|
||||
id: "acme".into(),
|
||||
name: "ACME".into(),
|
||||
keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
|
||||
}],
|
||||
plugins: Vec::new(),
|
||||
revoked: vec![Revocation {
|
||||
id: "acme.bad".into(),
|
||||
versions: vec!["1.0.0".into()],
|
||||
reason: "malware".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_packages_round_trip_and_tampering_is_caught() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let src = tmp.path().join("src");
|
||||
let files = package(&src);
|
||||
let digest = package_digest(&src, &files).unwrap();
|
||||
let key = PublisherKey::from_seed([7; 32]);
|
||||
let sig = key.sign("acme", "acme.tools", "1.0.0", &digest);
|
||||
let out = tmp.path().join("acme.tools-1.0.0.oiplugin");
|
||||
pack(&src, &files, &out, Some(&sig)).unwrap();
|
||||
|
||||
let dest = tmp.path().join("dest");
|
||||
let unpacked = unpack(&out, &dest).unwrap();
|
||||
assert_eq!(unpacked.files, files);
|
||||
assert_eq!(package_digest(&dest, &unpacked.files).unwrap(), digest);
|
||||
let index = index(&key);
|
||||
assert_eq!(index.verify(unpacked.signature.as_ref().unwrap(), "acme.tools", "1.0.0", &digest).unwrap(), "acme");
|
||||
|
||||
// Changed bytes, another id, someone else's prefix, a revoked key.
|
||||
assert!(index.verify(&sig, "acme.tools", "1.0.0", "0000").is_err());
|
||||
assert!(index.verify(&sig, "acme.tools", "1.0.1", &digest).is_err());
|
||||
let other = PublisherKey::from_seed([9; 32]).sign("acme", "acme.tools", "1.0.0", &digest);
|
||||
assert!(index.verify(&other, "acme.tools", "1.0.0", &digest).unwrap_err().contains("not one of"));
|
||||
let foreign = key.sign("acme", "evil.tools", "1.0.0", &digest);
|
||||
assert!(index.verify(&foreign, "evil.tools", "1.0.0", &digest).unwrap_err().contains("acme.*"));
|
||||
let mut revoked = index.clone();
|
||||
revoked.publishers[0].keys[0].revoked = true;
|
||||
assert!(revoked.verify(&sig, "acme.tools", "1.0.0", &digest).unwrap_err().contains("revoked"));
|
||||
assert!(revoked.key_revoked(&sig.key));
|
||||
assert_eq!(index.revocation("acme.bad", "1.0.0").unwrap().reason, "malware");
|
||||
assert!(index.revocation("acme.bad", "1.0.1").is_none());
|
||||
assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpacking_refuses_paths_leaving_the_package() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = tmp.path().join("evil.oiplugin");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file(MANIFEST_FILE, o).unwrap();
|
||||
zip.write_all(b"id = \"x\"").unwrap();
|
||||
zip.start_file("../outside.txt", o).unwrap();
|
||||
zip.write_all(b"x").unwrap();
|
||||
zip.finish().unwrap();
|
||||
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
|
||||
assert!(err.contains("invalid path"), "{err}");
|
||||
assert!(!tmp.path().join("outside.txt").exists());
|
||||
assert!(Index::parse(r#"{"format": 2}"#).is_err());
|
||||
}
|
||||
|
||||
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
|
||||
for (name, body) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zip_holds_the_package_at_its_root_or_in_its_only_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let manifest = "id = \"acme.tools\"";
|
||||
let at_root = tmp.path().join("root.zip");
|
||||
zip_of(&at_root, &[(MANIFEST_FILE, manifest), ("dist/plugin.wasm", "w"), (".DS_Store", "x")]);
|
||||
let got = unpack(&at_root, &tmp.path().join("a")).unwrap();
|
||||
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
|
||||
|
||||
// What zipping the folder in Finder makes: the folder plus `__MACOSX`.
|
||||
let folder = tmp.path().join("folder.zip");
|
||||
zip_of(
|
||||
&folder,
|
||||
&[
|
||||
("acme-tools/plugin.toml", manifest),
|
||||
("acme-tools/dist/plugin.wasm", "w"),
|
||||
("__MACOSX/acme-tools/._plugin.toml", "x"),
|
||||
],
|
||||
);
|
||||
let dest = tmp.path().join("b");
|
||||
let got = unpack(&folder, &dest).unwrap();
|
||||
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
|
||||
assert!(dest.join(MANIFEST_FILE).is_file());
|
||||
assert!(!dest.join("__MACOSX").exists() && !dest.join("acme-tools").exists());
|
||||
|
||||
// Two folders, a manifest one level too deep, or none (an IntelliJ plugin): not a package.
|
||||
for (name, entries) in [
|
||||
("two.zip", &[("a/plugin.toml", manifest), ("b/x.txt", "x")][..]),
|
||||
("deep.zip", &[("a/b/plugin.toml", manifest)][..]),
|
||||
("intellij.zip", &[("my-plugin/lib/my-plugin.jar", "jar")][..]),
|
||||
] {
|
||||
let path = tmp.path().join(name);
|
||||
zip_of(&path, entries);
|
||||
let err = unpack(&path, &tmp.path().join(name).with_extension("out")).unwrap_err();
|
||||
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The extension point contract (plan 4.1).
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::manifest::{Manifest, check_relative};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Where a contribution takes effect (plan 3, placement rules).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Placement {
|
||||
/// Projected to the client (menus, keybindings, themes, icons).
|
||||
Ui,
|
||||
/// Takes effect on the machine the project is on (languages, LSP, run configurations).
|
||||
Workspace,
|
||||
}
|
||||
|
||||
/// The package a declaration comes from, as extension points see it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Package {
|
||||
pub manifest: Arc<Manifest>,
|
||||
/// Package directory.
|
||||
pub dir: PathBuf,
|
||||
/// Hash of the package's files when they never change (an installed
|
||||
/// copy): validated declarations are then reused from the
|
||||
/// [`ContributionIndex`](crate::ContributionIndex).
|
||||
pub digest: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
/// Largest single package file an extension point reads.
|
||||
pub const MAX_PACKAGE_FILE: u64 = 8 << 20;
|
||||
|
||||
impl Package {
|
||||
pub fn new(manifest: Manifest, dir: PathBuf) -> Self {
|
||||
Self { manifest: Arc::new(manifest), dir, digest: None }
|
||||
}
|
||||
|
||||
pub fn with_digest(mut self, digest: &str) -> Self {
|
||||
self.digest = Some(digest.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.manifest.id
|
||||
}
|
||||
|
||||
/// Resolves a package-relative path, refusing anything that leaves the
|
||||
/// package (`..`, absolute paths, symlinks pointing outside).
|
||||
pub fn resolve(&self, rel: &Path) -> Result<PathBuf, String> {
|
||||
check_relative(rel, "path")?;
|
||||
let joined = self.dir.join(rel);
|
||||
let real = joined.canonicalize().map_err(|_| format!("{}: no such file in the plugin", rel.display()))?;
|
||||
let root = self.dir.canonicalize().map_err(|e| format!("plugin directory: {e}"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{}: points outside the plugin", rel.display()));
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
pub fn read_bytes(&self, rel: &Path) -> Result<Vec<u8>, String> {
|
||||
let real = self.resolve(rel)?;
|
||||
let meta = std::fs::metadata(&real).map_err(|e| format!("{}: {e}", rel.display()))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a file", rel.display()));
|
||||
}
|
||||
if meta.len() > MAX_PACKAGE_FILE {
|
||||
return Err(format!("{}: larger than {} MiB", rel.display(), MAX_PACKAGE_FILE >> 20));
|
||||
}
|
||||
std::fs::read(&real).map_err(|e| format!("{}: {e}", rel.display()))
|
||||
}
|
||||
|
||||
pub fn read_text(&self, rel: &Path) -> Result<String, String> {
|
||||
String::from_utf8(self.read_bytes(rel)?).map_err(|_| format!("{}: not UTF-8 text", rel.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Undoes one committed contribution. Dropping without [`dispose`](Self::dispose) also disposes.
|
||||
#[must_use = "dropping a Disposable undoes the contribution"]
|
||||
pub struct Disposable(Option<Box<dyn FnOnce() + Send>>);
|
||||
|
||||
impl Disposable {
|
||||
pub fn new(f: impl FnOnce() + Send + 'static) -> Self {
|
||||
Self(Some(Box::new(f)))
|
||||
}
|
||||
|
||||
/// A contribution with nothing to undo.
|
||||
pub fn noop() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
|
||||
pub fn dispose(mut self) {
|
||||
if let Some(f) = self.0.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Disposable {
|
||||
fn drop(&mut self) {
|
||||
if let Some(f) = self.0.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Disposable {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Disposable")
|
||||
}
|
||||
}
|
||||
|
||||
pub type Commit = Box<dyn FnOnce() -> Result<Disposable, String> + Send>;
|
||||
|
||||
/// A validated declaration, ready to take effect.
|
||||
pub struct Prepared {
|
||||
/// Capabilities this declaration needs (a language with an LSP → `process:<binary>`).
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// Package files the declaration references (copied on install).
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Identity within the point: a later declaration with the same key replaces
|
||||
/// the earlier one of the same plugin (re-registering a command).
|
||||
pub key: Option<String>,
|
||||
/// User-facing one-liner for the plugin details page ("Insert Date").
|
||||
pub label: String,
|
||||
/// Makes it take effect. Must not fail for reasons `prepare` could have checked.
|
||||
pub commit: Commit,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub fn new(label: impl Into<String>, commit: impl FnOnce() -> Result<Disposable, String> + Send + 'static) -> Self {
|
||||
Self { capabilities: Vec::new(), files: Vec::new(), key: None, label: label.into(), commit: Box::new(commit) }
|
||||
}
|
||||
|
||||
pub fn key(mut self, key: impl Into<String>) -> Self {
|
||||
self.key = Some(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.files.push(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn capability(mut self, cap: Capability) -> Self {
|
||||
if !self.capabilities.contains(&cap) {
|
||||
self.capabilities.push(cap);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Prepared {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Prepared").field("key", &self.key).field("label", &self.label).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// A kind of contribution (`contributes.<id>` in the manifest). Implemented by
|
||||
/// the crate that owns the feature; registered once in an [`crate::ExtensionRegistry`].
|
||||
pub trait ExtensionPoint: Send + Sync {
|
||||
/// `"languages"`, `"iconThemes"`, …
|
||||
fn id(&self) -> &'static str;
|
||||
|
||||
fn placement(&self) -> Placement;
|
||||
|
||||
/// Parses and validates one declaration without side effects, and works
|
||||
/// out the capabilities it needs. `Ui` points validate completely here: the
|
||||
/// client's projection is not allowed to fail.
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String>;
|
||||
}
|
||||
|
||||
/// Deserializes a declaration with serde, prefixing errors with the point id.
|
||||
pub fn decode<T: serde::de::DeserializeOwned>(point: &str, decl: toml::Value) -> Result<T, String> {
|
||||
decl.try_into().map_err(|e: toml::de::Error| format!("contributes.{point}: {}", e.message()))
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! 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;
|
||||
|
||||
/// A contribution as the plugin details page lists it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContributionInfo {
|
||||
pub point: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
point: &'static str,
|
||||
key: Option<String>,
|
||||
label: String,
|
||||
disposable: Disposable,
|
||||
}
|
||||
|
||||
/// Every declaration of a package, validated; nothing has taken effect yet.
|
||||
pub struct PreparedSet {
|
||||
pub plugin: String,
|
||||
/// Union of what the declarations need (beyond the manifest's own list).
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// Package files the declarations reference.
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Declarations for extension points this host does not know (skipped).
|
||||
pub warnings: Vec<String>,
|
||||
items: Vec<(&'static str, Prepared)>,
|
||||
}
|
||||
|
||||
impl PreparedSet {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PreparedSet {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PreparedSet")
|
||||
.field("plugin", &self.plugin)
|
||||
.field("items", &self.items.len())
|
||||
.field("warnings", &self.warnings)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ExtensionRegistry {
|
||||
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
|
||||
/// plugin id → its contributions in commit order.
|
||||
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
|
||||
}
|
||||
|
||||
impl ExtensionRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Adds an extension point. Ids are unique.
|
||||
pub fn register(&self, point: Arc<dyn ExtensionPoint>) -> Result<(), String> {
|
||||
let id = point.id();
|
||||
crate::manifest::validate_point_id(id)?;
|
||||
let mut points = self.points.write();
|
||||
if points.contains_key(id) {
|
||||
return Err(format!("extension point {id:?} is already registered"));
|
||||
}
|
||||
points.insert(id, point);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn point_ids(&self) -> Vec<&'static str> {
|
||||
self.points.read().keys().copied().collect()
|
||||
}
|
||||
|
||||
pub fn placement(&self, point: &str) -> Option<Placement> {
|
||||
self.points.read().get(point).map(|p| p.placement())
|
||||
}
|
||||
|
||||
fn point(&self, id: &str) -> Option<Arc<dyn ExtensionPoint>> {
|
||||
self.points.read().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
|
||||
/// whole on the first invalid declaration; unknown points are skipped with
|
||||
/// a warning (forward compatibility).
|
||||
pub fn prepare(&self, pkg: &Package) -> Result<PreparedSet, String> {
|
||||
let mut set = PreparedSet {
|
||||
plugin: pkg.id().to_owned(),
|
||||
capabilities: Vec::new(),
|
||||
files: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
items: Vec::new(),
|
||||
};
|
||||
for point_id in pkg.manifest.contributes.keys() {
|
||||
let Some(point) = self.point(point_id) else {
|
||||
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
|
||||
tracing::warn!(plugin = %pkg.id(), "{msg}");
|
||||
set.warnings.push(msg);
|
||||
continue;
|
||||
};
|
||||
for (i, decl) in pkg.manifest.declarations(point_id).into_iter().enumerate() {
|
||||
let prepared = point
|
||||
.prepare(pkg, decl)
|
||||
.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(), 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) -> Result<(), String> {
|
||||
let mut committed: Vec<Entry> = Vec::with_capacity(set.items.len());
|
||||
for (point, prepared) in set.items {
|
||||
let Prepared { key, label, commit, .. } = prepared;
|
||||
match commit() {
|
||||
Ok(disposable) => committed.push(Entry { point, key, label, 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(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
pub fn contributions(&self, plugin: &str) -> Vec<ContributionInfo> {
|
||||
self.entries
|
||||
.lock()
|
||||
.get(plugin)
|
||||
.map(|list| {
|
||||
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// How many contributions `plugin` has at `point`.
|
||||
pub fn count(&self, plugin: &str, point: &str) -> usize {
|
||||
self.entries.lock().get(plugin).map_or(0, |list| list.iter().filter(|e| e.point == point).count())
|
||||
}
|
||||
|
||||
/// Plugins with at least one contribution.
|
||||
pub fn plugins(&self) -> Vec<String> {
|
||||
self.entries.lock().iter().filter(|(_, l)| !l.is_empty()).map(|(id, _)| id.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
|
||||
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::Manifest;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Records live items in a shared list; `fail = true` makes commit fail.
|
||||
struct Recording {
|
||||
id: &'static str,
|
||||
live: Arc<Mutex<Vec<String>>>,
|
||||
commits: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ExtensionPoint for Recording {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
}
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Ui
|
||||
}
|
||||
fn prepare(&self, _pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let name = decl.get("name").and_then(|v| v.as_str()).ok_or("missing name")?.to_owned();
|
||||
let fail = decl.get("fail").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let (live, commits) = (self.live.clone(), self.commits.clone());
|
||||
let label = name.clone();
|
||||
let mut p = Prepared::new(label, move || {
|
||||
commits.fetch_add(1, Ordering::SeqCst);
|
||||
if fail {
|
||||
return Err("boom".into());
|
||||
}
|
||||
live.lock().push(name.clone());
|
||||
Ok(Disposable::new(move || live.lock().retain(|n| *n != name)))
|
||||
});
|
||||
if let Some(f) = decl.get("file").and_then(|v| v.as_str()) {
|
||||
p = p.file(f);
|
||||
}
|
||||
if decl.get("key").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
p = p.key("k");
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (ExtensionRegistry, Arc<Mutex<Vec<String>>>) {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let live = Arc::new(Mutex::new(Vec::new()));
|
||||
let commits = Arc::new(AtomicUsize::new(0));
|
||||
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: commits.clone() })).unwrap();
|
||||
reg.register(Arc::new(Recording { id: "others", live: live.clone(), commits })).unwrap();
|
||||
(reg, live)
|
||||
}
|
||||
|
||||
fn pkg(contributes: &str) -> Package {
|
||||
let src = format!("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n{contributes}");
|
||||
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_and_disposes_per_plugin() {
|
||||
let (reg, live) = setup();
|
||||
assert!(
|
||||
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: Default::default() }))
|
||||
.is_err()
|
||||
);
|
||||
let p = pkg(
|
||||
"[[contributes.things]]\nname = \"a\"\nfile = \"x.json\"\n[[contributes.things]]\nname = \"b\"\n[contributes.others]\nname = \"c\"",
|
||||
);
|
||||
let set = reg.prepare(&p).unwrap();
|
||||
assert_eq!(set.len(), 3);
|
||||
assert_eq!(set.files, [PathBuf::from("x.json")]);
|
||||
assert!(live.lock().is_empty(), "prepare has no side effects");
|
||||
reg.commit(set).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()).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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! UI contributions: validated declarations projected to the client.
|
||||
//!
|
||||
//! A `Ui` extension point validates a declaration completely in Rust and
|
||||
//! commits it as JSON into the [`UiStore`]; the client receives the store's
|
||||
//! snapshot and projects each item onto an existing `register*` function
|
||||
//! (`defineUiPoint` in `src/features/plugins/projection.ts`). Large payloads
|
||||
//! (icon SVGs) go into the store's assets and are fetched on demand.
|
||||
|
||||
use crate::index::ContributionIndex;
|
||||
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// One projected declaration.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UiContribution {
|
||||
pub plugin_id: String,
|
||||
/// Extension point id (`commands`, `iconThemes`, …).
|
||||
pub point: String,
|
||||
/// Stable within (plugin, point): the client keys its registrations on it.
|
||||
pub key: String,
|
||||
/// The validated declaration, shaped by the point.
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub decl: serde_json::Value,
|
||||
}
|
||||
|
||||
struct Item {
|
||||
seq: u64,
|
||||
contribution: UiContribution,
|
||||
}
|
||||
|
||||
/// Committed UI contributions of all plugins.
|
||||
#[derive(Default)]
|
||||
pub struct UiStore {
|
||||
items: RwLock<Vec<Item>>,
|
||||
/// (plugin, asset id) → content.
|
||||
assets: RwLock<BTreeMap<(String, String), Arc<str>>>,
|
||||
seq: AtomicU64,
|
||||
revision: AtomicU64,
|
||||
index: RwLock<Option<Arc<ContributionIndex>>>,
|
||||
}
|
||||
|
||||
impl UiStore {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Adds an item; the returned disposable removes it.
|
||||
pub fn insert(self: &Arc<Self>, contribution: UiContribution) -> Disposable {
|
||||
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
self.items.write().push(Item { seq, contribution });
|
||||
self.revision.fetch_add(1, Ordering::Release);
|
||||
let store = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(store) = store.upgrade() {
|
||||
store.items.write().retain(|i| i.seq != seq);
|
||||
store.revision.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Stores assets of a plugin (ids are unique per plugin; later ones replace).
|
||||
pub fn insert_assets(self: &Arc<Self>, plugin: &str, assets: Vec<(String, String)>) -> Disposable {
|
||||
let keys: Vec<(String, String)> = assets.iter().map(|(id, _)| (plugin.to_owned(), id.clone())).collect();
|
||||
{
|
||||
let mut map = self.assets.write();
|
||||
for (id, content) in assets {
|
||||
map.insert((plugin.to_owned(), id), Arc::from(content));
|
||||
}
|
||||
}
|
||||
let store = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(store) = store.upgrade() {
|
||||
let mut map = store.assets.write();
|
||||
for k in keys {
|
||||
map.remove(&k);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn asset(&self, plugin: &str, id: &str) -> Option<Arc<str>> {
|
||||
self.assets.read().get(&(plugin.to_owned(), id.to_owned())).cloned()
|
||||
}
|
||||
|
||||
/// Assets of `plugin` whose id starts with `prefix`.
|
||||
pub fn assets_with_prefix(&self, plugin: &str, prefix: &str) -> Vec<(String, Arc<str>)> {
|
||||
self.assets
|
||||
.read()
|
||||
.range((plugin.to_owned(), prefix.to_owned())..)
|
||||
.take_while(|((p, id), _)| p == plugin && id.starts_with(prefix))
|
||||
.map(|((_, id), v)| (id.clone(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where [`UiPoint`]s keep validated declarations of installed packages.
|
||||
pub fn set_index(&self, index: Option<Arc<ContributionIndex>>) {
|
||||
*self.index.write() = index;
|
||||
}
|
||||
|
||||
/// Bumped on every change: clients compare it to skip re-projection.
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Vec<UiContribution> {
|
||||
self.items.read().iter().map(|i| i.contribution.clone()).collect()
|
||||
}
|
||||
|
||||
/// Items of one point, optionally of one plugin.
|
||||
pub fn items(&self, point: &str, plugin: Option<&str>) -> Vec<UiContribution> {
|
||||
self.items
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|i| i.contribution.point == point && plugin.is_none_or(|p| i.contribution.plugin_id == p))
|
||||
.map(|i| i.contribution.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`UiPoint`]'s validator produces for one declaration.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UiDecl {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub json: serde_json::Value,
|
||||
/// Package files referenced (copied on install).
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Large validated payloads fetched separately by the client.
|
||||
pub assets: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl UiDecl {
|
||||
pub fn new(key: impl Into<String>, label: impl Into<String>, json: serde_json::Value) -> Self {
|
||||
Self { key: key.into(), label: label.into(), json, files: Vec::new(), assets: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
type Validate = dyn Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync;
|
||||
|
||||
/// A `Ui` extension point defined by a validator: committing stores the
|
||||
/// validated JSON (and assets) in the [`UiStore`]. Most UI contribution types
|
||||
/// (menus, keybindings, status bar items, icon themes) are one of these.
|
||||
pub struct UiPoint {
|
||||
id: &'static str,
|
||||
store: Arc<UiStore>,
|
||||
validate: Box<Validate>,
|
||||
}
|
||||
|
||||
impl UiPoint {
|
||||
pub fn new(
|
||||
id: &'static str,
|
||||
store: Arc<UiStore>,
|
||||
validate: impl Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self { id, store, validate: Box::new(validate) }
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionPoint for UiPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Ui
|
||||
}
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let index = pkg.digest.as_ref().and_then(|_| self.store.index.read().clone());
|
||||
let ui = match index.as_ref().and_then(|i| i.get(pkg, self.id, &decl)) {
|
||||
Some(ui) => ui,
|
||||
None => match &index {
|
||||
Some(i) => {
|
||||
let ui = (self.validate)(pkg, decl.clone())?;
|
||||
i.put(pkg, self.id, &decl, &ui);
|
||||
ui
|
||||
}
|
||||
None => (self.validate)(pkg, decl)?,
|
||||
},
|
||||
};
|
||||
let UiDecl { key, label, json, files, assets } = ui;
|
||||
let store = self.store.clone();
|
||||
let contribution =
|
||||
UiContribution { plugin_id: pkg.id().to_owned(), point: self.id.to_owned(), key: key.clone(), decl: json };
|
||||
let plugin = pkg.id().to_owned();
|
||||
let mut prepared = Prepared::new(label, move || {
|
||||
let item = store.insert(contribution);
|
||||
if assets.is_empty() {
|
||||
return Ok(item);
|
||||
}
|
||||
let assets = store.insert_assets(&plugin, assets);
|
||||
Ok(Disposable::new(move || {
|
||||
item.dispose();
|
||||
assets.dispose();
|
||||
}))
|
||||
})
|
||||
.key(key);
|
||||
prepared.files = files;
|
||||
Ok(prepared)
|
||||
}
|
||||
}
|
||||
|
||||
/// TOML declaration → JSON for the client.
|
||||
pub fn to_json(v: &toml::Value) -> serde_json::Value {
|
||||
serde_json::to_value(v).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::registry::ExtensionRegistry;
|
||||
|
||||
#[test]
|
||||
fn ui_point_projects_into_the_store() {
|
||||
let store = UiStore::new();
|
||||
let reg = ExtensionRegistry::new();
|
||||
reg.register(Arc::new(UiPoint::new("badges", store.clone(), |_pkg, decl| {
|
||||
let text = decl.get("text").and_then(|v| v.as_str()).ok_or("missing text")?.to_owned();
|
||||
let mut d = UiDecl::new(text.clone(), text.clone(), serde_json::json!({ "text": text }));
|
||||
d.assets.push((format!("badge/{text}"), "<svg/>".into()));
|
||||
Ok(d)
|
||||
})))
|
||||
.unwrap();
|
||||
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
|
||||
[[contributes.badges]]\ntext = \"hi\"";
|
||||
let pkg = Package::new(Manifest::parse(src).unwrap(), PathBuf::from("/x"));
|
||||
let rev = store.revision();
|
||||
reg.commit(reg.prepare(&pkg).unwrap()).unwrap();
|
||||
assert!(store.revision() > rev);
|
||||
let items = store.items("badges", Some("p"));
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].decl["text"], "hi");
|
||||
assert_eq!(store.asset("p", "badge/hi").as_deref(), Some("<svg/>"));
|
||||
assert_eq!(store.assets_with_prefix("p", "badge/").len(), 1);
|
||||
reg.dispose_plugin("p");
|
||||
assert!(store.snapshot().is_empty());
|
||||
assert!(store.asset("p", "badge/hi").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Syntax check for `when` clauses in UI declarations. The client evaluates
|
||||
//! them (`src/platform/context.ts`); validating here keeps the client's
|
||||
//! projection from failing. Grammar (same as the client):
|
||||
//!
|
||||
//! ```text
|
||||
//! expr := or ; or := and ("||" and)* ; and := unary ("&&" unary)*
|
||||
//! unary := "!" unary | "(" expr ")" | cmp ; cmp := key (("==" | "!=") value)?
|
||||
//! ```
|
||||
|
||||
const MAX_LEN: usize = 512;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Tok<'a> {
|
||||
Id(&'a str),
|
||||
Str,
|
||||
Op(&'a str),
|
||||
}
|
||||
|
||||
fn tokenize(src: &str) -> Result<Vec<Tok<'_>>, String> {
|
||||
let mut out = Vec::new();
|
||||
let b = src.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
let c = b[i] as char;
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if ["&&", "||", "==", "!="].iter().any(|op| src[i..].starts_with(op)) {
|
||||
out.push(Tok::Op(&src[i..i + 2]));
|
||||
i += 2;
|
||||
} else if matches!(c, '!' | '(' | ')') {
|
||||
out.push(Tok::Op(&src[i..i + 1]));
|
||||
i += 1;
|
||||
} else if c == '\'' || c == '"' {
|
||||
let end = src[i + 1..].find(c).ok_or_else(|| format!("unterminated string in when clause: {src}"))?;
|
||||
out.push(Tok::Str);
|
||||
i += end + 2;
|
||||
} else {
|
||||
let len = src[i..]
|
||||
.find(|ch: char| !(ch.is_alphanumeric() || matches!(ch, '_' | '.' | '-' | ':' | '/')))
|
||||
.unwrap_or(src.len() - i);
|
||||
if len == 0 {
|
||||
return Err(format!("unexpected '{c}' in when clause: {src}"));
|
||||
}
|
||||
out.push(Tok::Id(&src[i..i + len]));
|
||||
i += len;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Checks a `when` clause; empty means "always".
|
||||
pub fn validate_when(src: &str) -> Result<(), String> {
|
||||
if src.len() > MAX_LEN {
|
||||
return Err(format!("when clause longer than {MAX_LEN} bytes"));
|
||||
}
|
||||
if src.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let toks = tokenize(src)?;
|
||||
let mut p = 0;
|
||||
parse_or(&toks, &mut p, src)?;
|
||||
if p != toks.len() {
|
||||
return Err(format!("unexpected token in when clause: {src}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_or(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
parse_and(t, p, src)?;
|
||||
while t.get(*p) == Some(&Tok::Op("||")) {
|
||||
*p += 1;
|
||||
parse_and(t, p, src)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_and(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
parse_unary(t, p, src)?;
|
||||
while t.get(*p) == Some(&Tok::Op("&&")) {
|
||||
*p += 1;
|
||||
parse_unary(t, p, src)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
match t.get(*p) {
|
||||
Some(Tok::Op("!")) => {
|
||||
*p += 1;
|
||||
parse_unary(t, p, src)
|
||||
}
|
||||
Some(Tok::Op("(")) => {
|
||||
*p += 1;
|
||||
parse_or(t, p, src)?;
|
||||
if t.get(*p) != Some(&Tok::Op(")")) {
|
||||
return Err(format!("missing ')' in when clause: {src}"));
|
||||
}
|
||||
*p += 1;
|
||||
Ok(())
|
||||
}
|
||||
Some(Tok::Id(_)) => {
|
||||
*p += 1;
|
||||
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
|
||||
*p += 1;
|
||||
match t.get(*p) {
|
||||
Some(Tok::Id(_) | Tok::Str) => *p += 1,
|
||||
_ => return Err(format!("missing value after comparison in when clause: {src}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("expected a context key in when clause: {src}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_when;
|
||||
|
||||
#[test]
|
||||
fn accepts_the_client_grammar() {
|
||||
for ok in [
|
||||
"",
|
||||
"editorFocus",
|
||||
"editorLang == rust",
|
||||
"editorLang != 'rust' && !readOnly",
|
||||
"(a || b) && toolWindow.project.active",
|
||||
"resourceScheme == file:/x",
|
||||
] {
|
||||
assert!(validate_when(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in ["a &&", "(a", "a == ", "== a", "a b", "a $ b", "'x", "!()"] {
|
||||
assert!(validate_when(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,15 +30,23 @@ ide-terminal.workspace = true
|
||||
ide-run.workspace = true
|
||||
ide-sdk.workspace = true
|
||||
ide-http.workspace = true
|
||||
reqwest.workspace = true
|
||||
ignore.workspace = true
|
||||
base64.workspace = true
|
||||
getrandom = "0.3"
|
||||
globset.workspace = true
|
||||
ide-db.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-notebook.workspace = true
|
||||
ide-extension.workspace = true
|
||||
ide-plugin.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
tokio = { workspace = true, features = ["io-std"] }
|
||||
@@ -53,9 +61,15 @@ 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 }
|
||||
zip.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1.1", features = ["fs", "process"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
||||
|
||||
[[bench]]
|
||||
name = "plugins"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! `cargo bench -p ide-host`: the plugin runtime budgets, checked by `pnpm perf:rust` against
|
||||
//! `perf/budgets.json` › rust (`plugins/…`).
|
||||
//!
|
||||
//! - `load_50`: start-up load of 50 installed plugins (20 with code, 10
|
||||
//! languages with a syntax provider, 10 icon themes, 10 declarations only),
|
||||
//! from the contribution index; no Wasm is instantiated.
|
||||
//! - `cold_activation`: an idle-reclaimed plugin's next command: activation
|
||||
//! from the precompiled component plus the call.
|
||||
//! - `syntax_highlights_2k_lines`: an edit-path call (Solidity highlighting).
|
||||
|
||||
use criterion::{Criterion, black_box, criterion_group, criterion_main};
|
||||
use ide_core::LanguageId;
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn repo() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for e in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let name = e.file_name();
|
||||
if matches!(name.to_str(), Some("target" | "src" | "Cargo.lock" | ".gitignore")) {
|
||||
continue;
|
||||
}
|
||||
let dst = to.join(&name);
|
||||
if e.path().is_dir() { copy_dir(&e.path(), &dst) } else { std::fs::copy(e.path(), &dst).map(|_| ()).unwrap() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of `src` installed under a new id (and new language / theme ids).
|
||||
fn variant(work: &Path, src: &Path, n: usize, rename: &[(&str, String)]) -> PathBuf {
|
||||
let dir = work.join(format!("pkg{n}"));
|
||||
copy_dir(src, &dir);
|
||||
for file in ["plugin.toml", "languages/solidity.toml"] {
|
||||
let path = dir.join(file);
|
||||
if let Ok(mut text) = std::fs::read_to_string(&path) {
|
||||
for (from, to) in rename {
|
||||
text = text.replace(from, to);
|
||||
}
|
||||
std::fs::write(&path, text).unwrap();
|
||||
}
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
fn sink() -> Arc<dyn ide_host::plugins::PluginEventSink> {
|
||||
Arc::new(|_: Option<&str>, _| {})
|
||||
}
|
||||
|
||||
fn install_50(plugins: &Path, work: &Path) {
|
||||
let host = PluginHost::new(plugins.to_path_buf(), sink(), Limits::default()).unwrap();
|
||||
host.load_all();
|
||||
let root = repo();
|
||||
let mut n = 0;
|
||||
let mut install = |src: PathBuf, rename: Vec<(&str, String)>| {
|
||||
let pkg = variant(work, &src, n, &rename);
|
||||
n += 1;
|
||||
host.install_from_dir(&pkg, &Consent::default()).unwrap();
|
||||
};
|
||||
for i in 0..20 {
|
||||
install(
|
||||
root.join("crates/ide-plugin/tests/fixtures/hello"),
|
||||
vec![("id = \"hello\"", format!("id = \"bench.hello{i}\""))],
|
||||
);
|
||||
}
|
||||
for i in 0..10 {
|
||||
install(
|
||||
root.join("plugins/official/solidity"),
|
||||
vec![
|
||||
("openidea.solidity", format!("bench.solidity{i}")),
|
||||
("id = \"solidity\"", format!("id = \"solidity{i}\"")),
|
||||
("extensions = [\"sol\"]", format!("extensions = [\"sol{i}\"]")),
|
||||
("on-language:solidity", format!("on-language:solidity{i}")),
|
||||
("id = \"forge-", format!("id = \"f{i}-")),
|
||||
("id = \"hardhat-", format!("id = \"h{i}-")),
|
||||
],
|
||||
);
|
||||
}
|
||||
for i in 0..10 {
|
||||
install(
|
||||
root.join("plugins/official/material-icons"),
|
||||
vec![
|
||||
("openidea.material-icons", format!("bench.icons{i}")),
|
||||
("id = \"material\"", format!("id = \"material{i}\"")),
|
||||
],
|
||||
);
|
||||
}
|
||||
for i in 0..10 {
|
||||
let dir = work.join(format!("static{i}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("plugin.toml"),
|
||||
format!(
|
||||
"id = \"bench.static{i}\"\nname = \"Static {i}\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
|
||||
[[contributes.commands]]\nid = \"a\"\ntitle = \"Static {i}: A\"\nwhen = \"editorLang == rust\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
host.install_from_dir(&dir, &Consent::default()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn bench(c: &mut Criterion) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins = tmp.path().join("plugins");
|
||||
install_50(&plugins, &tmp.path().join("work"));
|
||||
|
||||
let mut g = c.benchmark_group("plugins");
|
||||
g.sample_size(20);
|
||||
g.bench_function("load_50", |b| {
|
||||
b.iter(|| {
|
||||
let host = PluginHost::new(plugins.clone(), sink(), Limits::default()).unwrap();
|
||||
host.load_all();
|
||||
assert_eq!(host.instances(), 0, "start-up instantiates no Wasm");
|
||||
black_box(host.list(None).plugins.len())
|
||||
})
|
||||
});
|
||||
|
||||
let limits = Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() };
|
||||
let host = Arc::new(PluginHost::new(plugins.clone(), sink(), limits).unwrap());
|
||||
host.load_all();
|
||||
host.warm_component_cache();
|
||||
g.bench_function("cold_activation", |b| {
|
||||
b.iter(|| {
|
||||
host.tick();
|
||||
// Fails for want of an editor, after activating and running.
|
||||
let r = host.run_command("bench.hello0", "word-count", None, Invocation::default());
|
||||
black_box(r.is_err())
|
||||
})
|
||||
});
|
||||
|
||||
let provider = host.syntax_provider();
|
||||
let lang = LanguageId::new("solidity0");
|
||||
let unit = "contract Token {\n uint256 public total;\n mapping(address => uint256) balances;\n event Sent(address to, uint256 v);\n function send(address to, uint256 v) public returns (bool) {\n // move funds\n balances[to] += v;\n emit Sent(to, v);\n return true;\n }\n}\n";
|
||||
let text = unit.repeat(2000 / unit.lines().count());
|
||||
assert!(provider.highlights(&lang, &text).is_some());
|
||||
g.bench_function("syntax_highlights_2k_lines", |b| {
|
||||
b.iter(|| black_box(provider.highlights(&lang, &text).map(|t| t.len())))
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,624 @@
|
||||
//! `openidea-plugin`: scaffolds, builds, checks and links OpenIDEA plugins.
|
||||
//!
|
||||
//! ```text
|
||||
//! openidea-plugin new <dir> [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH]
|
||||
//! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check`
|
||||
//! openidea-plugin check [dir] # validate the package as the IDE would install it
|
||||
//! openidea-plugin link [dir] # load the folder in the IDE without installing it
|
||||
//! openidea-plugin unlink [dir]
|
||||
//! openidea-plugin links # list linked folders
|
||||
//! openidea-plugin keygen <key-file> # a publisher's Ed25519 key
|
||||
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # <id>-<version>.oiplugin
|
||||
//! openidea-plugin index <dir> --publisher ID --name NAME --key F # marketplace index.json
|
||||
//! ```
|
||||
//!
|
||||
//! `link` writes `links.json` in the IDE's plugins directory
|
||||
//! (`$OPENIDEA_PLUGINS_DIR`, or the app data directory); the IDE picks the
|
||||
//! change up when Settings › Plugins is opened or refreshed. Linked plugins
|
||||
//! are re-read from the folder, so `build` + refresh is the edit loop.
|
||||
|
||||
mod templates;
|
||||
|
||||
use ide_extension::{Manifest, validate_plugin_id};
|
||||
use ide_host::plugins::PluginHost;
|
||||
use ide_plugin::{Limits, PluginEngine};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: openidea-plugin <command> [options]
|
||||
|
||||
commands:
|
||||
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
|
||||
create a plugin; T = command (default), theme, icon-theme, language
|
||||
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
|
||||
check [dir] validate the package as the IDE would install it
|
||||
link [dir] load the folder in the IDE without installing it
|
||||
unlink [dir] stop loading a linked folder
|
||||
links list linked folders
|
||||
keygen <key-file> create a publisher signing key (keep the file secret)
|
||||
pack [dir] [--key F --publisher ID] [--out FILE]
|
||||
write <id>-<version>.oiplugin, signed with the key
|
||||
index <dir> --publisher ID --name NAME --key F [--out FILE]
|
||||
write the marketplace index.json for the packages in dir
|
||||
|
||||
environment:
|
||||
OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => cmd_new(&args[1..]),
|
||||
Some("build") => cmd_build(&args[1..]),
|
||||
Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)),
|
||||
Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)),
|
||||
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
|
||||
Some("links") => list_links(),
|
||||
Some("keygen") => cmd_keygen(&args[1..]),
|
||||
Some("pack") => cmd_pack(&args[1..]),
|
||||
Some("index") => cmd_index(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION);
|
||||
Ok(())
|
||||
}
|
||||
Some("help" | "--help" | "-h") | None => {
|
||||
println!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown command {other:?}\n\n{USAGE}")),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `args` into positionals and `--flag value` / `--switch` options.
|
||||
struct Args {
|
||||
positional: Vec<String>,
|
||||
options: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(args: &[String], switches: &[&str], valued: &[&str]) -> Result<Self, String> {
|
||||
let mut out = Args { positional: Vec::new(), options: Vec::new() };
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if let Some(name) = a.strip_prefix("--") {
|
||||
let (name, inline) = match name.split_once('=') {
|
||||
Some((n, v)) => (n, Some(v.to_owned())),
|
||||
None => (name, None),
|
||||
};
|
||||
if switches.contains(&name) && inline.is_none() {
|
||||
out.options.push((name.into(), None));
|
||||
} else if valued.contains(&name) {
|
||||
let v = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("--{name} needs a value"))?;
|
||||
out.options.push((name.into(), Some(v)));
|
||||
} else {
|
||||
return Err(format!("unknown option --{name}"));
|
||||
}
|
||||
} else {
|
||||
out.positional.push(a.clone());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn value(&self, name: &str) -> Option<&str> {
|
||||
self.options.iter().rev().find(|(n, _)| n == name).and_then(|(_, v)| v.as_deref())
|
||||
}
|
||||
|
||||
fn has(&self, name: &str) -> bool {
|
||||
self.options.iter().any(|(n, _)| n == name)
|
||||
}
|
||||
|
||||
fn dir(&self) -> Result<PathBuf, String> {
|
||||
match self.positional.as_slice() {
|
||||
[] => Ok(PathBuf::from(".")),
|
||||
[d] => Ok(PathBuf::from(d)),
|
||||
_ => Err("expected at most one directory".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_arg(args: &[String]) -> Result<PathBuf, String> {
|
||||
Args::parse(args, &[], &[])?.dir()
|
||||
}
|
||||
|
||||
fn canonical(dir: &Path) -> Result<PathBuf, String> {
|
||||
dunce::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- new
|
||||
|
||||
fn cmd_new(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["template", "id", "name", "sdk"])?;
|
||||
let [dir] = a.positional.as_slice() else {
|
||||
return Err("usage: openidea-plugin new <dir> [--template T] [--id ID] [--name NAME]".into());
|
||||
};
|
||||
let dir = PathBuf::from(dir);
|
||||
let template = templates::Template::parse(a.value("template").unwrap_or("command"))?;
|
||||
let base = dir.file_name().and_then(|n| n.to_str()).ok_or("the directory needs a name")?;
|
||||
let id = match a.value("id") {
|
||||
Some(id) => id.to_owned(),
|
||||
None => slugify(base),
|
||||
};
|
||||
validate_plugin_id(&id)?;
|
||||
let name = match a.value("name") {
|
||||
Some(n) => n.to_owned(),
|
||||
None => title_case(id.rsplit('.').next().unwrap_or(&id)),
|
||||
};
|
||||
if name.trim().is_empty() || name.contains(['"', '\\', '\n']) {
|
||||
return Err("--name must not be empty or contain quotes, backslashes or newlines".into());
|
||||
}
|
||||
if dir.exists() && std::fs::read_dir(&dir).map_err(|e| e.to_string())?.next().is_some() {
|
||||
return Err(format!("{} already exists and is not empty", dir.display()));
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let dir = canonical(&dir)?;
|
||||
let sdk = match template {
|
||||
templates::Template::Command => Some(sdk_path(a.value("sdk"), &dir)?),
|
||||
_ => None,
|
||||
};
|
||||
let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() };
|
||||
let files = templates::render(template, &vars);
|
||||
for (rel, content) in &files {
|
||||
let path = dir.join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&path, content).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
}
|
||||
// Generated packages must be valid as written.
|
||||
Manifest::load(&dir).map_err(|e| format!("internal error: the template is invalid: {e}"))?;
|
||||
println!("created {} ({id}) in {}", template.label(), dir.display());
|
||||
let shown = std::env::current_dir().ok().and_then(|cwd| dir.strip_prefix(cwd).ok().map(Path::to_path_buf));
|
||||
let shown = shown.as_deref().unwrap_or(&dir).display().to_string();
|
||||
println!();
|
||||
println!("next:");
|
||||
if template == templates::Template::Command {
|
||||
println!(" cargo test --manifest-path {shown}/Cargo.toml # unit tests against the mock host");
|
||||
println!(" openidea-plugin build {shown}");
|
||||
} else {
|
||||
println!(" openidea-plugin check {shown}");
|
||||
}
|
||||
println!(" openidea-plugin link {shown} # then Settings › Plugins");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `My Cool_Plugin` → `my-cool-plugin`.
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'a'..='z' | '0'..='9' | '.' => out.push(c),
|
||||
'A'..='Z' => out.push(c.to_ascii_lowercase()),
|
||||
_ if !out.ends_with('-') && !out.is_empty() => out.push('-'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.trim_matches(['-', '.']).to_owned()
|
||||
}
|
||||
|
||||
/// `my-cool_plugin` → `My Cool Plugin`.
|
||||
fn title_case(s: &str) -> String {
|
||||
s.split(['-', '_'])
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next().map(|f| f.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// The SDK dependency path written into a new plugin's Cargo.toml: `--sdk`,
|
||||
/// or the SDK of the source tree this tool was built from.
|
||||
fn sdk_path(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
|
||||
let sdk = match explicit {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin")),
|
||||
};
|
||||
let sdk = dunce::canonicalize(&sdk).map_err(|_| {
|
||||
format!("the plugin SDK was not found at {}; pass --sdk <path to sdk/rust/openidea-plugin>", sdk.display())
|
||||
})?;
|
||||
if !sdk.join("Cargo.toml").is_file() {
|
||||
return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display()));
|
||||
}
|
||||
let path = relative(plugin_dir, &sdk).unwrap_or(sdk);
|
||||
Ok(path.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
/// `to` relative to `from` when they share a root (keeps packages in a repo movable).
|
||||
fn relative(from: &Path, to: &Path) -> Option<PathBuf> {
|
||||
let (f, t): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
|
||||
let common = f.iter().zip(&t).take_while(|(a, b)| a == b).count();
|
||||
if common <= 1 {
|
||||
return None;
|
||||
}
|
||||
let mut out = PathBuf::new();
|
||||
for _ in common..f.len() {
|
||||
out.push("..");
|
||||
}
|
||||
for c in &t[common..] {
|
||||
out.push(c);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- build / check
|
||||
|
||||
fn cmd_build(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &["debug"], &[])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
let manifest = Manifest::load(&dir)?;
|
||||
let cargo_toml = dir.join("Cargo.toml");
|
||||
if !cargo_toml.is_file() {
|
||||
if manifest.wasm.is_some() {
|
||||
return Err(format!(
|
||||
"{} has no Cargo.toml: build the component with your toolchain, then run `check`",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
println!("{} has no code to build", manifest.id);
|
||||
return check(&dir);
|
||||
}
|
||||
let wasm_rel = manifest.wasm.clone().ok_or("plugin.toml has no `wasm = \"dist/plugin.wasm\"`")?;
|
||||
let crate_name = crate_name(&cargo_toml)?;
|
||||
ensure_wasm_target();
|
||||
let release = !a.has("debug");
|
||||
let target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| dir.join("target"));
|
||||
let mut cmd = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
|
||||
cmd.arg("build").arg("--manifest-path").arg(&cargo_toml).args(["--target", "wasm32-wasip2"]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
cmd.env("CARGO_TARGET_DIR", &target_dir);
|
||||
println!("building {crate_name} for wasm32-wasip2 ({})", if release { "release" } else { "debug" });
|
||||
let status = cmd.status().map_err(|e| format!("cannot run cargo: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err("cargo build failed".into());
|
||||
}
|
||||
let built = target_dir
|
||||
.join("wasm32-wasip2")
|
||||
.join(if release { "release" } else { "debug" })
|
||||
.join(format!("{}.wasm", crate_name.replace('-', "_")));
|
||||
if !built.is_file() {
|
||||
return Err(format!("{} was not produced: is `crate-type = [\"cdylib\"]` set in Cargo.toml?", built.display()));
|
||||
}
|
||||
let out = dir.join(&wasm_rel);
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::copy(&built, &out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
|
||||
println!(" -> {} ({})", wasm_rel.display(), human_size(size));
|
||||
check(&dir)
|
||||
}
|
||||
|
||||
fn crate_name(cargo_toml: &Path) -> Result<String, String> {
|
||||
let src = std::fs::read_to_string(cargo_toml).map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
|
||||
let doc: toml::Table = src.parse().map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
|
||||
let package = doc.get("package").and_then(|p| p.as_table());
|
||||
let lib_name = doc.get("lib").and_then(|l| l.get("name")).and_then(|n| n.as_str());
|
||||
lib_name
|
||||
.or_else(|| package.and_then(|p| p.get("name")).and_then(|n| n.as_str()))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| format!("{}: no [package] name", cargo_toml.display()))
|
||||
}
|
||||
|
||||
/// Installs the target through rustup when it is missing (no-op without rustup).
|
||||
fn ensure_wasm_target() {
|
||||
let Ok(out) = Command::new("rustup").args(["target", "list", "--installed"]).output() else { return };
|
||||
if String::from_utf8_lossy(&out.stdout).lines().any(|l| l.trim() == "wasm32-wasip2") {
|
||||
return;
|
||||
}
|
||||
println!("installing the wasm32-wasip2 target");
|
||||
let _ = Command::new("rustup").args(["target", "add", "wasm32-wasip2"]).status();
|
||||
}
|
||||
|
||||
/// Validates `dir` exactly as Install from Disk would, and checks the
|
||||
/// component imports only the plugin API.
|
||||
fn check(dir: &Path) -> Result<(), String> {
|
||||
let dir = canonical(dir)?;
|
||||
let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id()));
|
||||
let result = (|| {
|
||||
let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let preview = host.preview_install(&dir).map_err(|e| e.to_string())?;
|
||||
let manifest = Manifest::load(&dir)?;
|
||||
println!("{} {} — {}", preview.id, preview.version, preview.name);
|
||||
if manifest.legacy {
|
||||
println!(" note: plugin.toml uses the 0.1 format (`openidea-api`); see plugins/README.md to move to 0.2");
|
||||
}
|
||||
if let Some(wasm) = &manifest.wasm {
|
||||
let engine = PluginEngine::new()?;
|
||||
let component = engine.compile(&dir.join(wasm))?;
|
||||
let built_for = engine.check_component(&component)?;
|
||||
let size = std::fs::metadata(dir.join(wasm)).map(|m| m.len()).unwrap_or(0);
|
||||
let api = built_for.map(|v| format!(", plugin API {}.{}", v.major, v.minor)).unwrap_or_default();
|
||||
println!(" code: {} ({}{api})", wasm.display(), human_size(size));
|
||||
} else {
|
||||
println!(" code: none (contributions only)");
|
||||
}
|
||||
if !manifest.activation.is_empty() {
|
||||
let events: Vec<String> = manifest.activation.iter().map(ToString::to_string).collect();
|
||||
println!(" activation: {}", events.join(", "));
|
||||
}
|
||||
for c in &preview.contributions {
|
||||
println!(" contributes {} × {}", c.count, c.point);
|
||||
}
|
||||
for c in &preview.capabilities {
|
||||
let kind = if c.optional { "optional" } else { "required" };
|
||||
println!(" capability {} ({kind}, {:?} risk): {}", c.id, c.risk, c.description);
|
||||
}
|
||||
for w in &preview.warnings {
|
||||
println!(" warning: {w}");
|
||||
}
|
||||
println!("ok");
|
||||
Ok(())
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
result
|
||||
}
|
||||
|
||||
fn human_size(bytes: u64) -> String {
|
||||
match bytes {
|
||||
b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / (1 << 20) as f64),
|
||||
b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0),
|
||||
b => format!("{b} B"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- link
|
||||
|
||||
fn plugins_dir() -> Result<PathBuf, String> {
|
||||
if let Some(dir) = std::env::var_os("OPENIDEA_PLUGINS_DIR") {
|
||||
return Ok(PathBuf::from(dir));
|
||||
}
|
||||
dirs::data_dir()
|
||||
.map(|d| d.join("ai.l0ng.openidea").join("plugins"))
|
||||
.ok_or_else(|| "cannot find the app data directory; set OPENIDEA_PLUGINS_DIR".into())
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
struct Links {
|
||||
#[serde(default)]
|
||||
paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn read_links(file: &Path) -> Result<Links, String> {
|
||||
match std::fs::read_to_string(file) {
|
||||
Ok(s) => serde_json::from_str(&s).map_err(|e| format!("{}: {e}", file.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Links::default()),
|
||||
Err(e) => Err(format!("{}: {e}", file.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn link(dir: &Path, add: bool) -> Result<(), String> {
|
||||
let plugins = plugins_dir()?;
|
||||
let file = plugins.join("links.json");
|
||||
let mut links = read_links(&file)?;
|
||||
let dir = if add {
|
||||
let dir = canonical(dir)?;
|
||||
let m = Manifest::load(&dir)?;
|
||||
if let Some(wasm) = &m.wasm
|
||||
&& !dir.join(wasm).is_file()
|
||||
{
|
||||
println!("note: {} does not exist yet; run `openidea-plugin build` first", wasm.display());
|
||||
}
|
||||
if links.paths.contains(&dir) {
|
||||
println!("{} is already linked", dir.display());
|
||||
return Ok(());
|
||||
}
|
||||
println!("linked {} ({})", m.id, dir.display());
|
||||
links.paths.push(dir.clone());
|
||||
dir
|
||||
} else {
|
||||
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
||||
let before = links.paths.len();
|
||||
links.paths.retain(|p| p != &dir);
|
||||
if links.paths.len() == before {
|
||||
return Err(format!("{} is not linked", dir.display()));
|
||||
}
|
||||
println!("unlinked {}", dir.display());
|
||||
dir
|
||||
};
|
||||
let _ = dir;
|
||||
std::fs::create_dir_all(&plugins).map_err(|e| format!("{}: {e}", plugins.display()))?;
|
||||
let json = serde_json::to_string_pretty(&links).map_err(|e| e.to_string())?;
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)
|
||||
.and_then(|()| std::fs::rename(&tmp, &file))
|
||||
.map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
println!("open Settings › Plugins in OpenIDEA to load the change");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_links() -> Result<(), String> {
|
||||
let links = read_links(&plugins_dir()?.join("links.json"))?;
|
||||
if links.paths.is_empty() {
|
||||
println!("no linked plugins");
|
||||
}
|
||||
for p in links.paths {
|
||||
let id = Manifest::load(&p).map(|m| m.id).unwrap_or_else(|e| format!("invalid: {e}"));
|
||||
println!("{id}\t{}", p.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- distribution
|
||||
|
||||
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PublisherKey};
|
||||
|
||||
fn read_key(path: &str) -> Result<PublisherKey, String> {
|
||||
PublisherKey::from_base64(&std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?)
|
||||
}
|
||||
|
||||
fn cmd_keygen(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &[])?;
|
||||
let [file] = a.positional.as_slice() else { return Err("usage: openidea-plugin keygen <key-file>".into()) };
|
||||
if Path::new(file).exists() {
|
||||
return Err(format!("{file} exists; not overwriting a key"));
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
getrandom::fill(&mut seed).map_err(|e| format!("random: {e}"))?;
|
||||
let key = PublisherKey::from_seed(seed);
|
||||
std::fs::write(file, key.seed_base64()).map_err(|e| format!("{file}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
println!("public key: {}", key.public_base64());
|
||||
println!("register it with your publisher id in the marketplace index; keep {file} secret");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A scratch host to list a package's files as the IDE would.
|
||||
fn with_scratch_host<T>(f: impl FnOnce(&PluginHost) -> Result<T, String>) -> Result<T, String> {
|
||||
let scratch = std::env::temp_dir().join(format!("openidea-plugin-pack-{}", std::process::id()));
|
||||
let result = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|h| f(&h));
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
result
|
||||
}
|
||||
|
||||
fn cmd_pack(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["key", "publisher", "out"])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
check(&dir)?;
|
||||
let manifest = Manifest::load(&dir)?;
|
||||
let mut files = with_scratch_host(|h| h.package_files_of(&dir).map_err(|e| e.to_string()))?;
|
||||
let digest = package::package_digest(&dir, &files).map_err(|e| e.to_string())?;
|
||||
let signature = match (a.value("key"), a.value("publisher")) {
|
||||
(Some(key), Some(publisher)) => {
|
||||
if !manifest.id.starts_with(&format!("{publisher}.")) {
|
||||
return Err(format!("publisher {publisher:?} may only sign plugins named {publisher}.*"));
|
||||
}
|
||||
Some(read_key(key)?.sign(publisher, &manifest.id, &manifest.version, &digest))
|
||||
}
|
||||
(None, None) => None,
|
||||
_ => return Err("--key and --publisher go together".into()),
|
||||
};
|
||||
for lic in ["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "COPYING", "README.md"] {
|
||||
if dir.join(lic).is_file() {
|
||||
files.push(PathBuf::from(lic));
|
||||
}
|
||||
}
|
||||
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| {
|
||||
PathBuf::from(format!("{}-{}.{}", manifest.id, manifest.version, package::PACKAGE_EXTENSION))
|
||||
});
|
||||
package::pack(&dir, &files, &out, signature.as_ref())?;
|
||||
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
|
||||
let signed = signature.as_ref().map_or("unsigned".to_owned(), |s| format!("signed by {}", s.publisher));
|
||||
println!("-> {} ({}, {signed})", out.display(), human_size(size));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_index(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
let publisher = a.value("publisher").ok_or("--publisher is required")?;
|
||||
let name = a.value("name").unwrap_or(publisher);
|
||||
let key = read_key(a.value("key").ok_or("--key is required")?)?;
|
||||
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json"));
|
||||
// Revocations and other publishers of an existing index are kept.
|
||||
let mut index = match std::fs::read_to_string(&out) {
|
||||
Ok(text) => Index::parse(&text)?,
|
||||
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
|
||||
};
|
||||
// Earlier keys of the publisher stay listed (packages they signed remain valid until revoked).
|
||||
let current = key.public_base64();
|
||||
let earlier: Vec<KeyEntry> = index
|
||||
.publishers
|
||||
.iter()
|
||||
.filter(|p| p.id == publisher)
|
||||
.flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned())
|
||||
.collect();
|
||||
index.publishers.retain(|p| p.id != publisher);
|
||||
let keys: Vec<KeyEntry> = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect();
|
||||
index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
|
||||
index.plugins.clear();
|
||||
let mut packages: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION))
|
||||
.collect();
|
||||
packages.sort();
|
||||
for path in packages {
|
||||
let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let unpacked = package::unpack(&path, &scratch)?;
|
||||
let entry = (|| {
|
||||
let m = Manifest::load(&scratch)?;
|
||||
let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?;
|
||||
let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?;
|
||||
let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?;
|
||||
sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok::<_, String>(IndexEntry {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
authors: m.authors.clone(),
|
||||
publisher: sig.publisher.clone(),
|
||||
download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
digest,
|
||||
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
|
||||
})
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let entry = entry?;
|
||||
println!(" {} {}", entry.id, entry.version);
|
||||
index.plugins.push(entry);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
println!("-> {} ({} plugins)", out.display(), index.plugins.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
|
||||
assert_eq!(slugify("acme.Tools"), "acme.tools");
|
||||
assert_eq!(title_case("material-icons"), "Material Icons");
|
||||
assert_eq!(
|
||||
relative(Path::new("/a/b/plugins/x"), Path::new("/a/b/sdk/rust/p")).unwrap(),
|
||||
PathBuf::from("../../sdk/rust/p")
|
||||
);
|
||||
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_template_is_a_valid_package() {
|
||||
for t in templates::Template::ALL {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("openidea-plugin-template-{}-{}", t.label(), std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("../sdk") };
|
||||
for (rel, content) in templates::render(t, &vars) {
|
||||
let path = dir.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
let m = Manifest::load(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
|
||||
assert_eq!(m.id, "acme.sample");
|
||||
if m.wasm.is_none() {
|
||||
check(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! The files of `openidea-plugin new`. Placeholders: `__ID__` (plugin id),
|
||||
//! `__SLUG__` (last id segment), `__NAME__`, `__CRATE__`, `__SDK__`.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Template {
|
||||
Command,
|
||||
Theme,
|
||||
IconTheme,
|
||||
Language,
|
||||
}
|
||||
|
||||
impl Template {
|
||||
pub const ALL: [Template; 4] = [Self::Command, Self::Theme, Self::IconTheme, Self::Language];
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|t| t.label() == s)
|
||||
.ok_or_else(|| format!("unknown template {s:?} (command, theme, icon-theme, language)"))
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Command => "command",
|
||||
Self::Theme => "theme",
|
||||
Self::IconTheme => "icon-theme",
|
||||
Self::Language => "language",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Vars<'a> {
|
||||
pub id: &'a str,
|
||||
pub name: &'a str,
|
||||
pub sdk: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// `(relative path, contents)` of every file of the template.
|
||||
pub fn render(template: Template, vars: &Vars) -> Vec<(String, String)> {
|
||||
let slug = vars.id.rsplit('.').next().unwrap_or(vars.id).to_owned();
|
||||
let crate_name = format!("openidea-plugin-{}", vars.id.replace('.', "-"));
|
||||
let fill = |s: &str| {
|
||||
s.replace("__ID__", vars.id)
|
||||
.replace("__SLUG__", &slug)
|
||||
.replace("__NAME__", vars.name)
|
||||
.replace("__CRATE__", &crate_name)
|
||||
.replace("__SDK__", vars.sdk.unwrap_or(""))
|
||||
// The theme files come from the Harbor Night plugin.
|
||||
.replace("harbor-night", &slug)
|
||||
.replace("Harbor Night", vars.name)
|
||||
};
|
||||
let files: Vec<(String, &str)> = match template {
|
||||
Template::Command => vec![
|
||||
("plugin.toml".into(), COMMAND_MANIFEST),
|
||||
("Cargo.toml".into(), COMMAND_CARGO),
|
||||
("src/lib.rs".into(), COMMAND_LIB),
|
||||
(".gitignore".into(), "/target\n"),
|
||||
("README.md".into(), COMMAND_README),
|
||||
],
|
||||
Template::Theme => vec![
|
||||
("plugin.toml".into(), THEME_MANIFEST),
|
||||
(format!("themes/{slug}.theme.json"), THEME_UI),
|
||||
(format!("themes/{slug}.scheme.json"), THEME_SCHEME),
|
||||
],
|
||||
Template::IconTheme => vec![
|
||||
("plugin.toml".into(), ICON_MANIFEST),
|
||||
("icons/theme.json".into(), ICON_THEME),
|
||||
("icons/file.svg".into(), ICON_FILE),
|
||||
("icons/folder.svg".into(), ICON_FOLDER),
|
||||
("icons/folder-open.svg".into(), ICON_FOLDER_OPEN),
|
||||
("icons/markdown.svg".into(), ICON_MARKDOWN),
|
||||
],
|
||||
Template::Language => {
|
||||
vec![("plugin.toml".into(), LANGUAGE_MANIFEST), (format!("languages/{slug}.toml"), LANGUAGE_DEF)]
|
||||
}
|
||||
};
|
||||
files.into_iter().map(|(path, content)| (path, fill(content))).collect()
|
||||
}
|
||||
|
||||
const COMMAND_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "Adds two sample commands."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
# Built by `openidea-plugin build`. The code is loaded the first time one of
|
||||
# its commands runs; the commands below show in Find Action before that.
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
# What the code may do. The user approves these when installing.
|
||||
[capabilities]
|
||||
required = ["editor.read"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "say-hello"
|
||||
title = "__NAME__: Say Hello"
|
||||
category = "__NAME__"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "line-count"
|
||||
title = "__NAME__: Line Count of Active File"
|
||||
category = "__NAME__"
|
||||
"#;
|
||||
|
||||
const COMMAND_CARGO: &str = r#"[package]
|
||||
name = "__CRATE__"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "__SDK__" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
"#;
|
||||
|
||||
const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin.
|
||||
|
||||
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Main {
|
||||
greetings: u32,
|
||||
}
|
||||
|
||||
impl Plugin for Main {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
|
||||
match id {
|
||||
"say-hello" => {
|
||||
// Instance state lasts until the plugin is idle for a while;
|
||||
// keep what must survive in `host::storage`.
|
||||
self.greetings += 1;
|
||||
let body = format!("Hello #{} from __NAME__!", self.greetings);
|
||||
host::notify(NotificationLevel::Info, "__NAME__", &body);
|
||||
Ok(None)
|
||||
}
|
||||
"line-count" => {
|
||||
let editor = ctx.editor().ok_or("no file is open")?;
|
||||
let text = editor.document()?.text;
|
||||
Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path())))
|
||||
}
|
||||
_ => Err(format!("unknown command {id}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Main);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openidea_plugin::mock::MockHost;
|
||||
|
||||
#[test]
|
||||
fn says_hello() {
|
||||
let host = MockHost::install();
|
||||
let mut plugin = Main::default();
|
||||
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
|
||||
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
|
||||
assert_eq!(host.notifications()[1].body, "Hello #2 from __NAME__!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_lines() {
|
||||
let host = MockHost::install();
|
||||
host.open_editor("/project/src/main.rs", "rust", "fn main() {\n}\n", &[]);
|
||||
let out = Main::default().run_command("line-count", &host.invocation(), None).unwrap();
|
||||
assert_eq!(out.as_deref(), Some("2 lines in /project/src/main.rs"));
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const COMMAND_README: &str = r#"# __NAME__
|
||||
|
||||
An OpenIDEA plugin.
|
||||
|
||||
```sh
|
||||
cargo test # logic, against the SDK's mock host
|
||||
openidea-plugin build # → dist/plugin.wasm, validated like an install
|
||||
openidea-plugin link # load this folder in the IDE (Settings › Plugins)
|
||||
```
|
||||
|
||||
`plugin.toml` declares the commands, so they appear in Find Action before the
|
||||
code is loaded, and the capabilities the code needs. `src/lib.rs` implements
|
||||
them with the `openidea-plugin` SDK.
|
||||
"#;
|
||||
|
||||
const THEME_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "A dark theme."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
|
||||
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
|
||||
[[contributes.themes]]
|
||||
path = "themes/__SLUG__.theme.json"
|
||||
editor-scheme = "themes/__SLUG__.scheme.json"
|
||||
"#;
|
||||
|
||||
const THEME_UI: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.theme.json");
|
||||
const THEME_SCHEME: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.scheme.json");
|
||||
|
||||
const ICON_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "File and folder icons."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# Apply it in Settings › Plugins › __NAME__. SVGs are sanitised on install:
|
||||
# no scripts, event handlers, external references or entities.
|
||||
[[contributes.iconThemes]]
|
||||
id = "__SLUG__"
|
||||
label = "__NAME__"
|
||||
path = "icons/theme.json"
|
||||
"#;
|
||||
|
||||
const ICON_THEME: &str = r#"{
|
||||
"iconDefinitions": {
|
||||
"file": "icons/file.svg",
|
||||
"folder": "icons/folder.svg",
|
||||
"folder-open": "icons/folder-open.svg",
|
||||
"markdown": "icons/markdown.svg"
|
||||
},
|
||||
"file": "file",
|
||||
"folder": "folder",
|
||||
"folderExpanded": "folder-open",
|
||||
"fileExtensions": { "md": "markdown", "markdown": "markdown" },
|
||||
"fileNames": { "readme": "markdown" },
|
||||
"folderNames": {}
|
||||
}
|
||||
"#;
|
||||
|
||||
const ICON_FILE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1.5h5.5L13 5v9.5H4z"/><path fill="#cfd8dc" d="M9.5 1.5V5H13z"/></svg>
|
||||
"##;
|
||||
|
||||
const ICON_FOLDER: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6.5v9h-13z"/></svg>
|
||||
"##;
|
||||
|
||||
const ICON_FOLDER_OPEN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6v2h-10l-2 7h-.5z"/><path fill="#b0bec5" d="M4 7.5h11.5l-2 6H2z"/></svg>
|
||||
"##;
|
||||
|
||||
const ICON_MARKDOWN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="3.5" width="14" height="9" rx="1.5" fill="#42a5f5"/><path fill="#fff" d="M3 10.5v-5h1.5L6 7.5l1.5-2H9v5H7.5V8L6 10 4.5 8v2.5zm8 0L9.5 8h1V5.5H12V8h1z"/></svg>
|
||||
"##;
|
||||
|
||||
const LANGUAGE_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "Language support for .__SLUG__ files: detection, comments and indentation."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# Same format as languages/<id>.toml in the IDE repository.
|
||||
[[contributes.languages]]
|
||||
path = "languages/__SLUG__.toml"
|
||||
"#;
|
||||
|
||||
const LANGUAGE_DEF: &str = r##"id = "__SLUG__"
|
||||
name = "__NAME__"
|
||||
extensions = ["__SLUG__"]
|
||||
line-comment = "#"
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
# Borrow an existing CodeMirror mode for instant highlighting.
|
||||
ui-mode = "Properties files"
|
||||
"##;
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `openidea-server --stdio`: the IDE backend for remote development, serving
|
||||
//! one desktop client over stdin / stdout (started by the client over SSH).
|
||||
|
||||
use ide_host::plugins::remote::{RemotePluginLanguages, SYNC_COMMAND};
|
||||
use ide_host::plugins::remote_host::RemotePlugins;
|
||||
use ide_host::rpc::{self, Dispatcher};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_language::LanguageRegistry;
|
||||
@@ -51,8 +53,12 @@ async fn run(base: PathBuf) -> i32 {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
// The desktop's plugin languages (`plugin_sync_languages`); programs pinned on this machine.
|
||||
let plugin_languages = RemotePluginLanguages::new(Some(&data_dir));
|
||||
languages.attach_plugins(plugin_languages.store().clone());
|
||||
let peer = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer.clone();
|
||||
let plugins_data = data_dir.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir,
|
||||
local_data_dir: data_dir.clone(),
|
||||
@@ -64,8 +70,31 @@ async fn run(base: PathBuf) -> i32 {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: true,
|
||||
}));
|
||||
// The desktop's code plugins run here, next to the project (`plugin_sync_packages`).
|
||||
let plugins = match RemotePlugins::start(&plugins_data, &host, peer.clone()) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::warn!("plugins are unavailable: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
|
||||
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
if let Some(plugins) = &plugins {
|
||||
plugins.register(&mut dispatcher);
|
||||
}
|
||||
dispatcher.add(SYNC_COMMAND, move |c| {
|
||||
let remote = plugin_languages.clone();
|
||||
async move {
|
||||
let report = remote.sync(c.arg("languages")?);
|
||||
c.host.plugin_languages_changed();
|
||||
Ok(report)
|
||||
}
|
||||
});
|
||||
rpc::serve(host.clone(), dispatcher, peer, tokio::io::stdin(), tokio::io::stdout()).await;
|
||||
if let Some(plugins) = &plugins {
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
// `process::exit` skips destructors: nothing started for the client may outlive it.
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() {
|
||||
tracing::warn!("language servers did not shut down in time");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The IDE backend without a UI toolkit: open projects per window, terminals,
|
||||
//! processes, settings and the remote services. The desktop shell (`src-tauri`)
|
||||
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
|
||||
//! wraps one [`Host`] and adds windows and menus; `openidea-server`
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod data_dirs;
|
||||
@@ -10,6 +10,7 @@ mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
pub mod plugins;
|
||||
mod project;
|
||||
mod proxy;
|
||||
mod requests;
|
||||
@@ -133,6 +134,17 @@ impl Host {
|
||||
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
|
||||
}
|
||||
|
||||
/// Plugin languages or the servers they may start changed (they are part
|
||||
/// of [`Self::languages`] and every project's fork): open documents are
|
||||
/// detected again and language servers follow their new configuration.
|
||||
pub fn plugin_languages_changed(&self) {
|
||||
for project in self.projects() {
|
||||
project.documents.redetect_languages();
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use super::doc::blocking;
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit as apply_edit;
|
||||
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
use ide_plugin::ProviderDocument;
|
||||
|
||||
async fn prepare_actions(
|
||||
project: std::sync::Arc<crate::Project>,
|
||||
@@ -38,9 +40,99 @@ pub async fn lsp_completion(
|
||||
) -> IdeResult<CompletionList> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.completion(doc, pos, trigger, cancel).await;
|
||||
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
// Plugin providers (`[[contributes.providers]]`) add theirs.
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
|
||||
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
|
||||
if items.is_empty() {
|
||||
return result;
|
||||
}
|
||||
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
|
||||
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// The plugin host and the document, when some plugin provides `kind`.
|
||||
fn plugin_providers(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
kind: ProviderKind,
|
||||
) -> Option<(std::sync::Arc<crate::plugins::PluginHost>, ProviderDocument)> {
|
||||
let plugins = editor_providers(kind)?;
|
||||
let snap = host.project(window).ok()?.documents.snapshot(doc).ok()?;
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
Some((plugins, d))
|
||||
}
|
||||
|
||||
/// Plugin calls block (the plugin's actor thread does the work).
|
||||
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
tokio::task::spawn_blocking(f).await.ok()
|
||||
}
|
||||
|
||||
/// Items from plugins carry no server (resolve and commands don't apply).
|
||||
const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
|
||||
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
|
||||
fn plugin_completion_kind(kind: &str) -> Option<CompletionKind> {
|
||||
Some(match kind {
|
||||
"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,
|
||||
"unit" => CompletionKind::Unit,
|
||||
"value" => CompletionKind::Value,
|
||||
"enum" => CompletionKind::Enum,
|
||||
"keyword" => CompletionKind::Keyword,
|
||||
"snippet" => CompletionKind::Snippet,
|
||||
"color" => CompletionKind::Color,
|
||||
"file" => CompletionKind::File,
|
||||
"reference" => CompletionKind::Reference,
|
||||
"folder" => CompletionKind::Folder,
|
||||
"enum-member" => CompletionKind::EnumMember,
|
||||
"constant" => CompletionKind::Constant,
|
||||
"struct" => CompletionKind::Struct,
|
||||
"event" => CompletionKind::Event,
|
||||
"operator" => CompletionKind::Operator,
|
||||
"type-parameter" => CompletionKind::TypeParameter,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
|
||||
let kind = item.kind.as_deref().and_then(plugin_completion_kind);
|
||||
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 +146,26 @@ pub async fn lsp_inline_completion(
|
||||
) -> IdeResult<Vec<InlineCompletionItem>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
|
||||
let result = lsp.inline_completion(doc, pos, automatic, cancel.clone()).await;
|
||||
// Plugins: cancelled (cooperatively) when the editor cancels the request.
|
||||
let plugin = match plugin_providers(host, window, doc, ProviderKind::InlineCompletion) {
|
||||
Some((plugins, d)) if !matches!(&result, Ok(items) if !items.is_empty()) => {
|
||||
let cancel = cancel.clone();
|
||||
plugin_call(move || plugins.provide_inline_completion(&d, pos, &cancel)).await.flatten()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
host.end_request(request);
|
||||
result
|
||||
match plugin {
|
||||
Some((_, s)) => Ok(vec![InlineCompletionItem {
|
||||
insert_text: s.insert_text,
|
||||
range: s.replace,
|
||||
filter_text: None,
|
||||
command: None,
|
||||
server: PLUGIN_SERVER,
|
||||
}]),
|
||||
None => result,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lsp_resolve_completion(
|
||||
@@ -87,7 +196,40 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
host.project(window)?.lsp.goto(doc, pos, kind).await
|
||||
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
|
||||
if kind != GotoKind::Definition {
|
||||
return result;
|
||||
}
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Definition) else { return result };
|
||||
let found = plugin_call(move || plugins.provide_definition(&d, pos)).await.unwrap_or_default();
|
||||
if found.is_empty() {
|
||||
return result;
|
||||
}
|
||||
let mut out = result.unwrap_or_default();
|
||||
out.extend(found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None }));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Gutter markers from plugins (`line-markers` providers) for `doc`.
|
||||
pub async fn plugin_line_markers(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
) -> IdeResult<Vec<crate::plugins::LineMarkerInfo>> {
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::LineMarkers) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let markers = plugin_call(move || plugins.provide_line_markers(&d)).await.unwrap_or_default();
|
||||
Ok(markers
|
||||
.into_iter()
|
||||
.map(|(plugin_id, m)| crate::plugins::LineMarkerInfo {
|
||||
plugin_id,
|
||||
line: m.line,
|
||||
icon: m.icon,
|
||||
tooltip: m.tooltip,
|
||||
target: m.target.map(|l| Location { path: l.path.into(), range: l.range, uri: None }),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Implementations of the symbols at `positions`, one list per position (the
|
||||
@@ -165,10 +307,47 @@ pub async fn lsp_code_actions(
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let actions = project.lsp.code_actions(doc, range, only).await?;
|
||||
let result = project.lsp.code_actions(doc, range, only.clone()).await;
|
||||
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
|
||||
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let plugin_actions: Vec<CodeAction> = plugin_actions
|
||||
.into_iter()
|
||||
.map(|(_, a)| plugin_code_action(a))
|
||||
.filter(|a| {
|
||||
only.is_empty()
|
||||
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
|
||||
})
|
||||
.collect();
|
||||
let actions = if plugin_actions.is_empty() {
|
||||
result?
|
||||
} else {
|
||||
let mut out = result.unwrap_or_default();
|
||||
out.extend(plugin_actions);
|
||||
out
|
||||
};
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
|
||||
let files = a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
|
||||
.collect();
|
||||
CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
is_preferred: a.preferred,
|
||||
disabled_reason: None,
|
||||
edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
command: None,
|
||||
data: None,
|
||||
server: PLUGIN_SERVER,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
pub async fn lsp_code_action_query(
|
||||
host: &Host,
|
||||
@@ -551,3 +730,28 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_completion_kind_the_plugin_api_documents_is_understood() {
|
||||
let wit = include_str!("../../../../wit/providers.wit");
|
||||
let doc: String = wit
|
||||
.lines()
|
||||
.map(|l| l.trim().trim_start_matches("///").trim())
|
||||
.skip_while(|l| !l.starts_with("Icon and sort group:"))
|
||||
.take_while(|l| !l.starts_with("kind:"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let list = doc.strip_prefix("Icon and sort group:").unwrap().split(". Unknown").next().unwrap();
|
||||
let list = list.replace(" or ", ", ");
|
||||
let kinds: Vec<&str> = list.split(',').map(str::trim).filter(|k| !k.is_empty()).collect();
|
||||
assert_eq!(kinds.len(), 25, "{kinds:?}");
|
||||
for k in kinds {
|
||||
assert!(plugin_completion_kind(k).is_some(), "{k:?}");
|
||||
}
|
||||
assert_eq!(plugin_completion_kind("nonsense"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
//! [`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::{PluginEvent, PluginEventSink};
|
||||
use ide_core::{CancellationToken, TextEdit};
|
||||
use ide_extension::Package;
|
||||
use ide_language::ProgramPin;
|
||||
use ide_plugin::{
|
||||
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus,
|
||||
StatusItem,
|
||||
};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Largest storage of one plugin (keys + values).
|
||||
const MAX_STORAGE: usize = 1 << 20;
|
||||
const MAX_KEY: usize = 256;
|
||||
/// Most status bar items per plugin.
|
||||
const MAX_STATUS_ITEMS: usize = 8;
|
||||
|
||||
/// What plugins see of the open projects; provided by the app
|
||||
/// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop.
|
||||
pub trait PluginWorkspace: Send + Sync {
|
||||
/// Roots of the open projects.
|
||||
fn projects(&self) -> Vec<PathBuf>;
|
||||
/// Unsaved text of the open document at `path`.
|
||||
fn buffer_text(&self, path: &Path) -> Option<String>;
|
||||
/// Version and text of the open document at `path`.
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)>;
|
||||
/// Applies `edits` to the open document at `path` if it is still at
|
||||
/// `version`, as one change (one undo step in the editor).
|
||||
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError>;
|
||||
/// Code from the project may run (git hooks and config on fetch).
|
||||
fn trusted(&self, root: &Path) -> bool;
|
||||
/// Root of the project open in `window`.
|
||||
fn project_of(&self, window: &str) -> Option<PathBuf>;
|
||||
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
|
||||
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
|
||||
-> Result<(), HostError>;
|
||||
/// The user's values of `plugin`'s settings: the project's at `root`
|
||||
/// (over global), or global.
|
||||
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
serde_json::Map::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Where plugin secrets live (the OS keychain on the desktop). Plugins can
|
||||
/// only ask whether one is set; values go out only inside `net` requests.
|
||||
pub trait SecretStore: Send + Sync {
|
||||
fn get(&self, plugin: &str, name: &str) -> Option<String>;
|
||||
/// Sets, or (`None`) deletes.
|
||||
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// Fallback [`SecretStore`]: one file per plugin, readable only by the user.
|
||||
pub struct FileSecretStore {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl FileSecretStore {
|
||||
fn file(&self, plugin: &str) -> PathBuf {
|
||||
self.dir.join(plugin).join("secrets.json")
|
||||
}
|
||||
|
||||
fn load(&self, plugin: &str) -> BTreeMap<String, String> {
|
||||
std::fs::read(self.file(plugin)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStore for FileSecretStore {
|
||||
fn get(&self, plugin: &str, name: &str) -> Option<String> {
|
||||
self.load(plugin).remove(name)
|
||||
}
|
||||
|
||||
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
|
||||
let mut map = self.load(plugin);
|
||||
match value {
|
||||
Some(v) => map.insert(name.to_owned(), v.to_owned()),
|
||||
None => map.remove(name),
|
||||
};
|
||||
let file = self.file(plugin);
|
||||
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
|
||||
let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?;
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
std::fs::rename(&tmp, &file).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest response body a plugin receives.
|
||||
const MAX_RESPONSE: usize = 8 << 20;
|
||||
/// Largest stdout / stderr kept from a process.
|
||||
const MAX_OUTPUT: usize = 4 << 20;
|
||||
/// Most files `find-files` walks before giving up.
|
||||
const MAX_WALK: usize = 200_000;
|
||||
|
||||
/// Runtime for plugin HTTP requests (actor threads block on it).
|
||||
pub(super) fn net_runtime() -> &'static tokio::runtime::Runtime {
|
||||
static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
|
||||
RT.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("plugin-net")
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("plugin network runtime")
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces `{{secret:<name>}}` in `value` with the plugin's secrets.
|
||||
fn fill_secrets(value: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, HostError> {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut rest = value;
|
||||
while let Some(i) = rest.find("{{secret:") {
|
||||
out.push_str(&rest[..i]);
|
||||
let after = &rest[i + "{{secret:".len()..];
|
||||
let end = after.find("}}").ok_or_else(|| HostError::Invalid("unterminated {{secret:…}}".into()))?;
|
||||
let name = &after[..end];
|
||||
let secret = lookup(name).ok_or_else(|| HostError::NotFound(format!("secret {name:?} is not set")))?;
|
||||
out.push_str(&secret);
|
||||
rest = &after[end + 2..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A plugin task shown in the UI.
|
||||
struct TaskEntry {
|
||||
plugin: String,
|
||||
cancel: CancellationToken,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// State shared by the host and every plugin's [`PluginApi`].
|
||||
pub(super) struct Shared {
|
||||
pub sink: Arc<dyn PluginEventSink>,
|
||||
pub workspace: RwLock<Option<Arc<dyn PluginWorkspace>>>,
|
||||
/// `<plugins>/.data`: one directory per plugin id.
|
||||
pub data_dir: PathBuf,
|
||||
tasks: Mutex<HashMap<u64, TaskEntry>>,
|
||||
next_task: AtomicU64,
|
||||
/// (plugin, item id) → item, for windows opened later.
|
||||
status: Mutex<BTreeMap<(String, String), StatusItem>>,
|
||||
/// plugin → kinds subscribed at run time (kept across idle reclaim).
|
||||
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
|
||||
/// plugin → files it shows problems for, withdrawn when it stops.
|
||||
diagnostics: Mutex<HashMap<String, BTreeSet<PathBuf>>>,
|
||||
pub secrets: RwLock<Arc<dyn SecretStore>>,
|
||||
/// (plugin, `process:` capability) → the approved program.
|
||||
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
/// Validated UI declarations (settings schemas, declared views).
|
||||
pub ui: Arc<ide_extension::UiStore>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub fn new(
|
||||
sink: Arc<dyn PluginEventSink>,
|
||||
data_dir: PathBuf,
|
||||
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
ui: Arc<ide_extension::UiStore>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ui,
|
||||
sink,
|
||||
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(),
|
||||
diagnostics: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
|
||||
self.workspace.read().clone()
|
||||
}
|
||||
|
||||
pub fn project_of(&self, window: &str) -> Option<PathBuf> {
|
||||
self.workspace()?.project_of(window)
|
||||
}
|
||||
|
||||
/// Shows `plugin`'s problems of `path`, remembering the file until they are
|
||||
/// cleared (by the plugin, or when it stops).
|
||||
fn publish_diagnostics(
|
||||
&self,
|
||||
plugin: &str,
|
||||
path: &Path,
|
||||
items: Vec<ide_core::Diagnostic>,
|
||||
) -> Result<(), HostError> {
|
||||
let workspace = self.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
|
||||
let empty = items.is_empty();
|
||||
workspace.publish_diagnostics(plugin, path, items)?;
|
||||
let mut published = self.diagnostics.lock();
|
||||
if empty {
|
||||
if let Some(paths) = published.get_mut(plugin) {
|
||||
paths.remove(path);
|
||||
if paths.is_empty() {
|
||||
published.remove(plugin);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
published.entry(plugin.to_owned()).or_default().insert(path.to_path_buf());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status_items(&self) -> Vec<(String, String, StatusItem)> {
|
||||
self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect()
|
||||
}
|
||||
|
||||
/// Withdraws a plugin's run-time state (it stopped or failed). Its
|
||||
/// subscriptions go only with `subscriptions`, as they survive restarts.
|
||||
pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) {
|
||||
let removed: Vec<String> = {
|
||||
let mut status = self.status.lock();
|
||||
let ids: Vec<String> = status.keys().filter(|(p, _)| p == plugin).map(|(_, id)| id.clone()).collect();
|
||||
for id in &ids {
|
||||
status.remove(&(plugin.to_owned(), id.clone()));
|
||||
}
|
||||
ids
|
||||
};
|
||||
for id in removed {
|
||||
self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None });
|
||||
}
|
||||
let tasks: Vec<u64> = self.tasks.lock().iter().filter(|(_, t)| t.plugin == plugin).map(|(id, _)| *id).collect();
|
||||
for id in tasks {
|
||||
self.end_task(id);
|
||||
}
|
||||
// Its problems go with it (an idle reclaim keeps them: it isn't cleared then).
|
||||
let paths = self.diagnostics.lock().remove(plugin).unwrap_or_default();
|
||||
if let Some(workspace) = self.workspace() {
|
||||
for path in paths {
|
||||
let _ = workspace.publish_diagnostics(plugin, &path, Vec::new());
|
||||
}
|
||||
}
|
||||
if subscriptions && self.subscriptions.lock().remove(plugin).is_some() {
|
||||
self.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// The user cancelled a task in the UI.
|
||||
pub fn cancel_task(&self, id: u64) -> bool {
|
||||
let mut tasks = self.tasks.lock();
|
||||
let Some(t) = tasks.get_mut(&id) else { return false };
|
||||
t.cancelled = true;
|
||||
t.cancel.cancel();
|
||||
true
|
||||
}
|
||||
|
||||
fn end_task(&self, id: u64) {
|
||||
if let Some(t) = self.tasks.lock().remove(&id) {
|
||||
self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn storage_file(&self, plugin: &str) -> PathBuf {
|
||||
self.data_dir.join(plugin).join("storage.json")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PluginApi {
|
||||
pub pkg: Package,
|
||||
pub shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
fn id(&self) -> &str {
|
||||
self.pkg.id()
|
||||
}
|
||||
|
||||
fn load_storage(&self) -> Result<BTreeMap<String, String>, HostError> {
|
||||
match std::fs::read(self.shared.storage_file(self.id())) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}"))),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
|
||||
Err(e) => Err(HostError::Failed(format!("storage: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_storage(&self, map: &BTreeMap<String, String>) -> Result<(), HostError> {
|
||||
let file = self.shared.storage_file(self.id());
|
||||
let bytes = serde_json::to_vec(map).map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
let io = |e: std::io::Error| HostError::Failed(format!("storage: {e}"));
|
||||
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(io)?;
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(io)
|
||||
}
|
||||
|
||||
/// The program approved for the (already checked) `process:` grant,
|
||||
/// verified right before it starts.
|
||||
fn approved_program(&self, program: &str) -> Result<PathBuf, HostError> {
|
||||
let cap = format!("process:{program}");
|
||||
// Granted without a pinned file: the program wasn't found when it was
|
||||
// allowed, and installing it later approves nothing by itself.
|
||||
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
|
||||
HostError::Failed(format!(
|
||||
"{cap}: `{program}` was not found when this permission was allowed; \
|
||||
install it, then allow {cap} again in Settings › Plugins"
|
||||
))
|
||||
})?;
|
||||
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
|
||||
impl HostApi for PluginApi {
|
||||
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String) {
|
||||
self.shared
|
||||
.sink
|
||||
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
|
||||
}
|
||||
|
||||
fn status_item(&self, id: &str, item: Option<StatusItem>) {
|
||||
let key = (self.id().to_owned(), id.to_owned());
|
||||
{
|
||||
let mut status = self.shared.status.lock();
|
||||
match &item {
|
||||
Some(item) => {
|
||||
let count = status.keys().filter(|(p, _)| p == self.id()).count();
|
||||
if !status.contains_key(&key) && count >= MAX_STATUS_ITEMS {
|
||||
tracing::warn!(plugin = %self.id(), "more than {MAX_STATUS_ITEMS} status items; {id:?} ignored");
|
||||
return;
|
||||
}
|
||||
if status.get(&key) == Some(item) {
|
||||
return;
|
||||
}
|
||||
status.insert(key, item.clone());
|
||||
}
|
||||
None => {
|
||||
if status.remove(&key).is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.shared
|
||||
.sink
|
||||
.emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item });
|
||||
}
|
||||
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
self.shared.workspace().map(|w| w.projects()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
self.shared.workspace()?.buffer_text(path)
|
||||
}
|
||||
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)> {
|
||||
self.shared.workspace()?.document(path)
|
||||
}
|
||||
|
||||
fn apply_edits(
|
||||
&self,
|
||||
_client: Option<&str>,
|
||||
path: &Path,
|
||||
version: u64,
|
||||
edits: Vec<TextEdit>,
|
||||
) -> Result<u64, HostError> {
|
||||
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
|
||||
workspace.apply_edits(path, version, &edits)
|
||||
}
|
||||
|
||||
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
|
||||
Ok(self.load_storage()?.get(key).cloned())
|
||||
}
|
||||
|
||||
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError> {
|
||||
if key.is_empty() || key.len() > MAX_KEY {
|
||||
return Err(HostError::Invalid(format!("keys are 1–{MAX_KEY} bytes")));
|
||||
}
|
||||
let mut map = self.load_storage()?;
|
||||
let changed = match value {
|
||||
Some(v) => map.insert(key.to_owned(), v.clone()).as_ref() != Some(&v),
|
||||
None => map.remove(key).is_some(),
|
||||
};
|
||||
if !changed {
|
||||
return Ok(());
|
||||
}
|
||||
let size: usize = map.iter().map(|(k, v)| k.len() + v.len()).sum();
|
||||
if size > MAX_STORAGE {
|
||||
return Err(HostError::Invalid(format!("storage is limited to {} KiB", MAX_STORAGE >> 10)));
|
||||
}
|
||||
self.save_storage(&map)
|
||||
}
|
||||
|
||||
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
|
||||
Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect())
|
||||
}
|
||||
|
||||
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
|
||||
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(None) };
|
||||
let info = repo.branch_info().map_err(failed)?;
|
||||
let changed = repo.status().map_err(failed)?.len();
|
||||
Ok(Some(RepositoryStatus {
|
||||
root: repo.root().to_string_lossy().into_owned(),
|
||||
branch: info.head,
|
||||
commit: info.head_commit,
|
||||
upstream: info.upstream,
|
||||
ahead: info.ahead,
|
||||
behind: info.behind,
|
||||
operation: info.operation,
|
||||
changed_files: u32::try_from(changed).unwrap_or(u32::MAX),
|
||||
}))
|
||||
}
|
||||
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
|
||||
let workspace = self.shared.workspace();
|
||||
if !workspace.is_some_and(|w| w.trusted(root)) {
|
||||
return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into()));
|
||||
}
|
||||
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else {
|
||||
return Err(HostError::NotFound("the project is not in a git repository".into()));
|
||||
};
|
||||
match repo.fetch(cancel, &|_| {}) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) if cancel.is_cancelled() => Err(HostError::Cancelled),
|
||||
Err(e) => Err(failed(e.message)),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
|
||||
let matcher = globset::GlobBuilder::new(glob)
|
||||
.literal_separator(true)
|
||||
.build()
|
||||
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
|
||||
.compile_matcher();
|
||||
let mut out = Vec::new();
|
||||
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
|
||||
if n >= MAX_WALK || out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
if !entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
|
||||
if matcher.is_match(rel) {
|
||||
out.push(entry.path().to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
|
||||
self.shared.publish_diagnostics(self.id(), path, items)
|
||||
}
|
||||
|
||||
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError> {
|
||||
let secrets = self.shared.secrets.read().clone();
|
||||
let id = self.id().to_owned();
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
for (name, value) in &request.headers {
|
||||
let value = fill_secrets(value, |n| secrets.get(&id, n))?;
|
||||
let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|e| HostError::Invalid(format!("header {name:?}: {e}")))?;
|
||||
let mut value = reqwest::header::HeaderValue::from_str(&value)
|
||||
.map_err(|e| HostError::Invalid(format!("header {name}: {e}")))?;
|
||||
value.set_sensitive(true);
|
||||
headers.append(name, value);
|
||||
}
|
||||
let method = reqwest::Method::from_bytes(request.method.to_ascii_uppercase().as_bytes())
|
||||
.map_err(|_| HostError::Invalid(format!("method {:?}", request.method)))?;
|
||||
let client = ide_http::client::shared().map_err(|e| HostError::Failed(e.message))?;
|
||||
// A redirect to another host is refused after the fact (the shared
|
||||
// client follows redirects).
|
||||
let builder = client.request(method, &request.url).headers(headers).timeout(request.timeout);
|
||||
let builder = match request.body {
|
||||
Some(body) => builder.body(body),
|
||||
None => builder,
|
||||
};
|
||||
let cancel = cancel.clone();
|
||||
let host = ide_plugin::net_host(&request.url).map_err(HostError::Invalid)?;
|
||||
net_runtime().block_on(async move {
|
||||
let send = async {
|
||||
let response = builder.send().await.map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
if ide_plugin::net_host(response.url().as_str()).ok().as_deref() != Some(host.as_str()) {
|
||||
return Err(HostError::Denied(format!(
|
||||
"net:{} (redirected)",
|
||||
response.url().host_str().unwrap_or("?")
|
||||
)));
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let headers = response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str().to_owned(), String::from_utf8_lossy(v.as_bytes()).into_owned()))
|
||||
.collect();
|
||||
let mut body = Vec::new();
|
||||
let mut response = response;
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Failed(e.to_string()))? {
|
||||
if body.len() + chunk.len() > MAX_RESPONSE {
|
||||
return Err(HostError::Failed("response larger than 8 MiB".into()));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(HttpResponse { status, headers, body })
|
||||
};
|
||||
tokio::select! {
|
||||
r = send => r,
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn secret_is_set(&self, name: &str) -> bool {
|
||||
self.shared.secrets.read().get(self.id(), name).is_some()
|
||||
}
|
||||
|
||||
fn run_process(
|
||||
&self,
|
||||
program: &str,
|
||||
args: Vec<String>,
|
||||
cwd: Option<&Path>,
|
||||
stdin: Option<String>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<ProcessOutput, HostError> {
|
||||
let binary = self.approved_program(program)?;
|
||||
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
|
||||
let _ = std::fs::create_dir_all(&cwd);
|
||||
let mut child = std::process::Command::new(&binary)
|
||||
.args(&args)
|
||||
.current_dir(&cwd)
|
||||
.stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() })
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
|
||||
if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) {
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
let _ = pipe.write_all(input.as_bytes());
|
||||
});
|
||||
}
|
||||
let read = |pipe: Option<Box<dyn std::io::Read + Send>>| {
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = pipe {
|
||||
let _ = p.take(MAX_OUTPUT as u64).read_to_end(&mut out);
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
})
|
||||
};
|
||||
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
|
||||
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
|
||||
let deadline = Instant::now() + timeout;
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().map_err(|e| HostError::Failed(e.to_string()))? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() || Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(if cancel.is_cancelled() {
|
||||
HostError::Cancelled
|
||||
} else {
|
||||
HostError::Failed(format!("{program} timed out after {timeout:?}"))
|
||||
});
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok(ProcessOutput {
|
||||
status: status.code(),
|
||||
stdout: stdout.join().unwrap_or_default(),
|
||||
stderr: stderr.join().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn subscribe(&self, kind: EventKind, on: bool) {
|
||||
let changed = {
|
||||
let mut subs = self.shared.subscriptions.lock();
|
||||
let set = subs.entry(self.id().to_owned()).or_default();
|
||||
if on { set.insert(kind) } else { set.remove(&kind) }
|
||||
};
|
||||
if changed {
|
||||
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64 {
|
||||
let id = self.shared.next_task.fetch_add(1, Ordering::Relaxed);
|
||||
self.shared.tasks.lock().insert(id, TaskEntry { plugin: self.id().to_owned(), cancel, cancelled: false });
|
||||
self.shared.sink.emit(
|
||||
client,
|
||||
PluginEvent::TaskStarted { plugin_id: self.id().to_owned(), task_id: id.to_string(), title, cancellable },
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>) {
|
||||
if !self.shared.tasks.lock().contains_key(&id) {
|
||||
return;
|
||||
}
|
||||
self.shared.sink.emit(
|
||||
None,
|
||||
PluginEvent::TaskProgress {
|
||||
plugin_id: self.id().to_owned(),
|
||||
task_id: id.to_string(),
|
||||
done,
|
||||
total,
|
||||
message,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn task_cancelled(&self, id: u64) -> bool {
|
||||
self.shared.tasks.lock().get(&id).is_some_and(|t| t.cancelled)
|
||||
}
|
||||
|
||||
fn task_end(&self, id: u64) {
|
||||
self.shared.end_task(id);
|
||||
}
|
||||
|
||||
fn refresh_view(&self, view: &str) {
|
||||
self.shared
|
||||
.sink
|
||||
.emit(None, PluginEvent::ViewChanged { plugin_id: self.id().to_owned(), view: view.to_owned() });
|
||||
}
|
||||
|
||||
fn post_view_message(&self, view: &str, message: String) -> Result<(), HostError> {
|
||||
let declared = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
|
||||
.iter()
|
||||
.any(|point| self.shared.ui.items(point, Some(self.id())).iter().any(|i| i.key == view));
|
||||
if !declared {
|
||||
return Err(HostError::NotFound(format!("the plugin declares no view {view:?}")));
|
||||
}
|
||||
let event = PluginEvent::ViewMessage { plugin_id: self.id().to_owned(), view: view.to_owned(), message };
|
||||
self.shared.sink.emit(None, event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_terminal(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
title: String,
|
||||
program: &str,
|
||||
args: Vec<String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), HostError> {
|
||||
let binary = self.approved_program(program)?;
|
||||
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
|
||||
let _ = std::fs::create_dir_all(&cwd);
|
||||
let event = PluginEvent::OpenTerminal {
|
||||
plugin_id: self.id().to_owned(),
|
||||
title,
|
||||
program: binary.into(),
|
||||
args,
|
||||
cwd: cwd.into(),
|
||||
};
|
||||
self.shared.sink.emit(client, event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
|
||||
let schema: Vec<super::ui_points::SettingSchema> = self
|
||||
.shared
|
||||
.ui
|
||||
.items(super::ui_points::SETTINGS, Some(self.id()))
|
||||
.into_iter()
|
||||
.find_map(|i| serde_json::from_value(i.decl.get("properties")?.clone()).ok())?;
|
||||
let setting = schema.into_iter().find(|s| s.key == key)?;
|
||||
let user = self.shared.workspace().map(|w| w.plugin_settings(self.id(), root)).unwrap_or_default();
|
||||
let value = user.get(key).filter(|v| setting.accepts(v)).cloned().unwrap_or(setting.default);
|
||||
serde_json::to_string(&value).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`PluginWorkspace`] over the desktop host's open projects.
|
||||
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
|
||||
|
||||
impl HostWorkspace {
|
||||
fn projects(&self) -> Vec<Arc<crate::project::Project>> {
|
||||
self.0.upgrade().map(|h| h.projects()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The project holding an open document at `path`, and its id.
|
||||
fn open_document(&self, path: &Path) -> Option<(Arc<crate::project::Project>, ide_core::DocId)> {
|
||||
self.projects().into_iter().find_map(|p| p.documents.by_path(path).map(|doc| (p, doc)))
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginWorkspace for HostWorkspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
let mut roots: Vec<PathBuf> = HostWorkspace::projects(self).iter().map(|p| p.root.clone()).collect();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
roots
|
||||
}
|
||||
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
|
||||
}
|
||||
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)> {
|
||||
let (project, doc) = self.open_document(path)?;
|
||||
let snap = project.documents.snapshot(doc).ok()?;
|
||||
Some((u64::from(snap.version), snap.text))
|
||||
}
|
||||
|
||||
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError> {
|
||||
let (project, doc) =
|
||||
self.open_document(path).ok_or_else(|| HostError::NotFound(format!("{} is not open", path.display())))?;
|
||||
let version = u32::try_from(version).map_err(|_| HostError::Stale)?;
|
||||
match project.documents.apply_edits_at(doc, version, edits) {
|
||||
Ok(Some(v)) => Ok(u64::from(v)),
|
||||
Ok(None) => Err(HostError::Stale),
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::InvalidInput => Err(HostError::Invalid(e.message)),
|
||||
Err(e) => Err(HostError::Failed(e.message)),
|
||||
}
|
||||
}
|
||||
|
||||
fn trusted(&self, root: &Path) -> bool {
|
||||
HostWorkspace::projects(self).iter().any(|p| p.root == root && p.is_trusted())
|
||||
}
|
||||
|
||||
fn project_of(&self, window: &str) -> Option<PathBuf> {
|
||||
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
|
||||
}
|
||||
|
||||
fn publish_diagnostics(
|
||||
&self,
|
||||
source: &str,
|
||||
path: &Path,
|
||||
items: Vec<ide_core::Diagnostic>,
|
||||
) -> Result<(), HostError> {
|
||||
let projects = HostWorkspace::projects(self);
|
||||
let project = projects
|
||||
.iter()
|
||||
.filter(|p| path.starts_with(&p.root))
|
||||
.max_by_key(|p| p.root.as_os_str().len())
|
||||
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
|
||||
project.sink.emit(ide_core::IdeEvent::Diagnostics {
|
||||
path: path.to_path_buf().into(),
|
||||
source: format!("plugin:{source}"),
|
||||
items,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
|
||||
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
|
||||
Some(p) => p.settings.effective(),
|
||||
None => host.global_settings.effective(),
|
||||
};
|
||||
settings.plugins.get(plugin).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn api(dir: &Path, pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>) -> PluginApi {
|
||||
let manifest = ide_extension::Manifest::parse(
|
||||
"id = \"acme.lint\"\nname = \"Lint\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
|
||||
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new());
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_run_only_as_approved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = tmp.path().join("lint");
|
||||
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
|
||||
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
|
||||
let api = api(tmp.path(), pins.clone());
|
||||
|
||||
// Allowed while the program was missing: say how to fix it rather than "not granted".
|
||||
let err = api.approved_program("lint").unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
pins.write().insert(("acme.lint".into(), "process:lint".into()), ProgramPin::approve(&bin).unwrap());
|
||||
assert_eq!(api.approved_program("lint").unwrap(), bin);
|
||||
|
||||
std::fs::write(&bin, "#!/bin/sh\necho replaced\n").unwrap();
|
||||
assert!(
|
||||
matches!(api.approved_program("lint"), Err(HostError::Denied(e)) if e.contains("changed since it was approved"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
//! The `iconThemes` extension point: file and folder icons.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.iconThemes]]
|
||||
//! id = "material"
|
||||
//! label = "Material Icons"
|
||||
//! path = "icons/theme.json"
|
||||
//! ```
|
||||
//!
|
||||
//! `theme.json` (paths relative to the package):
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "iconDefinitions": { "rust": "icons/rust.svg", "folder-src": "icons/folder-src.svg" },
|
||||
//! "file": "file", "folder": "folder", "folderExpanded": "folder-open",
|
||||
//! "fileExtensions": { "rs": "rust" },
|
||||
//! "fileNames": { "cargo.toml": "cargo" },
|
||||
//! "folderNames": { "src": "folder-src" }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Every SVG is sanitised here (allow-listed elements and attributes, no
|
||||
//! scripts, event handlers, external references or entities) and stored as an
|
||||
//! asset; the client fetches the mapping and the icons only when the theme is
|
||||
//! selected, so installed icon themes cost nothing at start-up.
|
||||
|
||||
use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id};
|
||||
use serde::Deserialize;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const POINT_ID: &str = "iconThemes";
|
||||
const MAX_ICONS: usize = 5000;
|
||||
const MAX_SVG: usize = 64 << 10;
|
||||
const MAX_MAPPINGS: usize = 20_000;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
id: String,
|
||||
label: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ThemeFile {
|
||||
icon_definitions: BTreeMap<String, PathBuf>,
|
||||
#[serde(default)]
|
||||
file: Option<String>,
|
||||
#[serde(default)]
|
||||
folder: Option<String>,
|
||||
#[serde(default)]
|
||||
folder_expanded: Option<String>,
|
||||
#[serde(default)]
|
||||
file_extensions: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
file_names: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
folder_names: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Asset id of a theme's mapping (JSON).
|
||||
pub fn map_asset(theme: &str) -> String {
|
||||
format!("iconTheme/{theme}/map")
|
||||
}
|
||||
|
||||
/// Asset id prefix of a theme's SVGs (`…/svg/<icon id>`).
|
||||
pub fn svg_prefix(theme: &str) -> String {
|
||||
format!("iconTheme/{theme}/svg/")
|
||||
}
|
||||
|
||||
/// An SVG icon of the package, size-checked and sanitised.
|
||||
pub(super) fn read_icon(pkg: &Package, path: &std::path::Path) -> Result<String, String> {
|
||||
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));
|
||||
}
|
||||
sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: Decl = decode(POINT_ID, decl)?;
|
||||
validate_item_id(&d.id, "icon theme id")?;
|
||||
if d.label.trim().is_empty() {
|
||||
return Err(format!("icon theme {:?} has an empty label", d.id));
|
||||
}
|
||||
let src = pkg.read_text(&d.path)?;
|
||||
let theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?;
|
||||
let at = |what: &str| format!("{} ({what})", d.path.display());
|
||||
if theme.icon_definitions.len() > MAX_ICONS {
|
||||
return Err(format!("{}: more than {MAX_ICONS} icons", d.path.display()));
|
||||
}
|
||||
let mappings = theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len();
|
||||
if mappings > MAX_MAPPINGS {
|
||||
return Err(format!("{}: more than {MAX_MAPPINGS} mappings", d.path.display()));
|
||||
}
|
||||
let defined: BTreeSet<&str> = theme.icon_definitions.keys().map(String::as_str).collect();
|
||||
let check_ref = |icon: &str, what: &str| -> Result<(), String> {
|
||||
if defined.contains(icon) { Ok(()) } else { Err(format!("{}: unknown icon {icon:?}", at(what))) }
|
||||
};
|
||||
for (what, icon) in [("file", &theme.file), ("folder", &theme.folder), ("folderExpanded", &theme.folder_expanded)] {
|
||||
if let Some(icon) = icon {
|
||||
check_ref(icon, what)?;
|
||||
}
|
||||
}
|
||||
// Extensions may be written with their dot (`.rs`); names keep theirs (`.github`, `.gitignore`).
|
||||
let lower = |map: &BTreeMap<String, String>, what: &str| -> Result<BTreeMap<String, String>, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (k, icon) in map {
|
||||
check_ref(icon, what)?;
|
||||
let key = k.trim();
|
||||
let key = if what == "fileExtensions" { key.trim_start_matches('.') } else { key }.to_lowercase();
|
||||
if key.is_empty() || key.contains(['/', '\\']) {
|
||||
return Err(format!("{}: invalid key {k:?}", at(what)));
|
||||
}
|
||||
out.insert(key, icon.clone());
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let file_extensions = lower(&theme.file_extensions, "fileExtensions")?;
|
||||
let file_names = lower(&theme.file_names, "fileNames")?;
|
||||
let folder_names = lower(&theme.folder_names, "folderNames")?;
|
||||
|
||||
let mut assets = Vec::with_capacity(theme.icon_definitions.len() + 1);
|
||||
let mut files = vec![d.path.clone()];
|
||||
for (icon, path) in &theme.icon_definitions {
|
||||
validate_item_id(icon, "icon id")?;
|
||||
let clean = read_icon(pkg, path)?;
|
||||
assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean));
|
||||
files.push(path.clone());
|
||||
}
|
||||
let map = serde_json::json!({
|
||||
"file": theme.file,
|
||||
"folder": theme.folder,
|
||||
"folderExpanded": theme.folder_expanded,
|
||||
"fileExtensions": file_extensions,
|
||||
"fileNames": file_names,
|
||||
"folderNames": folder_names,
|
||||
});
|
||||
assets.push((map_asset(&d.id), map.to_string()));
|
||||
let json = serde_json::json!({ "id": d.id, "label": d.label, "icons": theme.icon_definitions.len() });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.label, json);
|
||||
out.files = files;
|
||||
out.assets = assets;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG sanitiser
|
||||
|
||||
const ELEMENTS: &[&str] = &[
|
||||
"svg",
|
||||
"g",
|
||||
"path",
|
||||
"circle",
|
||||
"ellipse",
|
||||
"line",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"rect",
|
||||
"defs",
|
||||
"lineargradient",
|
||||
"radialgradient",
|
||||
"stop",
|
||||
"clippath",
|
||||
"mask",
|
||||
"use",
|
||||
"symbol",
|
||||
"pattern",
|
||||
"text",
|
||||
"tspan",
|
||||
"filter",
|
||||
"fegaussianblur",
|
||||
"feoffset",
|
||||
"feblend",
|
||||
"fecolormatrix",
|
||||
"feflood",
|
||||
"fecomposite",
|
||||
"femerge",
|
||||
"femergenode",
|
||||
"fedropshadow",
|
||||
];
|
||||
|
||||
/// Dropped with their content: harmless metadata.
|
||||
const STRIPPED: &[&str] = &["title", "desc", "metadata"];
|
||||
|
||||
const ATTRIBUTES: &[&str] = &[
|
||||
"id",
|
||||
"class",
|
||||
"viewbox",
|
||||
"width",
|
||||
"height",
|
||||
"x",
|
||||
"y",
|
||||
"x1",
|
||||
"y1",
|
||||
"x2",
|
||||
"y2",
|
||||
"cx",
|
||||
"cy",
|
||||
"r",
|
||||
"rx",
|
||||
"ry",
|
||||
"fx",
|
||||
"fy",
|
||||
"d",
|
||||
"points",
|
||||
"transform",
|
||||
"fill",
|
||||
"fill-opacity",
|
||||
"fill-rule",
|
||||
"clip-rule",
|
||||
"clip-path",
|
||||
"mask",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
"stroke-linecap",
|
||||
"stroke-linejoin",
|
||||
"stroke-miterlimit",
|
||||
"stroke-dasharray",
|
||||
"stroke-dashoffset",
|
||||
"stroke-opacity",
|
||||
"opacity",
|
||||
"offset",
|
||||
"stop-color",
|
||||
"stop-opacity",
|
||||
"gradientunits",
|
||||
"gradienttransform",
|
||||
"spreadmethod",
|
||||
"patternunits",
|
||||
"patterncontentunits",
|
||||
"patterntransform",
|
||||
"clippathunits",
|
||||
"maskunits",
|
||||
"maskcontentunits",
|
||||
"preserveaspectratio",
|
||||
"style",
|
||||
"href",
|
||||
"xlink:href",
|
||||
"xmlns",
|
||||
"xmlns:xlink",
|
||||
"version",
|
||||
"font-family",
|
||||
"font-size",
|
||||
"font-weight",
|
||||
"text-anchor",
|
||||
"dominant-baseline",
|
||||
"letter-spacing",
|
||||
"display",
|
||||
"visibility",
|
||||
"color",
|
||||
"filter",
|
||||
"in",
|
||||
"in2",
|
||||
"result",
|
||||
"stddeviation",
|
||||
"dx",
|
||||
"dy",
|
||||
"mode",
|
||||
"operator",
|
||||
"values",
|
||||
"type",
|
||||
"flood-color",
|
||||
"flood-opacity",
|
||||
"k1",
|
||||
"k2",
|
||||
"k3",
|
||||
"k4",
|
||||
"filterunits",
|
||||
"primitiveunits",
|
||||
"vector-effect",
|
||||
"shape-rendering",
|
||||
"isolation",
|
||||
"mix-blend-mode",
|
||||
];
|
||||
|
||||
/// Parses `src` and re-serialises the allow-listed subset. Fails on anything
|
||||
/// that could execute, fetch or escape (scripts, `foreignObject`, event
|
||||
/// handlers, external `href`s, `url()` to anything but a local fragment,
|
||||
/// DTDs, entities, CDATA).
|
||||
pub fn sanitize_svg(src: &str) -> Result<String, String> {
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
// Depth inside a stripped element (its content is skipped).
|
||||
let mut skip_depth = 0usize;
|
||||
let mut seen_root = false;
|
||||
let mut rest = src;
|
||||
while !rest.is_empty() {
|
||||
let Some(lt) = rest.find('<') else {
|
||||
if skip_depth == 0 && !stack.is_empty() {
|
||||
out.push_str(&text(rest)?);
|
||||
} else if !rest.trim().is_empty() && stack.is_empty() {
|
||||
return Err("text outside the <svg> element".into());
|
||||
}
|
||||
break;
|
||||
};
|
||||
if lt > 0 {
|
||||
let t = &rest[..lt];
|
||||
if skip_depth == 0 && !stack.is_empty() {
|
||||
out.push_str(&text(t)?);
|
||||
} else if stack.is_empty() && !t.trim().is_empty() {
|
||||
return Err("text outside the <svg> element".into());
|
||||
}
|
||||
}
|
||||
rest = &rest[lt..];
|
||||
if let Some(after) = rest.strip_prefix("<!--") {
|
||||
let end = after.find("-->").ok_or("unterminated comment")?;
|
||||
rest = &after[end + 3..];
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<?") {
|
||||
let end = rest.find("?>").ok_or("unterminated processing instruction")?;
|
||||
rest = &rest[end + 2..];
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<!") {
|
||||
return Err("DTDs, entities and CDATA are not allowed".into());
|
||||
}
|
||||
if let Some(after) = rest.strip_prefix("</") {
|
||||
let end = after.find('>').ok_or("unterminated closing tag")?;
|
||||
let name = after[..end].trim().to_ascii_lowercase();
|
||||
rest = &after[end + 1..];
|
||||
let open = stack.pop().ok_or_else(|| format!("unexpected </{name}>"))?;
|
||||
if open != name {
|
||||
return Err(format!("</{name}> does not close <{open}>"));
|
||||
}
|
||||
if skip_depth > 0 {
|
||||
skip_depth -= 1;
|
||||
} else {
|
||||
out.push_str(&format!("</{name}>"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Start tag.
|
||||
let end = tag_end(rest).ok_or("unterminated tag")?;
|
||||
let inner = &rest[1..end];
|
||||
rest = &rest[end + 1..];
|
||||
let self_closing = inner.trim_end().ends_with('/');
|
||||
let inner = inner.trim_end().trim_end_matches('/');
|
||||
let name_end = inner.find(|c: char| c.is_whitespace()).unwrap_or(inner.len());
|
||||
let raw_name = &inner[..name_end];
|
||||
let name = raw_name.to_ascii_lowercase();
|
||||
if name.is_empty() {
|
||||
return Err("empty tag".into());
|
||||
}
|
||||
if stack.is_empty() {
|
||||
if seen_root || name != "svg" {
|
||||
return Err("the file must contain exactly one <svg> element".into());
|
||||
}
|
||||
seen_root = true;
|
||||
}
|
||||
if !self_closing {
|
||||
stack.push(name.clone());
|
||||
}
|
||||
if skip_depth > 0 {
|
||||
if !self_closing {
|
||||
skip_depth += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if STRIPPED.contains(&name.as_str()) || name.contains(':') {
|
||||
// Editor metadata (sodipodi:namedview) or descriptive text.
|
||||
if !self_closing {
|
||||
skip_depth = 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !ELEMENTS.contains(&name.as_str()) {
|
||||
return Err(format!("<{raw_name}> is not allowed"));
|
||||
}
|
||||
out.push('<');
|
||||
out.push_str(raw_name);
|
||||
for (attr, value) in attributes(&inner[name_end..])? {
|
||||
let key = attr.to_ascii_lowercase();
|
||||
if key.starts_with("on") {
|
||||
return Err(format!("event handler {attr} is not allowed"));
|
||||
}
|
||||
if key.contains(':') && !matches!(key.as_str(), "xlink:href" | "xmlns:xlink") {
|
||||
continue; // inkscape:label, sodipodi:docname, …
|
||||
}
|
||||
if !ATTRIBUTES.contains(&key.as_str()) && !key.starts_with("data-") && !key.starts_with("aria-") {
|
||||
return Err(format!("attribute {attr} is not allowed"));
|
||||
}
|
||||
check_value(&key, &value).map_err(|e| format!("{attr}: {e}"))?;
|
||||
out.push(' ');
|
||||
out.push_str(&attr);
|
||||
out.push_str("=\"");
|
||||
out.push_str(&escape(&value));
|
||||
out.push('"');
|
||||
}
|
||||
out.push_str(if self_closing { "/>" } else { ">" });
|
||||
}
|
||||
if !stack.is_empty() {
|
||||
return Err(format!("<{}> is not closed", stack.last().map(String::as_str).unwrap_or("?")));
|
||||
}
|
||||
if !seen_root {
|
||||
return Err("no <svg> element".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Index of the `>` ending the tag at the start of `s`, skipping quoted values.
|
||||
fn tag_end(s: &str) -> Option<usize> {
|
||||
let mut quote: Option<char> = None;
|
||||
for (i, c) in s.char_indices().skip(1) {
|
||||
match (quote, c) {
|
||||
(Some(q), c) if c == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '\'') => quote = Some(c),
|
||||
(None, '>') => return Some(i),
|
||||
(None, '<') => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn attributes(s: &str) -> Result<Vec<(String, String)>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = s.trim_start();
|
||||
while !rest.is_empty() {
|
||||
let eq = rest.find('=').ok_or_else(|| format!("attribute without value near {:?}", clip(rest)))?;
|
||||
let name = rest[..eq].trim();
|
||||
if name.is_empty() || name.contains(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
|
||||
return Err(format!("malformed attribute near {:?}", clip(rest)));
|
||||
}
|
||||
let after = rest[eq + 1..].trim_start();
|
||||
let quote = after.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("unquoted attribute value")?;
|
||||
let close = after[1..].find(quote).ok_or("unterminated attribute value")?;
|
||||
let value = decode_entities(&after[1..1 + close])?;
|
||||
if out.iter().any(|(n, _): &(String, String)| n.eq_ignore_ascii_case(name)) {
|
||||
return Err(format!("duplicate attribute {name}"));
|
||||
}
|
||||
out.push((name.to_owned(), value));
|
||||
rest = after[close + 2..].trim_start();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn check_value(key: &str, value: &str) -> Result<(), String> {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
if matches!(key, "href" | "xlink:href") {
|
||||
return if value.starts_with('#') { Ok(()) } else { Err("only local references (#id) are allowed".into()) };
|
||||
}
|
||||
if key == "xmlns" || key == "xmlns:xlink" {
|
||||
return Ok(());
|
||||
}
|
||||
if lower.contains("javascript:")
|
||||
|| lower.contains("expression(")
|
||||
|| lower.contains("@import")
|
||||
|| value.contains('\\')
|
||||
{
|
||||
return Err("unsafe value".into());
|
||||
}
|
||||
let mut rest = lower.as_str();
|
||||
while let Some(i) = rest.find("url(") {
|
||||
let inner = rest[i + 4..].trim_start().trim_start_matches(['"', '\'']);
|
||||
if !inner.starts_with('#') {
|
||||
return Err("url() may only reference a local fragment".into());
|
||||
}
|
||||
rest = &rest[i + 4..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_entities(s: &str) -> Result<String, String> {
|
||||
if !s.contains('&') {
|
||||
return Ok(s.to_owned());
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find('&') {
|
||||
out.push_str(&rest[..i]);
|
||||
let semi = rest[i..].find(';').ok_or("unterminated entity")?;
|
||||
let ent = &rest[i + 1..i + semi];
|
||||
let c = match ent {
|
||||
"amp" => '&',
|
||||
"lt" => '<',
|
||||
"gt" => '>',
|
||||
"quot" => '"',
|
||||
"apos" => '\'',
|
||||
_ => {
|
||||
let code = if let Some(hex) = ent.strip_prefix("#x").or_else(|| ent.strip_prefix("#X")) {
|
||||
u32::from_str_radix(hex, 16).ok()
|
||||
} else if let Some(dec) = ent.strip_prefix('#') {
|
||||
dec.parse().ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
code.and_then(char::from_u32).ok_or_else(|| format!("entity &{ent}; is not allowed"))?
|
||||
}
|
||||
};
|
||||
out.push(c);
|
||||
rest = &rest[i + semi + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn text(t: &str) -> Result<String, String> {
|
||||
Ok(escape(&decode_entities(t)?))
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
fn clip(s: &str) -> &str {
|
||||
let mut end = s.len().min(24);
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&s[..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_ordinary_icons() {
|
||||
let src = r##"<?xml version="1.0"?>
|
||||
<!-- Material icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="x" viewBox="0 0 24 24" inkscape:version="1">
|
||||
<title>rust</title>
|
||||
<defs><linearGradient id="g"><stop offset="0" stop-color="#fff"/></linearGradient></defs>
|
||||
<path fill="url(#g)" d="M1 2h3 & 4" style="opacity:.5"/>
|
||||
<use href="#g"/>
|
||||
</svg>"##;
|
||||
let out = sanitize_svg(src).unwrap();
|
||||
assert!(out.starts_with("<svg xmlns="), "{out}");
|
||||
assert!(!out.contains("title") && !out.contains("inkscape") && !out.contains("<!--"));
|
||||
assert!(out.contains(r#"fill="url(#g)""#));
|
||||
assert!(out.contains(r#"d="M1 2h3 & 4""#));
|
||||
assert!(out.ends_with("</svg>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_active_content() {
|
||||
let bad = [
|
||||
r##"<svg><script>alert(1)</script></svg>"##,
|
||||
r##"<svg onload="alert(1)"></svg>"##,
|
||||
r##"<svg><foreignObject/></svg>"##,
|
||||
r##"<svg><use href="https://evil/x.svg#a"/></svg>"##,
|
||||
r##"<svg><use xlink:href="data:image/svg+xml,..."/></svg>"##,
|
||||
r##"<svg><rect style="fill:url(https://evil)"/></svg>"##,
|
||||
r##"<svg><rect fill="url( 'http://x' )"/></svg>"##,
|
||||
r##"<!DOCTYPE svg [<!ENTITY x "y">]><svg/>"##,
|
||||
r##"<svg><![CDATA[x]]></svg>"##,
|
||||
r##"<svg><a href="#x"/></svg>"##,
|
||||
r##"<svg><rect width="&xx;"/></svg>"##,
|
||||
r##"<svg><rect width=10/></svg>"##,
|
||||
r##"<svg><g></svg>"##,
|
||||
r##"<svg/><svg/>"##,
|
||||
r##"<g/>"##,
|
||||
r##"hello"##,
|
||||
r##"<svg><rect style="behavior:url(#x);x:expression(1)"/></svg>"##,
|
||||
];
|
||||
for src in bad {
|
||||
assert!(sanitize_svg(src).is_err(), "accepted: {src}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_files_validate_and_produce_assets() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join("icons")).unwrap();
|
||||
std::fs::write(dir.join("icons/rust.svg"), r#"<svg viewBox="0 0 16 16"><path d="M0 0h16v16z"/></svg>"#)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("icons/file.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("theme.json"),
|
||||
r#"{"iconDefinitions":{"rust":"icons/rust.svg","file":"icons/file.svg"},"file":"file",
|
||||
"fileExtensions":{".RS":"rust"},"fileNames":{"Cargo.toml":"rust",".gitignore":"file"},
|
||||
"folderNames":{".GitHub":"file","src":"rust"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = ide_extension::Manifest::parse(
|
||||
"id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"",
|
||||
)
|
||||
.unwrap();
|
||||
let pkg = Package::new(manifest, dir.to_path_buf());
|
||||
let decl = |path: &str| {
|
||||
let mut t = toml::Table::new();
|
||||
t.insert("id".into(), "m".into());
|
||||
t.insert("label".into(), "M".into());
|
||||
t.insert("path".into(), path.into());
|
||||
toml::Value::Table(t)
|
||||
};
|
||||
let d = icon_theme(&pkg, decl("theme.json")).unwrap();
|
||||
assert_eq!(d.json["icons"], 2);
|
||||
assert_eq!(d.files.len(), 3);
|
||||
let map: serde_json::Value =
|
||||
serde_json::from_str(&d.assets.iter().find(|(k, _)| *k == map_asset("m")).unwrap().1).unwrap();
|
||||
assert_eq!(map["fileExtensions"]["rs"], "rust");
|
||||
assert_eq!(map["fileNames"]["cargo.toml"], "rust");
|
||||
// Names keep their leading dot (only extensions drop it).
|
||||
assert_eq!(map["fileNames"][".gitignore"], "file");
|
||||
assert_eq!(map["folderNames"][".github"], "file");
|
||||
assert_eq!(map["folderNames"]["src"], "rust");
|
||||
assert!(map["folderNames"].get("github").is_none());
|
||||
assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust", svg_prefix("m"))));
|
||||
|
||||
std::fs::write(dir.join("bad.json"), r#"{"iconDefinitions":{},"fileExtensions":{"rs":"nope"}}"#).unwrap();
|
||||
assert!(icon_theme(&pkg, decl("bad.json")).unwrap_err().contains("unknown icon"));
|
||||
std::fs::write(dir.join("icons/evil.svg"), r#"<svg><script/></svg>"#).unwrap();
|
||||
std::fs::write(dir.join("evil.json"), r#"{"iconDefinitions":{"e":"icons/evil.svg"}}"#).unwrap();
|
||||
assert!(icon_theme(&pkg, decl("evil.json")).unwrap_err().contains("evil.svg"));
|
||||
assert!(icon_theme(&pkg, decl("../x.json")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Copying a package into the plugins directory without ever leaving the
|
||||
//! plugin missing. Installed plugins are versioned:
|
||||
//! `<plugins>/<id>/<version>/` plus an `active` pointer file (and `previous`,
|
||||
//! the version to roll back to). A new version is written to a staging
|
||||
//! directory, renamed into place, and only then made active by replacing the
|
||||
//! pointer; a swap interrupted by a crash is undone on the next start
|
||||
//! ([`recover`]). Older installs used `<plugins>/<id>/` directly; they are
|
||||
//! moved into the versioned layout on their next update.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Top-level files copied with a package besides those its declarations reference.
|
||||
pub const LICENSE_FILES: &[&str] =
|
||||
&["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING", "README.md"];
|
||||
/// Written into an installed package: the package hash the grant ledger compares.
|
||||
pub const HASH_FILE: &str = ".package-hash";
|
||||
|
||||
const STAGING: &str = ".staging-";
|
||||
const OLD: &str = ".old-";
|
||||
/// `<root>/active`: the version in use.
|
||||
pub const ACTIVE_FILE: &str = "active";
|
||||
/// `<root>/previous`: the version an update replaced (for rollback).
|
||||
pub const PREVIOUS_FILE: &str = "previous";
|
||||
|
||||
fn nonce() -> String {
|
||||
let n = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
format!("{n:x}-{}", std::process::id())
|
||||
}
|
||||
|
||||
/// Copies `files` (plus manifest and licence files) from `src` into `target`
|
||||
/// atomically, recording `hash`.
|
||||
pub fn install_package(src: &Path, target: &Path, files: &[PathBuf], hash: &str) -> io::Result<()> {
|
||||
let parent = target.parent().ok_or_else(|| io::Error::other("plugin target has no parent"))?;
|
||||
let name = target.file_name().and_then(|n| n.to_str()).ok_or_else(|| io::Error::other("bad target"))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let staging = parent.join(format!("{STAGING}{name}-{}", nonce()));
|
||||
let result = (|| {
|
||||
std::fs::create_dir_all(&staging)?;
|
||||
std::fs::copy(src.join(ide_extension::MANIFEST_FILE), staging.join(ide_extension::MANIFEST_FILE))?;
|
||||
for file in files {
|
||||
let to = staging.join(file);
|
||||
if let Some(dir) = to.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::copy(src.join(file), to)?;
|
||||
}
|
||||
for lic in LICENSE_FILES {
|
||||
if src.join(lic).is_file() {
|
||||
std::fs::copy(src.join(lic), staging.join(lic))?;
|
||||
}
|
||||
}
|
||||
std::fs::write(staging.join(HASH_FILE), hash)?;
|
||||
swap(&staging, target, |from, to| std::fs::rename(from, to))
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// A version as a directory name (semver: `[0-9A-Za-z.+-]`).
|
||||
fn version_dir(version: &str) -> io::Result<&str> {
|
||||
let ok = !version.is_empty()
|
||||
&& version.len() <= 64
|
||||
&& !version.starts_with('.')
|
||||
&& version.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-'));
|
||||
if ok { Ok(version) } else { Err(io::Error::other(format!("version {version:?} cannot name a directory"))) }
|
||||
}
|
||||
|
||||
fn read_pointer(root: &Path, name: &str) -> Option<String> {
|
||||
let v = std::fs::read_to_string(root.join(name)).ok()?;
|
||||
let v = v.trim();
|
||||
(!v.is_empty() && version_dir(v).is_ok()).then(|| v.to_owned())
|
||||
}
|
||||
|
||||
/// Replaces the pointer file `name` atomically.
|
||||
fn write_pointer(root: &Path, name: &str, version: &str) -> io::Result<()> {
|
||||
let tmp = root.join(format!(".{name}.tmp"));
|
||||
std::fs::write(&tmp, version)?;
|
||||
std::fs::rename(&tmp, root.join(name))
|
||||
}
|
||||
|
||||
/// The directory of a plugin's active version under its `root`
|
||||
/// (`<plugins>/<id>`): `<root>/<active>`, or `root` itself in the flat layout.
|
||||
pub fn active_dir(root: &Path) -> Option<PathBuf> {
|
||||
if let Some(v) = read_pointer(root, ACTIVE_FILE) {
|
||||
let dir = root.join(v);
|
||||
return dir.join(ide_extension::MANIFEST_FILE).is_file().then_some(dir);
|
||||
}
|
||||
root.join(ide_extension::MANIFEST_FILE).is_file().then(|| root.to_path_buf())
|
||||
}
|
||||
|
||||
/// The version an update replaced, if it is still there.
|
||||
pub fn previous_version(root: &Path) -> Option<String> {
|
||||
read_pointer(root, PREVIOUS_FILE).filter(|v| root.join(v).join(ide_extension::MANIFEST_FILE).is_file())
|
||||
}
|
||||
|
||||
/// Moves a flat install (`<root>/plugin.toml`) to `<root>/<version>/`.
|
||||
fn migrate_flat(root: &Path, version_of: impl Fn(&Path) -> Option<String>) -> io::Result<()> {
|
||||
if read_pointer(root, ACTIVE_FILE).is_some() || !root.join(ide_extension::MANIFEST_FILE).is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let version = version_of(root).unwrap_or_else(|| "0.0.0".into());
|
||||
let version = version_dir(&version).map(str::to_owned).unwrap_or_else(|_| "0.0.0".into());
|
||||
let parent = root.parent().unwrap_or(Path::new("."));
|
||||
let name = root.file_name().and_then(|n| n.to_str()).ok_or_else(|| io::Error::other("bad plugin directory"))?;
|
||||
// `.old-<name>-…` is restored by `recover` if this is interrupted.
|
||||
let old = parent.join(format!("{OLD}{name}-{}", nonce()));
|
||||
std::fs::rename(root, &old)?;
|
||||
std::fs::create_dir_all(root)?;
|
||||
std::fs::rename(&old, root.join(&version))?;
|
||||
write_pointer(root, ACTIVE_FILE, &version)
|
||||
}
|
||||
|
||||
/// Installs `files` of `src` as `<root>/<version>` and makes it the active
|
||||
/// version. Returns the version it replaced (now `previous`).
|
||||
pub fn install_version(
|
||||
src: &Path,
|
||||
root: &Path,
|
||||
version: &str,
|
||||
files: &[PathBuf],
|
||||
hash: &str,
|
||||
version_of: impl Fn(&Path) -> Option<String>,
|
||||
) -> io::Result<Option<String>> {
|
||||
let version = version_dir(version)?;
|
||||
std::fs::create_dir_all(root)?;
|
||||
migrate_flat(root, version_of)?;
|
||||
install_package(src, &root.join(version), files, hash)?;
|
||||
let previous = read_pointer(root, ACTIVE_FILE).filter(|v| v != version);
|
||||
if let Some(p) = &previous {
|
||||
write_pointer(root, PREVIOUS_FILE, p)?;
|
||||
}
|
||||
write_pointer(root, ACTIVE_FILE, version)?;
|
||||
Ok(previous)
|
||||
}
|
||||
|
||||
/// Makes `version` active again (rollback). The replaced version stays until [`prune`].
|
||||
pub fn activate_version(root: &Path, version: &str) -> io::Result<()> {
|
||||
let version = version_dir(version)?;
|
||||
if !root.join(version).join(ide_extension::MANIFEST_FILE).is_file() {
|
||||
return Err(io::Error::other(format!("version {version} is not installed")));
|
||||
}
|
||||
let _ = std::fs::remove_file(root.join(PREVIOUS_FILE));
|
||||
write_pointer(root, ACTIVE_FILE, version)
|
||||
}
|
||||
|
||||
/// Removes versions other than the active and the previous one.
|
||||
pub fn prune(root: &Path) {
|
||||
let keep: Vec<String> = [ACTIVE_FILE, PREVIOUS_FILE].iter().filter_map(|f| read_pointer(root, f)).collect();
|
||||
if keep.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return };
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if path.is_dir() && !keep.contains(&name) {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves `staging` to `target`, keeping the old `target` until the new one is in place.
|
||||
fn swap(staging: &Path, target: &Path, rename: impl Fn(&Path, &Path) -> io::Result<()>) -> io::Result<()> {
|
||||
if !target.exists() {
|
||||
return rename(staging, target);
|
||||
}
|
||||
let parent = target.parent().unwrap_or(Path::new("."));
|
||||
let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("plugin");
|
||||
let old = parent.join(format!("{OLD}{name}-{}", nonce()));
|
||||
rename(target, &old)?;
|
||||
if let Err(e) = rename(staging, target) {
|
||||
// Put the previous version back.
|
||||
rename(&old, target)?;
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = std::fs::remove_dir_all(&old) {
|
||||
tracing::warn!("removing the previous version {}: {e}", old.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Undoes interrupted installs in `dir`: restores `.old-<name>-*` when
|
||||
/// `<name>` is missing, and removes leftover staging and old directories.
|
||||
pub fn recover(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Inside a versioned plugin: staging and old versions of a version swap.
|
||||
if path.is_dir() && read_pointer(&path, ACTIVE_FILE).is_some() {
|
||||
recover(&path);
|
||||
continue;
|
||||
}
|
||||
let Some(file) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue };
|
||||
if file.starts_with(STAGING) {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
} else if let Some(rest) = file.strip_prefix(OLD) {
|
||||
// `<name>-<nonce>-<pid>`: the name is everything before the last two parts.
|
||||
let parts: Vec<&str> = rest.rsplitn(3, '-').collect();
|
||||
let name = parts.get(2).copied().unwrap_or_default();
|
||||
let target = dir.join(name);
|
||||
// A layout migration interrupted after creating the new root: it is still empty.
|
||||
if !name.is_empty() && target.is_dir() && active_dir(&target).is_none() {
|
||||
let _ = std::fs::remove_dir_all(&target);
|
||||
}
|
||||
if !name.is_empty() && !target.exists() {
|
||||
tracing::warn!("restoring {} after an interrupted install", target.display());
|
||||
let _ = std::fs::rename(&path, &target);
|
||||
} else {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_swap_keeps_the_old_version() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = tmp.path().join("p");
|
||||
let staging = tmp.path().join(".staging-p-1");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
std::fs::write(target.join("v"), "1").unwrap();
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
std::fs::write(staging.join("v"), "2").unwrap();
|
||||
let calls = std::cell::Cell::new(0);
|
||||
let err = swap(&staging, &target, |a, b| {
|
||||
calls.set(calls.get() + 1);
|
||||
if calls.get() == 2 { Err(io::Error::other("disk full")) } else { std::fs::rename(a, b) }
|
||||
});
|
||||
assert!(err.is_err());
|
||||
assert_eq!(std::fs::read_to_string(target.join("v")).unwrap(), "1");
|
||||
swap(&staging, &target, |a, b| std::fs::rename(a, b)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(target.join("v")).unwrap(), "2");
|
||||
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1, "old version removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_are_installed_side_by_side_and_rolled_back() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let src = tmp.path().join("src");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let root = tmp.path().join("plugins/acme.tools");
|
||||
let version_of = |_: &Path| Some("0.9.0".to_owned());
|
||||
// A flat install from before versioning.
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join(ide_extension::MANIFEST_FILE), "v0.9").unwrap();
|
||||
assert_eq!(active_dir(&root), Some(root.clone()));
|
||||
|
||||
std::fs::write(src.join(ide_extension::MANIFEST_FILE), "v1").unwrap();
|
||||
assert_eq!(install_version(&src, &root, "1.0.0", &[], "h1", version_of).unwrap().as_deref(), Some("0.9.0"));
|
||||
assert_eq!(active_dir(&root), Some(root.join("1.0.0")));
|
||||
std::fs::write(src.join(ide_extension::MANIFEST_FILE), "v2").unwrap();
|
||||
assert_eq!(install_version(&src, &root, "2.0.0", &[], "h2", version_of).unwrap().as_deref(), Some("1.0.0"));
|
||||
assert_eq!(previous_version(&root).as_deref(), Some("1.0.0"));
|
||||
prune(&root);
|
||||
assert!(!root.join("0.9.0").exists(), "older than previous: pruned");
|
||||
|
||||
activate_version(&root, "1.0.0").unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(active_dir(&root).unwrap().join(ide_extension::MANIFEST_FILE)).unwrap(),
|
||||
"v1"
|
||||
);
|
||||
assert_eq!(previous_version(&root), None);
|
||||
assert!(install_version(&src, &root, "../x", &[], "h", version_of).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_restores_an_interrupted_swap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".old-acme.tools-1a2b-77")).unwrap();
|
||||
std::fs::write(tmp.path().join(".old-acme.tools-1a2b-77/v"), "1").unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".staging-acme.tools-1a2c-77")).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("x")).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".old-x-1-2")).unwrap();
|
||||
recover(tmp.path());
|
||||
assert_eq!(std::fs::read_to_string(tmp.path().join("acme.tools/v")).unwrap(), "1");
|
||||
let mut names: Vec<_> =
|
||||
std::fs::read_dir(tmp.path()).unwrap().flatten().map(|e| e.file_name().into_string().unwrap()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["acme.tools", "x"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! The marketplace: 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 official marketplace: the index the release workflow publishes with
|
||||
/// the app, next to the signed packages of the official plugins.
|
||||
pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json";
|
||||
|
||||
/// The last index read, in the plugins directory.
|
||||
pub const INDEX_FILE: &str = "market-index.json";
|
||||
const DOWNLOADS: &str = ".downloads";
|
||||
const MAX_INDEX: usize = 16 << 20;
|
||||
const MAX_PACKAGE: usize = 256 << 20;
|
||||
|
||||
/// A marketplace entry with what is installed.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarketPlugin {
|
||||
#[serde(flatten)]
|
||||
pub entry: IndexEntry,
|
||||
/// Installed version, if any.
|
||||
pub installed: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
|
||||
pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
|
||||
if url.starts_with("https://") || url.starts_with("http://") {
|
||||
let client = ide_http::client::shared().map_err(|e| e.message)?;
|
||||
let url = url.to_owned();
|
||||
return super::api::net_runtime().block_on(async move {
|
||||
let response = client.get(&url).send().await.map_err(|e| format!("{url}: {e}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("{url}: HTTP {}", response.status()));
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|e| format!("{url}: {e}"))?;
|
||||
if bytes.len() > max {
|
||||
return Err(format!("{url}: larger than {} MiB", max >> 20));
|
||||
}
|
||||
Ok(bytes.to_vec())
|
||||
});
|
||||
}
|
||||
let path = url.strip_prefix("file://").unwrap_or(url);
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if bytes.len() > max {
|
||||
return Err(format!("{path}: larger than {} MiB", max >> 20));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// `href` relative to the index at `base`: a URL, or a local path (with the
|
||||
/// platform's separators, e.g. `C:\\market\\index.json`).
|
||||
pub fn resolve(base: &str, href: &str) -> String {
|
||||
if href.contains("://") || href.starts_with('/') || Path::new(href).is_absolute() {
|
||||
return href.to_owned();
|
||||
}
|
||||
if base.contains("://") {
|
||||
return match base.rfind('/') {
|
||||
Some(i) => format!("{}/{href}", &base[..i]),
|
||||
None => href.to_owned(),
|
||||
};
|
||||
}
|
||||
match Path::new(base).parent() {
|
||||
Some(dir) => dir.join(href).to_string_lossy().into_owned(),
|
||||
None => href.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(super) fn load_market_index(dir: &Path) -> Option<Index> {
|
||||
let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?;
|
||||
Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()
|
||||
}
|
||||
|
||||
/// The marketplace index URL: the user's, else the default (none: no marketplace).
|
||||
pub fn market_url(&self) -> Option<String> {
|
||||
self.state.lock().market_url.clone().or_else(|| self.default_market_url.read().clone())
|
||||
}
|
||||
|
||||
/// The marketplace used while the user has set none.
|
||||
pub fn set_default_market_url(&self, url: Option<String>) {
|
||||
*self.default_market_url.write() = url.filter(|u| !u.trim().is_empty());
|
||||
}
|
||||
|
||||
pub fn set_market_url(&self, url: Option<String>) {
|
||||
self.state.lock().market_url = url.filter(|u| !u.trim().is_empty());
|
||||
self.save_state();
|
||||
}
|
||||
|
||||
/// Reads the index again, keeps it, and applies its revocations.
|
||||
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
|
||||
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
|
||||
let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?;
|
||||
let index = Index::parse(&text).map_err(IdeError::invalid)?;
|
||||
let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), &text);
|
||||
*self.market.write() = Some(index);
|
||||
// Trust may have changed: who signed what, what is revoked.
|
||||
let installed: Vec<_> =
|
||||
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
|
||||
for plugin in installed {
|
||||
let publisher = self.verified_publisher(plugin.dir());
|
||||
plugin.meta.lock().publisher = publisher;
|
||||
}
|
||||
self.apply_revocations();
|
||||
Ok(self.market_plugins())
|
||||
}
|
||||
|
||||
/// The plugins of the last index read.
|
||||
pub fn market_plugins(&self) -> Vec<MarketPlugin> {
|
||||
let Some(index) = self.market.read().clone() else { return Vec::new() };
|
||||
let plugins = self.plugins.read();
|
||||
index
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|e| index.revocation(&e.id, &e.version).is_none())
|
||||
.cloned()
|
||||
.map(|entry| {
|
||||
let installed = plugins
|
||||
.get(&entry.id)
|
||||
.filter(|p| p.source == PluginSource::Installed)
|
||||
.map(|p| p.manifest().version.clone());
|
||||
MarketPlugin { entry, installed }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Downloads a marketplace plugin, checks it against the index (digest and
|
||||
/// signature), and unpacks it for the install page. Returns the folder to
|
||||
/// install from.
|
||||
pub fn download_market_plugin(&self, id: &str) -> IdeResult<PathBuf> {
|
||||
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
|
||||
let index = self.market.read().clone().ok_or_else(|| IdeError::unavailable("refresh the marketplace first"))?;
|
||||
let entry = index
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|e| e.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("{id} is not in the marketplace")))?;
|
||||
let bytes = read_url(&resolve(&url, &entry.download), MAX_PACKAGE).map_err(IdeError::unavailable)?;
|
||||
let downloads = self.plugins_dir.join(DOWNLOADS);
|
||||
std::fs::create_dir_all(&downloads)?;
|
||||
let archive = downloads.join(format!("{}-{}.{}", entry.id, entry.version, package::PACKAGE_EXTENSION));
|
||||
std::fs::write(&archive, bytes)?;
|
||||
let dir = self.unpack_package(&archive)?;
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
let manifest = Manifest::load(&dir).map_err(IdeError::invalid)?;
|
||||
let files = self.package_files_in(&dir)?;
|
||||
let digest = package::package_digest(&dir, &files)?;
|
||||
if (manifest.id.as_str(), manifest.version.as_str(), digest.as_str())
|
||||
!= (&entry.id, &entry.version, &entry.digest)
|
||||
{
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return Err(IdeError::invalid("the download does not match the marketplace's entry"));
|
||||
}
|
||||
if self.verified_publisher(&dir).is_none() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return Err(IdeError::invalid("the marketplace package is not signed by a trusted publisher"));
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Unpacks a package file (`.oiplugin`, or a `.zip` of a package) next to the downloads; returns the folder.
|
||||
pub fn unpack_package(&self, archive: &Path) -> IdeResult<PathBuf> {
|
||||
let stem = archive.file_stem().and_then(|s| s.to_str()).unwrap_or("package");
|
||||
let dir = self.plugins_dir.join(DOWNLOADS).join(format!("{stem}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let unpacked = package::unpack(archive, &dir).map_err(IdeError::invalid)?;
|
||||
// Kept with the files: install checks it and keeps it with the installed copy.
|
||||
if let Some(sig) = unpacked.signature {
|
||||
let json = serde_json::to_string_pretty(&sig).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(dir.join(SIGNATURE_FILE), json)?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Files of the package in `dir` (what its declarations reference, and
|
||||
/// the component): what a package file holds besides the manifest.
|
||||
pub fn package_files_of(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
|
||||
self.package_files_in(dir)
|
||||
}
|
||||
|
||||
pub(super) fn package_files_in(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
|
||||
let manifest = Manifest::load(dir).map_err(IdeError::invalid)?;
|
||||
let pkg = ide_extension::Package::new(manifest, dir.to_path_buf());
|
||||
let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?;
|
||||
Ok(set.files.into_iter().chain(pkg.manifest.wasm.clone()).collect())
|
||||
}
|
||||
|
||||
/// The publisher whose valid, trusted signature the package in `dir` carries.
|
||||
pub(super) fn verified_publisher(&self, dir: &Path) -> Option<String> {
|
||||
let sig = read_signature(dir)?;
|
||||
let index = self.market.read().clone()?;
|
||||
let manifest = Manifest::load(dir).ok()?;
|
||||
let files = self.package_files_in(dir).ok()?;
|
||||
let digest = package::package_digest(dir, &files).ok()?;
|
||||
index
|
||||
.verify(&sig, &manifest.id, &manifest.version, &digest)
|
||||
.map_err(|e| tracing::warn!("{}: {e}", dir.display()))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// A signature that is present but doesn't verify (tampered, foreign prefix,
|
||||
/// revoked or unknown key): installing is refused.
|
||||
pub(super) fn signature_problem(&self, dir: &Path) -> Option<String> {
|
||||
let sig = read_signature(dir)?;
|
||||
let manifest = Manifest::load(dir).ok()?;
|
||||
let files = self.package_files_in(dir).ok()?;
|
||||
let digest = package::package_digest(dir, &files).ok()?;
|
||||
if let Err(e) = sig.check(&manifest.id, &manifest.version, &digest) {
|
||||
return Some(e);
|
||||
}
|
||||
let index = self.market.read().clone()?;
|
||||
index.verify(&sig, &manifest.id, &manifest.version, &digest).err()
|
||||
}
|
||||
|
||||
/// Why an installed plugin must not run (index revocations, revoked signing key).
|
||||
pub(super) fn revoked_reason(&self, id: &str, version: &str, dir: &Path) -> Option<String> {
|
||||
let market = self.market.read();
|
||||
let index = market.as_ref()?;
|
||||
if let Some(r) = index.revocation(id, version) {
|
||||
return Some(r.reason.clone());
|
||||
}
|
||||
let sig = read_signature(dir)?;
|
||||
index.key_revoked(&sig.key).then(|| "its publisher's signing key was revoked".to_owned())
|
||||
}
|
||||
|
||||
/// Disables installed plugins the index revokes (telling the user once),
|
||||
/// and starts again those it no longer revokes.
|
||||
pub(super) fn apply_revocations(&self) {
|
||||
let installed: Vec<_> =
|
||||
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
|
||||
let mut changed = false;
|
||||
for plugin in installed {
|
||||
let reason = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir());
|
||||
let current = plugin.meta.lock().error.clone().filter(|e| e.starts_with("revoked: "));
|
||||
match (reason, current) {
|
||||
(Some(r), Some(c)) if c == format!("revoked: {r}") => {}
|
||||
(Some(reason), was) => {
|
||||
self.stop(&plugin);
|
||||
{
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.status = PluginStatus::Failed;
|
||||
meta.error = Some(format!("revoked: {reason}"));
|
||||
}
|
||||
if was.is_none() {
|
||||
self.emit(
|
||||
None,
|
||||
super::PluginEvent::Notification {
|
||||
plugin_id: plugin.id().to_owned(),
|
||||
level: ide_plugin::NotificationLevel::Warning,
|
||||
title: format!("{} was disabled", plugin.manifest().name),
|
||||
body: format!("The marketplace revoked this plugin: {reason}"),
|
||||
},
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
{
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.status = PluginStatus::Inactive;
|
||||
meta.error = None;
|
||||
}
|
||||
if !self.state.lock().disabled.contains(plugin.id()) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read_signature(dir: &Path) -> Option<PackageSignature> {
|
||||
let text = std::fs::read_to_string(dir.join(SIGNATURE_FILE)).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// Keeps the signature of a package with its installed copy.
|
||||
pub(super) fn copy_signature(src: &Path, installed: &Path) {
|
||||
if src.join(SIGNATURE_FILE).is_file() {
|
||||
let _ = std::fs::copy(src.join(SIGNATURE_FILE), installed.join(SIGNATURE_FILE));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn downloads_resolve_against_the_index() {
|
||||
assert_eq!(resolve("https://x.dev/market/index.json", "p/a.oiplugin"), "https://x.dev/market/p/a.oiplugin");
|
||||
assert_eq!(
|
||||
Path::new(&resolve("/srv/market/index.json", "a.oiplugin")),
|
||||
Path::new("/srv/market").join("a.oiplugin")
|
||||
);
|
||||
#[cfg(windows)]
|
||||
assert_eq!(resolve(r"C:\market\index.json", "a.oiplugin"), r"C:\market\a.oiplugin");
|
||||
assert_eq!(resolve("https://x.dev/index.json", "https://cdn.dev/a"), "https://cdn.dev/a");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
//! First-party extension points registered by the host, plus
|
||||
//! [`register_builtin_points`], the one place that lists them. Feature crates
|
||||
//! own their points (`ide_language::plugin_point`); adding a contribution type
|
||||
//! means adding a point here or in its crate, and a `defineUiPoint` projection
|
||||
//! on the client for `Ui` points — nothing else.
|
||||
|
||||
use ide_extension::{ExtensionRegistry, Package, UiDecl, UiPoint, UiStore, decode, validate_item_id, validate_when};
|
||||
use ide_language::plugin_point::{LanguagesPoint, PluginLanguages};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const COMMANDS: &str = "commands";
|
||||
const MAX_TITLE: usize = 256;
|
||||
|
||||
/// `[[contributes.commands]]`: an action `plugin.<plugin-id>.<id>` in Search
|
||||
/// Everywhere and the keymap editor; shown before the plugin's code runs.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CommandDecl {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
/// Enablement, evaluated by the client against context keys (`editorLang == rust`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub when: Option<String>,
|
||||
}
|
||||
|
||||
fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let c: CommandDecl = decode(COMMANDS, decl)?;
|
||||
validate_item_id(&c.id, "command id")?;
|
||||
if c.title.trim().is_empty() {
|
||||
return Err(format!("command {:?} has an empty title", c.id));
|
||||
}
|
||||
if c.title.len() > MAX_TITLE || c.category.as_ref().is_some_and(|x| x.len() > MAX_TITLE) {
|
||||
return Err(format!("command {:?}: title or category longer than {MAX_TITLE} bytes", c.id));
|
||||
}
|
||||
if let Some(when) = &c.when {
|
||||
validate_when(when).map_err(|e| format!("command {:?}: {e}", c.id))?;
|
||||
}
|
||||
let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c };
|
||||
let json = serde_json::to_value(&c).map_err(|e| e.to_string())?;
|
||||
Ok(UiDecl::new(c.id.clone(), c.title, json))
|
||||
}
|
||||
|
||||
/// Registers every first-party extension point.
|
||||
pub fn register_builtin_points(
|
||||
registry: &ExtensionRegistry,
|
||||
ui: &Arc<UiStore>,
|
||||
languages: &Arc<PluginLanguages>,
|
||||
run_detectors: &Arc<super::run_detectors::RunDetectors>,
|
||||
providers: &Arc<super::providers::Providers>,
|
||||
) -> Result<(), String> {
|
||||
registry.register(Arc::new(UiPoint::new(COMMANDS, ui.clone(), command)))?;
|
||||
registry.register(Arc::new(super::theme::ThemesPoint::new(ui.clone())))?;
|
||||
registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?;
|
||||
registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?;
|
||||
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
|
||||
use super::ui_points as p;
|
||||
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::STATUS_BAR, ui.clone(), p::status_item)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Editor providers of plugins: completion, inline
|
||||
//! completion, code actions, go to definition and gutter line markers, for
|
||||
//! the languages a plugin declares. The host calls only plugins whose
|
||||
//! declaration matches, so the others stay unloaded.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.providers]]
|
||||
//! kinds = ["definition", "line-markers"]
|
||||
//! languages = ["java", "xml"] # editor language ids; "*" for any
|
||||
//! ```
|
||||
//!
|
||||
//! And `[[contributes.secrets]]`: secrets the user can store for the plugin
|
||||
//! (shown in Settings › Plugins), used only through `net` request headers.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.secrets]]
|
||||
//! name = "api-key"
|
||||
//! label = "API key"
|
||||
//! description = "Create one at https://example.com/keys"
|
||||
//! ```
|
||||
|
||||
use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub const POINT_ID: &str = "providers";
|
||||
pub const SECRETS_POINT: &str = "secrets";
|
||||
|
||||
/// What a plugin can provide in the editor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ProviderKind {
|
||||
Completion,
|
||||
InlineCompletion,
|
||||
CodeActions,
|
||||
Definition,
|
||||
LineMarkers,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
kinds: Vec<ProviderKind>,
|
||||
languages: Vec<String>,
|
||||
}
|
||||
|
||||
/// (sequence, plugin, kinds, languages) of one declaration.
|
||||
type Declared = (u64, String, Vec<ProviderKind>, Vec<String>);
|
||||
|
||||
/// Provider declarations of enabled plugins.
|
||||
#[derive(Default)]
|
||||
pub struct Providers {
|
||||
items: RwLock<Vec<Declared>>,
|
||||
seq: AtomicU64,
|
||||
}
|
||||
|
||||
impl Providers {
|
||||
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
|
||||
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
|
||||
let weak = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(this) = weak.upgrade() {
|
||||
this.items.write().retain(|(s, ..)| *s != seq);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Plugins providing `kind` for `language`, in declaration order.
|
||||
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (_, plugin, kinds, languages) in self.items.read().iter() {
|
||||
if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) {
|
||||
out.push(plugin.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether any plugin provides `kind` (for any language).
|
||||
pub fn any(&self, kind: ProviderKind) -> bool {
|
||||
self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProvidersPoint {
|
||||
store: Arc<Providers>,
|
||||
}
|
||||
|
||||
impl ProvidersPoint {
|
||||
pub fn new(store: Arc<Providers>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionPoint for ProvidersPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
POINT_ID
|
||||
}
|
||||
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Workspace
|
||||
}
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let d: Decl = decode(POINT_ID, decl)?;
|
||||
if pkg.manifest.wasm.is_none() {
|
||||
return Err("contributes.providers: providers need code (`wasm`)".into());
|
||||
}
|
||||
if d.kinds.is_empty() || d.languages.is_empty() || d.languages.len() > 64 {
|
||||
return Err("contributes.providers: give `kinds` and 1–64 `languages`".into());
|
||||
}
|
||||
for l in &d.languages {
|
||||
if l != "*"
|
||||
&& (l.is_empty()
|
||||
|| l.len() > 64
|
||||
|| !l.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c)))
|
||||
{
|
||||
return Err(format!("contributes.providers: invalid language id {l:?}"));
|
||||
}
|
||||
}
|
||||
let label = format!(
|
||||
"{} for {}",
|
||||
d.kinds
|
||||
.iter()
|
||||
.map(|k| serde_json::to_value(k).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
d.languages.join(", ")
|
||||
);
|
||||
let (store, plugin) = (self.store.clone(), pkg.id().to_owned());
|
||||
Ok(Prepared::new(label, move || Ok(store.add(plugin, d))))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SecretDecl {
|
||||
name: String,
|
||||
label: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
/// `[[contributes.secrets]]` (a UI point: Settings › Plugins lists them).
|
||||
pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: SecretDecl = decode(SECRETS_POINT, decl)?;
|
||||
validate_item_id(&d.name, "secret name")?;
|
||||
if d.label.trim().is_empty() || d.label.len() > 128 || d.description.as_ref().is_some_and(|x| x.len() > 1024) {
|
||||
return Err(format!("secret {:?}: `label` 1–128 bytes, `description` up to 1 KiB", d.name));
|
||||
}
|
||||
let json = serde_json::to_value(&d).map_err(|e| e.to_string())?;
|
||||
Ok(UiDecl::new(d.name.clone(), d.label, json))
|
||||
}
|
||||
|
||||
// ---- dispatch ---------------------------------------------------------------
|
||||
|
||||
use super::{LoadedPlugin, PluginHost, PluginStatus};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::{
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
|
||||
ProviderDocument, Runtime,
|
||||
};
|
||||
use std::sync::Weak;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a superseded inline completion may take to return after its
|
||||
/// request was cancelled before it is interrupted.
|
||||
const CANCEL_GRACE: Duration = Duration::from_millis(250);
|
||||
|
||||
static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
|
||||
|
||||
/// Makes `host`'s plugin providers part of completion, code actions, go to
|
||||
/// definition and inline completion (`None` removes them).
|
||||
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
|
||||
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
|
||||
}
|
||||
|
||||
/// The plugin host whose providers take part, when any declares `kind`.
|
||||
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
|
||||
let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?;
|
||||
host.providers.any(kind).then_some(host)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Usable plugins declaring `kind` for `language`.
|
||||
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
|
||||
self.providers
|
||||
.plugins_for(kind, language)
|
||||
.into_iter()
|
||||
.filter_map(|id| self.get(&id).ok())
|
||||
.filter(|p| {
|
||||
let m = p.meta.lock();
|
||||
matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Edit-path calls (soft / hard deadline) to every usable plugin that
|
||||
/// declares `kind` for `language`, in plugin order, leaving out those that
|
||||
/// failed or were late. Each plugin runs on its own thread, so the calls
|
||||
/// run at the same time: the wait is the slowest plugin's, not the sum.
|
||||
fn edit_calls<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
language: &str,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard);
|
||||
let queued: Vec<_> = self
|
||||
.provider_plugins(kind, language)
|
||||
.into_iter()
|
||||
.filter_map(|plugin| {
|
||||
let (actor, activated) = self.ensure_active(&plugin, None).ok()?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let f = f.clone();
|
||||
let started = Instant::now();
|
||||
let call = actor.submit(Some(soft), move |rt| f(rt, hard));
|
||||
Some((plugin, actor, started, call))
|
||||
})
|
||||
.collect();
|
||||
queued
|
||||
.into_iter()
|
||||
.filter_map(|(plugin, actor, started, call)| {
|
||||
let result = call.and_then(Pending::wait);
|
||||
self.finish_provider_call(&plugin, &actor, started, result).map(|r| (plugin, r))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn finish_provider_call<R>(
|
||||
&self,
|
||||
plugin: &Arc<LoadedPlugin>,
|
||||
actor: &Arc<ide_plugin::Actor>,
|
||||
started: Instant,
|
||||
result: Result<R, ActorError>,
|
||||
) -> Option<R> {
|
||||
let elapsed = started.elapsed();
|
||||
match result {
|
||||
Ok(r) => {
|
||||
self.record_call(plugin, elapsed, true, true);
|
||||
Some(r)
|
||||
}
|
||||
Err(ActorError::SoftTimeout) => {
|
||||
plugin.meta.lock().stats.slow_calls += 1;
|
||||
None
|
||||
}
|
||||
Err(ActorError::Cancelled) => None,
|
||||
Err(ActorError::Call(CallError::Guest(e))) => {
|
||||
self.record_call(plugin, elapsed, !e.is_failure(), 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 (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_code_actions(
|
||||
&self,
|
||||
doc: &ProviderDocument,
|
||||
selection: ide_core::Range,
|
||||
) -> Vec<(String, CodeAction)> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::CodeActions, &doc.language, move |rt, timeout| {
|
||||
rt.provide_code_actions(&d, selection, timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(p, actions)| actions.into_iter().map(move |a| (p.id().to_owned(), a)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
|
||||
rt.provide_definition(&d, at, timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(_, locations)| locations)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_line_markers(&self, doc: &ProviderDocument) -> Vec<(String, LineMarker)> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::LineMarkers, &doc.language, move |rt, timeout| {
|
||||
rt.provide_line_markers(&d, timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(p, markers)| markers.into_iter().map(move |m| (p.id().to_owned(), m)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ghost text from the first plugin that has some. Not bound by the
|
||||
/// edit-path soft deadline (it may need the network) but by `cancel`:
|
||||
/// the editor cancels a request as soon as the user types on.
|
||||
pub fn provide_inline_completion(
|
||||
&self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
cancel: &CancellationToken,
|
||||
) -> Option<(String, InlineSuggestion)> {
|
||||
for p in self.provider_plugins(ProviderKind::InlineCompletion, &doc.language) {
|
||||
if cancel.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let Ok((actor, activated)) = self.ensure_active(&p, None) else { continue };
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let (d, timeout) = (doc.clone(), self.limits.timeout);
|
||||
let started = Instant::now();
|
||||
let result =
|
||||
actor.call_cancellable(cancel, CANCEL_GRACE, move |rt| rt.provide_inline_completion(&d, at, timeout));
|
||||
if let Some(Some(s)) = self.finish_provider_call(&p, &actor, started, result) {
|
||||
return Some((p.id().to_owned(), s));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Plugin languages of a remote project: `Workspace` contributions take effect where the project is, so the
|
||||
//! desktop pushes its plugins' language definitions to `openidea-server`
|
||||
//! (`plugin_sync_languages`) together with the `process:` programs the user
|
||||
//! granted. The server resolves each granted program on its own `PATH`, pins
|
||||
//! the file (path + hash, kept in `plugin-pins.json`) the first time and
|
||||
//! starts only that file afterwards; a program missing from a sync is revoked.
|
||||
//! Revoking and granting again on the desktop re-pins a replaced program.
|
||||
|
||||
use ide_extension::Capability;
|
||||
use ide_extension::ledger::{self, BinaryGrant};
|
||||
use ide_language::plugin_point::{PluginLanguages, validate_definition};
|
||||
use ide_language::{LanguageConfig, ProgramPin};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const PINS_FILE: &str = "plugin-pins.json";
|
||||
/// The server command the desktop calls.
|
||||
pub const SYNC_COMMAND: &str = "plugin_sync_languages";
|
||||
|
||||
/// One plugin language as the desktop sends it.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteLanguage {
|
||||
pub plugin: String,
|
||||
pub language: LanguageConfig,
|
||||
/// Program names of the plugin's granted `process:` capabilities.
|
||||
#[serde(default)]
|
||||
pub granted: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the server made of a sync.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncReport {
|
||||
pub languages: usize,
|
||||
/// `plugin: program` → the file it starts.
|
||||
pub pinned: BTreeMap<String, PathBuf>,
|
||||
/// Granted programs not found on this machine's `PATH`.
|
||||
pub unresolved: Vec<String>,
|
||||
/// Definitions that failed validation (not applied).
|
||||
pub rejected: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct PinsFile {
|
||||
/// `plugin\nprocess:program` → the approved file.
|
||||
#[serde(default)]
|
||||
pins: BTreeMap<String, BinaryGrant>,
|
||||
}
|
||||
|
||||
type PathVar = Arc<dyn Fn() -> Option<std::ffi::OsString> + Send + Sync>;
|
||||
|
||||
/// The server's side: an overlay for its language registry, filled by syncs.
|
||||
pub struct RemotePluginLanguages {
|
||||
store: Arc<PluginLanguages>,
|
||||
pins: Arc<Mutex<BTreeMap<String, BinaryGrant>>>,
|
||||
file: Option<PathBuf>,
|
||||
applied: Mutex<Vec<ide_extension::Disposable>>,
|
||||
path_var: PathVar,
|
||||
}
|
||||
|
||||
fn key(plugin: &str, cap: &Capability) -> String {
|
||||
format!("{plugin}\n{cap}")
|
||||
}
|
||||
|
||||
impl RemotePluginLanguages {
|
||||
/// Pins persist in `data_dir` (none: in memory, for tests).
|
||||
pub fn new(data_dir: Option<&std::path::Path>) -> Arc<Self> {
|
||||
Self::with_path(data_dir, Arc::new(|| std::env::var_os("PATH")))
|
||||
}
|
||||
|
||||
pub fn with_path(data_dir: Option<&std::path::Path>, path_var: PathVar) -> Arc<Self> {
|
||||
let file = data_dir.map(|d| d.join(PINS_FILE));
|
||||
let saved: PinsFile = file
|
||||
.as_ref()
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let pins = Arc::new(Mutex::new(saved.pins));
|
||||
let store = PluginLanguages::new();
|
||||
let policy_pins = pins.clone();
|
||||
store.set_process_policy(Arc::new(move |plugin, cap| {
|
||||
policy_pins.lock().get(&key(plugin, cap)).map(|b| ProgramPin { path: b.path.clone(), hash: b.hash.clone() })
|
||||
}));
|
||||
Arc::new(Self { store, pins, file, applied: Mutex::default(), path_var })
|
||||
}
|
||||
|
||||
/// For [`ide_language::LanguageRegistry::attach_plugins`].
|
||||
pub fn store(&self) -> &Arc<PluginLanguages> {
|
||||
&self.store
|
||||
}
|
||||
|
||||
/// Replaces every plugin language with `languages`.
|
||||
pub fn sync(&self, languages: Vec<RemoteLanguage>) -> SyncReport {
|
||||
let mut report = SyncReport::default();
|
||||
let mut wanted: BTreeMap<String, (String, Capability)> = BTreeMap::new();
|
||||
let mut accepted = Vec::new();
|
||||
for l in languages {
|
||||
if let Err(e) = validate_definition(&l.language) {
|
||||
report.rejected.push(format!("{} ({}): {e}", l.language.id, l.plugin));
|
||||
continue;
|
||||
}
|
||||
for program in &l.granted {
|
||||
let cap = Capability::Process(program.clone());
|
||||
// Only programs the definition starts: a grant is not a licence for anything else.
|
||||
if ide_language::plugin_point::required_capabilities(&l.language).contains(&cap) {
|
||||
wanted.insert(key(&l.plugin, &cap), (program.clone(), cap));
|
||||
}
|
||||
}
|
||||
accepted.push(l);
|
||||
}
|
||||
{
|
||||
let mut pins = self.pins.lock();
|
||||
pins.retain(|k, _| wanted.contains_key(k));
|
||||
for (k, (program, _)) in &wanted {
|
||||
if pins.contains_key(k) {
|
||||
continue;
|
||||
}
|
||||
let resolved = ledger::resolve_program(program, (self.path_var)().as_deref()).and_then(|path| {
|
||||
ledger::file_hash(&path).map(|hash| BinaryGrant { path, hash }).map_err(|e| e.to_string())
|
||||
});
|
||||
match resolved {
|
||||
Ok(bin) => {
|
||||
pins.insert(k.clone(), bin);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!("process:{program}: {e}");
|
||||
report.unresolved.push(program.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, bin) in pins.iter() {
|
||||
report.pinned.insert(k.replace("\nprocess:", ": "), bin.path.clone());
|
||||
}
|
||||
self.save(&pins);
|
||||
}
|
||||
let mut applied = self.applied.lock();
|
||||
applied.clear(); // disposes the previous sync's languages
|
||||
for l in accepted {
|
||||
applied.push(self.store.add(l.plugin, l.language));
|
||||
}
|
||||
report.languages = applied.len();
|
||||
self.store.invalidate();
|
||||
report
|
||||
}
|
||||
|
||||
fn save(&self, pins: &BTreeMap<String, BinaryGrant>) {
|
||||
let Some(file) = &self.file else { return };
|
||||
let json = serde_json::to_string_pretty(&PinsFile { pins: pins.clone() }).unwrap_or_default();
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, json).and_then(|()| std::fs::rename(&tmp, file)) {
|
||||
tracing::warn!("saving {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::LanguageId;
|
||||
use ide_language::LanguageRegistry;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn toy(granted: &[&str]) -> RemoteLanguage {
|
||||
let language = ide_language::plugin_point::parse_definition(
|
||||
"id = \"toy\"\nname = \"Toy\"\nextensions = [\"toy\"]\n\
|
||||
[[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
RemoteLanguage {
|
||||
plugin: "acme.toy".into(),
|
||||
language,
|
||||
granted: granted.iter().map(|s| (*s).to_owned()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syncs_languages_and_pins_granted_programs_on_this_machine() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = tmp.path().join("bin");
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
std::fs::write(bin.join("toylsp"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(bin.join("toylsp"), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path = bin.clone().into_os_string();
|
||||
let remote = RemotePluginLanguages::with_path(Some(tmp.path()), Arc::new(move || Some(path.clone())));
|
||||
let registry = LanguageRegistry::load(None).unwrap();
|
||||
registry.attach_plugins(remote.store().clone());
|
||||
let project = registry.fork();
|
||||
let id = LanguageId::new("toy");
|
||||
|
||||
// Not granted: the language, no server.
|
||||
let report = remote.sync(vec![toy(&[])]);
|
||||
assert_eq!(report.languages, 1);
|
||||
assert_eq!(project.detect(std::path::Path::new("/r/a.toy"), None), id);
|
||||
assert!(project.get(&id).unwrap().language_servers.is_empty());
|
||||
|
||||
// Granted: resolved here and pinned; persisted.
|
||||
let report = remote.sync(vec![toy(&["toylsp", "rm"])]);
|
||||
assert_eq!(report.pinned.len(), 1, "only programs the definition starts: {report:?}");
|
||||
let pin = project.get(&id).unwrap().language_servers[0].pinned.clone().unwrap();
|
||||
assert!(pin.verify().is_ok());
|
||||
assert!(tmp.path().join(PINS_FILE).is_file());
|
||||
|
||||
// Replaced: refused at start until revoked and granted again.
|
||||
std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho x\n").unwrap();
|
||||
remote.sync(vec![toy(&["toylsp"])]);
|
||||
assert!(project.get(&id).unwrap().language_servers[0].pinned.as_ref().unwrap().verify().is_err());
|
||||
remote.sync(vec![toy(&[])]);
|
||||
remote.sync(vec![toy(&["toylsp"])]);
|
||||
assert!(project.get(&id).unwrap().language_servers[0].pinned.as_ref().unwrap().verify().is_ok());
|
||||
|
||||
// Missing programs are reported; invalid definitions rejected; empty sync removes all.
|
||||
let mut bad = toy(&[]);
|
||||
bad.language.id = LanguageId::new("a b");
|
||||
let report = remote.sync(vec![bad]);
|
||||
assert_eq!(report.rejected.len(), 1);
|
||||
assert_eq!(project.detect(std::path::Path::new("/r/a.toy"), None), LanguageId::plain_text());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
//! Code plugins of remote projects:
|
||||
//! Wasm runs where the project is, so `openidea-server` hosts the desktop's
|
||||
//! code plugins and they read, search, run processes and publish diagnostics
|
||||
//! on the remote machine.
|
||||
//!
|
||||
//! The desktop stays the authority over what is installed and granted. It
|
||||
//! pushes its live code plugins with [`SYNC_PACKAGES`]: first their digests
|
||||
//! and grants, then the files of those the server doesn't have (one package
|
||||
//! per request); a package missing from a sync is uninstalled. The server
|
||||
//! installs them under `<data>/remote-plugins` and records the desktop's
|
||||
//! grants (a `process:` grant is pinned to the program on this machine's
|
||||
//! `PATH`).
|
||||
//!
|
||||
//! The commands a window needs at run time (`plugin_run_command`,
|
||||
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
|
||||
//! `plugin_cancel_task`, `plugin_set_active_file`, `plugin_line_markers`,
|
||||
//! `plugin_view_render`, `plugin_view_event`)
|
||||
//! carry the desktop commands' names and arguments, so a remote window's UI
|
||||
//! routes them here unchanged. Plugin results come back as `plugin`
|
||||
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
|
||||
//! keychain and are asked for with a `plugin_secret` request
|
||||
//! ([`SecretRequest`]) when a request header needs one (rule 4).
|
||||
|
||||
use super::{
|
||||
Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore,
|
||||
};
|
||||
use crate::rpc::Dispatcher;
|
||||
use base64::Engine as _;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::{MANIFEST_FILE, ledger};
|
||||
use ide_jsonrpc::Peer;
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The server command the desktop calls with its packages.
|
||||
pub const SYNC_PACKAGES: &str = "plugin_sync_packages";
|
||||
/// Server → client notification carrying a [`PluginEventMessage`].
|
||||
pub const EVENT_NOTIFICATION: &str = "plugin";
|
||||
/// Server → client request ([`SecretRequest`] → `Option<String>`).
|
||||
pub const SECRET_REQUEST: &str = "plugin_secret";
|
||||
/// Under the server's data directory.
|
||||
pub const PLUGINS_DIR: &str = "remote-plugins";
|
||||
const INCOMING_DIR: &str = "remote-plugins.incoming";
|
||||
const SECRET_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const EVENT_FLUSH: Duration = Duration::from_millis(250);
|
||||
const TICKS_PER_SUPERVISION: u32 = 20;
|
||||
|
||||
/// One live code plugin of the desktop.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemotePackage {
|
||||
pub id: String,
|
||||
/// [`package_digest`] of the package.
|
||||
pub digest: String,
|
||||
/// Capabilities the user granted on the desktop.
|
||||
pub granted: Vec<String>,
|
||||
}
|
||||
|
||||
/// One file of a package (`path` relative to the package, `data` base64).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PackageFile {
|
||||
pub path: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackageSyncReport {
|
||||
/// Packages the server needs the files of (send them in another sync).
|
||||
pub missing: Vec<String>,
|
||||
pub installed: Vec<String>,
|
||||
pub removed: Vec<String>,
|
||||
/// `id: reason`.
|
||||
pub failed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PluginEventMessage {
|
||||
/// The window that invoked the plugin (`None`: every window of the connection).
|
||||
pub window: Option<String>,
|
||||
pub event: PluginEvent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SecretRequest {
|
||||
pub plugin: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Identifies a package's content: the manifest, the files its declarations
|
||||
/// reference and the component.
|
||||
pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
|
||||
let mut all = files.to_vec();
|
||||
all.push(PathBuf::from(MANIFEST_FILE));
|
||||
Ok(ledger::package_hash(dir, &all)?)
|
||||
}
|
||||
|
||||
/// Whether a plugin event means something to a remote window (the desktop
|
||||
/// owns the plugin list, themes and consent).
|
||||
pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
PluginEvent::Notification { .. }
|
||||
| PluginEvent::StatusItemChanged { .. }
|
||||
| PluginEvent::TaskStarted { .. }
|
||||
| PluginEvent::TaskProgress { .. }
|
||||
| PluginEvent::TaskEnded { .. }
|
||||
| PluginEvent::SubscriptionsChanged
|
||||
| PluginEvent::ViewChanged { .. }
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
)
|
||||
}
|
||||
|
||||
// ---- desktop side -----------------------------------------------------------
|
||||
|
||||
impl PluginHost {
|
||||
fn package_files(&self, plugin: &super::LoadedPlugin) -> IdeResult<Vec<PathBuf>> {
|
||||
let set = self.registry.prepare(&plugin.pkg).map_err(IdeError::invalid)?;
|
||||
Ok(set.files.into_iter().chain(plugin.manifest().wasm.clone()).collect())
|
||||
}
|
||||
|
||||
/// The live code plugins (installed or linked) for a remote project's server.
|
||||
pub fn remote_packages(&self) -> Vec<RemotePackage> {
|
||||
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
|
||||
plugins
|
||||
.iter()
|
||||
.filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some())
|
||||
.filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive))
|
||||
.filter_map(|p| {
|
||||
let files = self.package_files(p).ok()?;
|
||||
let digest = package_digest(p.dir(), &files).ok()?;
|
||||
let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect();
|
||||
Some(RemotePackage { id: p.id().to_owned(), digest, granted })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The files of a package for [`SYNC_PACKAGES`], the manifest first.
|
||||
pub fn remote_package_files(&self, id: &str) -> IdeResult<Vec<PackageFile>> {
|
||||
let plugin = self.get(id)?;
|
||||
let files = self.package_files(&plugin)?;
|
||||
std::iter::once(PathBuf::from(MANIFEST_FILE))
|
||||
.chain(files)
|
||||
.map(|rel| {
|
||||
let bytes = std::fs::read(plugin.dir().join(&rel))?;
|
||||
let path = rel.to_str().ok_or_else(|| IdeError::invalid("non UTF-8 file name"))?.replace('\\', "/");
|
||||
Ok(PackageFile { path, data: base64::engine::general_purpose::STANDARD.encode(bytes) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A secret a plugin declares, for its remote instance (`plugin_secret`).
|
||||
pub fn declared_secret(&self, id: &str, name: &str) -> Option<String> {
|
||||
let plugin = self.get(id).ok()?;
|
||||
if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == name) {
|
||||
return None;
|
||||
}
|
||||
let store = self.shared.secrets.read().clone();
|
||||
store.get(plugin.id(), name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- server side ------------------------------------------------------------
|
||||
|
||||
/// The plugin host of `openidea-server`.
|
||||
pub struct RemotePlugins {
|
||||
host: Arc<PluginHost>,
|
||||
incoming: PathBuf,
|
||||
sync: Mutex<()>,
|
||||
}
|
||||
|
||||
/// Secrets from the desktop's keychain, asked for when used.
|
||||
struct ClientSecrets {
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
impl SecretStore for ClientSecrets {
|
||||
fn get(&self, plugin: &str, name: &str) -> Option<String> {
|
||||
let peer = self.peer.get()?.clone();
|
||||
let request = SecretRequest { plugin: plugin.to_owned(), name: name.to_owned() };
|
||||
let ask = async move {
|
||||
tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Option<String>>(SECRET_REQUEST, request, None)).await
|
||||
};
|
||||
// Plugin threads block on the runtime; a runtime worker (a host
|
||||
// command asking) must leave its thread to the other tasks first.
|
||||
let answer = if tokio::runtime::Handle::try_current().is_ok() {
|
||||
tokio::task::block_in_place(|| self.runtime.block_on(ask))
|
||||
} else {
|
||||
self.runtime.block_on(ask)
|
||||
};
|
||||
match answer {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("plugin secret {plugin}/{name}: {e}");
|
||||
None
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
|
||||
Err("secrets are stored on the desktop".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl RemotePlugins {
|
||||
/// Creates the host over `<data_dir>/remote-plugins`, loads what earlier
|
||||
/// sessions installed and starts event delivery and supervision.
|
||||
pub fn start(data_dir: &Path, projects: &Arc<crate::Host>, peer: Arc<OnceLock<Peer>>) -> IdeResult<Arc<Self>> {
|
||||
let sink_peer = peer.clone();
|
||||
let sink = move |client: Option<&str>, event: PluginEvent| {
|
||||
if forwarded(&event)
|
||||
&& let Some(p) = sink_peer.get()
|
||||
{
|
||||
let _ = p.notify(EVENT_NOTIFICATION, PluginEventMessage { window: client.map(str::to_owned), event });
|
||||
}
|
||||
};
|
||||
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
|
||||
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
|
||||
host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() }));
|
||||
let trust = Arc::downgrade(projects);
|
||||
host.set_project_policy(Arc::new(move |root| {
|
||||
trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false))
|
||||
}));
|
||||
super::set_editor_providers(Some(&host));
|
||||
ide_language::set_external_syntax(Some(host.syntax_provider()));
|
||||
ide_run::set_external_detection(Some(host.run_detection()));
|
||||
host.load_all();
|
||||
host.startup_finished();
|
||||
spawn_supervision(Arc::downgrade(&host))?;
|
||||
Ok(Arc::new(Self { host, incoming: data_dir.join(INCOMING_DIR), sync: Mutex::new(()) }))
|
||||
}
|
||||
|
||||
pub fn host(&self) -> &Arc<PluginHost> {
|
||||
&self.host
|
||||
}
|
||||
|
||||
/// Makes the installed packages match `packages`: uninstalls the others,
|
||||
/// follows changed grants, installs those with `files` and reports the
|
||||
/// rest as missing.
|
||||
pub fn sync(
|
||||
&self,
|
||||
packages: Vec<RemotePackage>,
|
||||
mut files: BTreeMap<String, Vec<PackageFile>>,
|
||||
) -> PackageSyncReport {
|
||||
let _guard = self.sync.lock();
|
||||
let mut report = PackageSyncReport::default();
|
||||
let wanted: BTreeSet<&str> = packages.iter().map(|p| p.id.as_str()).collect();
|
||||
let installed: Vec<String> = self
|
||||
.host
|
||||
.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.source == PluginSource::Installed)
|
||||
.map(|p| p.id().to_owned())
|
||||
.collect();
|
||||
for id in installed.iter().filter(|id| !wanted.contains(id.as_str())) {
|
||||
match self.host.uninstall(id) {
|
||||
Ok(()) => report.removed.push(id.clone()),
|
||||
Err(e) => report.failed.push(format!("{id}: {e}")),
|
||||
}
|
||||
}
|
||||
for pkg in packages {
|
||||
if !valid_id(&pkg.id) {
|
||||
report.failed.push(format!("{}: invalid plugin id", pkg.id));
|
||||
continue;
|
||||
}
|
||||
let consent = Consent { optional: pkg.granted.clone() };
|
||||
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
|
||||
if let Err(e) = self.follow_grants(&pkg, &consent) {
|
||||
report.failed.push(format!("{}: {e}", pkg.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match files.remove(&pkg.id) {
|
||||
None => report.missing.push(pkg.id),
|
||||
Some(list) => match self.install(&pkg, list, &consent) {
|
||||
Ok(()) => report.installed.push(pkg.id),
|
||||
Err(e) => report.failed.push(format!("{}: {}", pkg.id, e.message)),
|
||||
},
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
fn current_digest(&self, id: &str) -> Option<String> {
|
||||
let plugin = self.host.plugins.read().get(id).cloned()?;
|
||||
if plugin.source != PluginSource::Installed || plugin.invalid {
|
||||
return None;
|
||||
}
|
||||
let files = self.host.package_files(&plugin).ok()?;
|
||||
package_digest(plugin.dir(), &files).ok()
|
||||
}
|
||||
|
||||
fn follow_grants(&self, pkg: &RemotePackage, consent: &Consent) -> IdeResult<()> {
|
||||
let info = self.host.info_of(&pkg.id)?;
|
||||
let have: BTreeSet<&str> = info.capabilities.iter().filter(|c| c.granted).map(|c| c.id.as_str()).collect();
|
||||
let want: BTreeSet<&str> = pkg.granted.iter().map(String::as_str).collect();
|
||||
if have != want || info.status == super::PluginStatus::NeedsConsent {
|
||||
self.host.grant(&pkg.id, consent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install(&self, pkg: &RemotePackage, files: Vec<PackageFile>, consent: &Consent) -> IdeResult<()> {
|
||||
let dir = self.incoming.join(&pkg.id);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let result = (|| {
|
||||
let mut rels = Vec::new();
|
||||
for f in files {
|
||||
let rel = safe_relative(&f.path)?;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(f.data.as_bytes())
|
||||
.map_err(|e| IdeError::invalid(format!("{}: {e}", f.path)))?;
|
||||
let to = dir.join(&rel);
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&to, bytes)?;
|
||||
if rel != Path::new(MANIFEST_FILE) {
|
||||
rels.push(rel);
|
||||
}
|
||||
}
|
||||
if package_digest(&dir, &rels)? != pkg.digest {
|
||||
return Err(IdeError::invalid("the package arrived incomplete or changed"));
|
||||
}
|
||||
let info = self.host.install_from_dir(&dir, consent)?;
|
||||
if info.id != pkg.id {
|
||||
let _ = self.host.uninstall(&info.id);
|
||||
return Err(IdeError::invalid(format!("the package is plugin {:?}", info.id)));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
/// The run-time commands of remote windows plus [`SYNC_PACKAGES`].
|
||||
pub fn register(self: &Arc<Self>, d: &mut Dispatcher) {
|
||||
let this = self.clone();
|
||||
d.add(SYNC_PACKAGES, move |c| {
|
||||
let this = this.clone();
|
||||
async move {
|
||||
let packages: Vec<RemotePackage> = c.arg("packages")?;
|
||||
let files: Option<BTreeMap<String, Vec<PackageFile>>> = c.arg("files")?;
|
||||
blocking(move || Ok(this.sync(packages, files.unwrap_or_default()))).await
|
||||
}
|
||||
});
|
||||
let host = Arc::downgrade(&self.host);
|
||||
let h = host.clone();
|
||||
d.add("plugin_run_command", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let plugin_id: String = c.arg("pluginId")?;
|
||||
let command_id: String = c.arg("commandId")?;
|
||||
let arg: Option<String> = c.arg("arg")?;
|
||||
let editor: Option<PluginEditor> = c.arg("editor")?;
|
||||
let invocation = Invocation {
|
||||
client: Some(c.window.clone()),
|
||||
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
|
||||
editor: editor.map(PluginEditor::context),
|
||||
};
|
||||
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_post_events", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
alive(&h)?.post_events(Some(&c.window), c.arg("events")?);
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_event_kinds", move |_| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.event_kinds().into_iter().collect::<Vec<_>>()) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_status_items", move |_| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
Ok(alive(&h)?
|
||||
.status_items()
|
||||
.into_iter()
|
||||
.map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item })
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_cancel_task", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.cancel_task(&c.arg::<String>("taskId")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_active_file", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
alive(&h)?.set_active_file(&c.window, c.arg("path")?);
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_view_render", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
super::views::render(
|
||||
&c.host,
|
||||
plugins,
|
||||
&c.window,
|
||||
c.arg("pluginId")?,
|
||||
c.arg("view")?,
|
||||
c.arg("viewport")?,
|
||||
c.arg("doc")?,
|
||||
c.arg("editor")?,
|
||||
)
|
||||
.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")?,
|
||||
c.arg("editor")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
d.add("plugin_line_markers", |c| async move {
|
||||
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn alive(host: &Weak<PluginHost>) -> IdeResult<Arc<PluginHost>> {
|
||||
host.upgrade().ok_or_else(|| IdeError::unavailable("the plugin host has shut down"))
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
fn spawn_supervision(host: Weak<PluginHost>) -> IdeResult<()> {
|
||||
std::thread::Builder::new().name("remote-plugins".into()).spawn(move || {
|
||||
for n in 1u32.. {
|
||||
std::thread::sleep(EVENT_FLUSH);
|
||||
let Some(host) = host.upgrade() else { return };
|
||||
host.flush_events();
|
||||
if n % TICKS_PER_SUPERVISION == 0 {
|
||||
host.tick();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& !id.starts_with('.')
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
|
||||
}
|
||||
|
||||
/// `path` as a relative path inside a package (no `..`, no root).
|
||||
fn safe_relative(path: &str) -> IdeResult<PathBuf> {
|
||||
let p = Path::new(path);
|
||||
if path.is_empty() || !p.components().all(|c| matches!(c, Component::Normal(_))) {
|
||||
return Err(IdeError::invalid(format!("invalid file path {path:?} in package")));
|
||||
}
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn package_paths_stay_inside_the_package() {
|
||||
assert!(safe_relative("dist/plugin.wasm").is_ok());
|
||||
for bad in ["", "../x", "/etc/passwd", "a/../../b", "./x"] {
|
||||
assert!(safe_relative(bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(valid_id("ai-completion"));
|
||||
assert!(!valid_id("../x") && !valid_id(".data") && !valid_id(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! The `runDetectors` extension point: run configurations a plugin offers in
|
||||
//! projects that contain a marker file.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.runDetectors]]
|
||||
//! id = "forge-test"
|
||||
//! name = "forge test"
|
||||
//! files = ["foundry.toml"] # any of these at the project root
|
||||
//! command = "forge"
|
||||
//! args = ["test"]
|
||||
//! test = true # results go to the test tree
|
||||
//! ```
|
||||
//!
|
||||
//! The command is a `process:` capability like a language server's: the
|
||||
//! configuration is offered only while it is granted, and starts the approved
|
||||
//! file (checked right before it starts).
|
||||
|
||||
use ide_extension::{
|
||||
Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, check_relative, decode, validate_item_id,
|
||||
};
|
||||
use ide_language::ProgramPin;
|
||||
use ide_run::{EXTERNAL_PREFIX, ExternalDetection, RunConfig};
|
||||
use parking_lot::RwLock;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub const POINT_ID: &str = "runDetectors";
|
||||
const MAX_FILES: usize = 16;
|
||||
const MAX_ARGS: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Detector {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub files: Vec<PathBuf>,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub test: bool,
|
||||
}
|
||||
|
||||
/// Detectors of enabled plugins.
|
||||
#[derive(Default)]
|
||||
pub struct RunDetectors {
|
||||
items: RwLock<Vec<(u64, String, Detector)>>,
|
||||
seq: AtomicU64,
|
||||
}
|
||||
|
||||
impl RunDetectors {
|
||||
fn add(self: &Arc<Self>, plugin: String, detector: Detector) -> Disposable {
|
||||
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
self.items.write().push((seq, plugin, detector));
|
||||
let weak = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(this) = weak.upgrade() {
|
||||
this.items.write().retain(|(s, _, _)| *s != seq);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RunDetectorsPoint {
|
||||
store: Arc<RunDetectors>,
|
||||
}
|
||||
|
||||
impl RunDetectorsPoint {
|
||||
pub fn new(store: Arc<RunDetectors>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionPoint for RunDetectorsPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
POINT_ID
|
||||
}
|
||||
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Workspace
|
||||
}
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let d: Detector = decode(POINT_ID, decl)?;
|
||||
validate_item_id(&d.id, "run detector id")?;
|
||||
if d.name.trim().is_empty() || d.name.len() > 128 {
|
||||
return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id));
|
||||
}
|
||||
if d.files.is_empty() || d.files.len() > MAX_FILES {
|
||||
return Err(format!("run detector {:?}: give 1–{MAX_FILES} marker `files`", d.id));
|
||||
}
|
||||
for f in &d.files {
|
||||
check_relative(f, "files")?;
|
||||
}
|
||||
let program = d.command.trim();
|
||||
if program.is_empty() || program.contains(char::is_whitespace) {
|
||||
return Err(format!("run detector {:?}: `command` is one program name (arguments go in `args`)", d.id));
|
||||
}
|
||||
if d.args.len() > MAX_ARGS {
|
||||
return Err(format!("run detector {:?}: at most {MAX_ARGS} arguments", d.id));
|
||||
}
|
||||
let cap = Capability::Process(program.to_owned());
|
||||
let (store, plugin, label) = (self.store.clone(), pkg.id().to_owned(), d.name.clone());
|
||||
let key = d.id.clone();
|
||||
Ok(Prepared::new(label, move || Ok(store.add(plugin, d))).key(key).capability(cap))
|
||||
}
|
||||
}
|
||||
|
||||
type Pins = Arc<RwLock<HashMap<(String, String), ProgramPin>>>;
|
||||
|
||||
/// [`ExternalDetection`] over the detectors and the approved programs.
|
||||
pub struct PluginRunDetection {
|
||||
pub(crate) store: Arc<RunDetectors>,
|
||||
pub(crate) pins: Pins,
|
||||
}
|
||||
|
||||
fn config_id(plugin: &str, detector: &str) -> String {
|
||||
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
|
||||
}
|
||||
|
||||
impl PluginRunDetection {
|
||||
fn pin(&self, plugin: &str, d: &Detector) -> Option<ProgramPin> {
|
||||
let cap = Capability::Process(d.command.trim().to_owned());
|
||||
self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalDetection for PluginRunDetection {
|
||||
fn detect(&self, root: &Path) -> Vec<RunConfig> {
|
||||
let items = self.store.items.read().clone();
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists()))
|
||||
.map(|(_, plugin, d)| RunConfig {
|
||||
id: config_id(&plugin, &d.id),
|
||||
name: d.name.clone(),
|
||||
kind: "plugin".into(),
|
||||
command: d.command.trim().to_owned(),
|
||||
args: d.args.clone(),
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: d.test,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String> {
|
||||
let items = self.store.items.read();
|
||||
let (plugin, d) = items
|
||||
.iter()
|
||||
.find(|(_, plugin, d)| config_id(plugin, &d.id) == config.id)
|
||||
.map(|(_, p, d)| (p.clone(), d.clone()))
|
||||
.ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?;
|
||||
drop(items);
|
||||
let pin = self.pin(&plugin, &d).ok_or_else(|| {
|
||||
format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command)
|
||||
})?;
|
||||
pin.verify().map(Path::to_path_buf)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
//! Theme contributions: design-token overrides applied as CSS variables on `:root`.
|
||||
//! Theme and editor-scheme files are JSON that may contain `//` and `/* */` comments.
|
||||
//! The `themes` extension point: design-token overrides applied as CSS
|
||||
//! variables on `:root`. Theme and editor-scheme files are JSON that may
|
||||
//! contain `//` and `/* */` comments.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.themes]]
|
||||
//! path = "themes/harbor-night.json"
|
||||
//! editor-scheme = "themes/harbor-night.scheme.json" # optional
|
||||
//! ```
|
||||
|
||||
use ide_extension::{ExtensionPoint, Package, Placement, Prepared, UiContribution, UiStore, decode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const POINT_ID: &str = "themes";
|
||||
|
||||
const MAX_TOKENS: usize = 512;
|
||||
const MAX_VALUE_LEN: usize = 256;
|
||||
@@ -48,7 +60,7 @@ impl ThemeDef {
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
crate::manifest::validate_command_id(&self.id).map_err(|e| e.replace("command id", "theme id"))?;
|
||||
ide_extension::validate_item_id(&self.id, "theme id")?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("theme name must not be empty".into());
|
||||
}
|
||||
@@ -71,6 +83,55 @@ impl ThemeDef {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ThemesPoint {
|
||||
store: Arc<UiStore>,
|
||||
}
|
||||
|
||||
impl ThemesPoint {
|
||||
pub fn new(store: Arc<UiStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
struct Decl {
|
||||
path: PathBuf,
|
||||
#[serde(default)]
|
||||
editor_scheme: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ExtensionPoint for ThemesPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
POINT_ID
|
||||
}
|
||||
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Ui
|
||||
}
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let decl: Decl = decode(POINT_ID, decl)?;
|
||||
let path = &decl.path;
|
||||
let mut theme = ThemeDef::parse(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut files = vec![path.clone()];
|
||||
if let Some(scheme) = &decl.editor_scheme {
|
||||
files.push(scheme.clone());
|
||||
theme.merge_editor_scheme(&pkg.read_text(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?;
|
||||
}
|
||||
let item = UiContribution {
|
||||
plugin_id: pkg.id().to_owned(),
|
||||
point: POINT_ID.into(),
|
||||
key: theme.id.clone(),
|
||||
decl: serde_json::to_value(&theme).map_err(|e| e.to_string())?,
|
||||
};
|
||||
let store = self.store.clone();
|
||||
let mut prepared = Prepared::new(theme.name.clone(), move || Ok(store.insert(item))).key(theme.id);
|
||||
prepared.files = files;
|
||||
Ok(prepared)
|
||||
}
|
||||
}
|
||||
|
||||
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
|
||||
/// parse errors still point at the right line.
|
||||
fn strip_json_comments(src: &str) -> String {
|
||||
@@ -0,0 +1,236 @@
|
||||
//! UI-facing plugin types and the event stream.
|
||||
|
||||
use super::theme::ThemeDef;
|
||||
use ide_extension::{CapabilityInfo, ContributionInfo, UiContribution};
|
||||
pub use ide_plugin::{ActionPerformed, ActionSource, EventKind, NotificationLevel, StatusItem, WorkbenchEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PluginSource {
|
||||
/// Installed under `<app-data>/plugins/<id>/`.
|
||||
Installed,
|
||||
/// Loaded from `<project>/.openidea/plugins/<dir>/` while that project is open.
|
||||
Dev,
|
||||
/// A development plugin linked from any folder (`openidea-plugin link`).
|
||||
Linked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PluginStatus {
|
||||
/// Code instantiated and activated.
|
||||
Active,
|
||||
/// Has code that has not been needed yet: contributions are live, no Wasm instance.
|
||||
Inactive,
|
||||
/// No code; contributions only.
|
||||
Static,
|
||||
Disabled,
|
||||
/// Waiting for the user to confirm its permissions (project and new plugins).
|
||||
NeedsConsent,
|
||||
/// Failed to load, or failed repeatedly and was stopped for this session (see `error`).
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl PluginStatus {
|
||||
/// Contributions are in effect.
|
||||
pub fn is_live(self) -> bool {
|
||||
matches!(self, Self::Active | Self::Inactive | Self::Static)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub change_notes: Option<String>,
|
||||
pub source: PluginSource,
|
||||
/// Package directory.
|
||||
pub path: ide_core::IpcPath,
|
||||
pub enabled: bool,
|
||||
pub status: PluginStatus,
|
||||
pub error: Option<String>,
|
||||
pub has_code: bool,
|
||||
/// Declared capabilities with risk and grant state.
|
||||
pub capabilities: Vec<CapabilityInfo>,
|
||||
/// What it contributes while live (point + label).
|
||||
pub contributions: Vec<ContributionInfo>,
|
||||
/// `activation` events of the manifest.
|
||||
pub activation: Vec<String>,
|
||||
/// Non-fatal problems (unsupported contribution points).
|
||||
pub warnings: Vec<String>,
|
||||
/// Statistics of this session.
|
||||
pub stats: PluginStats,
|
||||
/// The marketplace publisher whose signature the package carries (none: unsigned or untrusted).
|
||||
pub publisher: Option<String>,
|
||||
/// The version an update replaced, to roll back to.
|
||||
pub previous_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStats {
|
||||
pub activations: u32,
|
||||
/// Last activation (instantiate + `activate`), ms.
|
||||
pub activation_ms: f64,
|
||||
pub calls: u32,
|
||||
pub errors: u32,
|
||||
/// Slowest call, ms.
|
||||
pub max_call_ms: f64,
|
||||
/// 95th percentile of the recent calls, ms.
|
||||
pub p95_call_ms: f64,
|
||||
/// Edit-path calls whose result came too late and was dropped.
|
||||
pub slow_calls: u32,
|
||||
/// Traps (panics, budgets, permission violations); each drops the instance.
|
||||
pub crashes: u32,
|
||||
/// Instances reclaimed after being idle.
|
||||
pub reclaims: u32,
|
||||
}
|
||||
|
||||
/// Number of declarations of one extension point.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContributionCount {
|
||||
pub point: String,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// A plugin package available for installation from a local catalog
|
||||
/// directory (the samples shipped with the IDE). Not installed by listing it.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogPlugin {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub change_notes: Option<String>,
|
||||
/// Package directory; install with `install_from_dir(path)`.
|
||||
pub path: ide_core::IpcPath,
|
||||
pub has_code: bool,
|
||||
pub capabilities: Vec<CapabilityInfo>,
|
||||
pub contributions: Vec<ContributionCount>,
|
||||
/// Why the package cannot be installed as is (e.g. its WASM is not built).
|
||||
pub problem: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActiveTheme {
|
||||
pub plugin_id: String,
|
||||
pub theme: ThemeDef,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginsSnapshot {
|
||||
pub plugins: Vec<PluginInfo>,
|
||||
pub active_theme: Option<ActiveTheme>,
|
||||
/// UI contributions of live plugins, projected by the client.
|
||||
pub ui: Vec<UiContribution>,
|
||||
}
|
||||
|
||||
/// Backend → UI notifications (Tauri event `ide://plugin`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PluginEvent {
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
|
||||
/// A plugin began a task: show it with the background tasks.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
TaskStarted { plugin_id: String, task_id: String, title: String, cancellable: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
TaskProgress { plugin_id: String, task_id: String, done: u32, total: Option<u32>, message: Option<String> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
TaskEnded { plugin_id: String, task_id: String },
|
||||
/// The kinds of workbench events plugins want changed: re-fetch
|
||||
/// `plugin_event_kinds` (the UI reports only those).
|
||||
SubscriptionsChanged,
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ThemeChanged { theme: Option<ActiveTheme> },
|
||||
/// A plugin needs capabilities the user has not granted yet (first use
|
||||
/// after migrating from 0.1): show its consent dialog.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ConsentRequested { plugin_id: String, capabilities: Vec<CapabilityInfo> },
|
||||
/// A view (or the custom editors) of a plugin must be rendered again.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ViewChanged { plugin_id: String, view: String },
|
||||
/// For the script of a view's `html` body.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ViewMessage { plugin_id: String, view: String, message: String },
|
||||
/// Open a terminal running `program` (already approved and verified).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenTerminal {
|
||||
plugin_id: String,
|
||||
title: String,
|
||||
program: ide_core::IpcPath,
|
||||
args: Vec<String>,
|
||||
cwd: ide_core::IpcPath,
|
||||
},
|
||||
/// Plugin languages, or the servers they may start, changed: re-fetch
|
||||
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
LanguagesChanged,
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
/// (`None` = broadcast, e.g. during activation).
|
||||
pub trait PluginEventSink: Send + Sync {
|
||||
fn emit(&self, client: Option<&str>, event: PluginEvent);
|
||||
}
|
||||
|
||||
impl<F: Fn(Option<&str>, PluginEvent) + Send + Sync> PluginEventSink for F {
|
||||
fn emit(&self, client: Option<&str>, event: PluginEvent) {
|
||||
self(client, event)
|
||||
}
|
||||
}
|
||||
|
||||
/// The focused editor when a plugin command is invoked.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginEditor {
|
||||
pub path: String,
|
||||
pub language: String,
|
||||
pub selections: Vec<ide_core::Range>,
|
||||
}
|
||||
|
||||
impl PluginEditor {
|
||||
pub fn context(self) -> ide_plugin::EditorContext {
|
||||
ide_plugin::EditorContext { path: self.path.into(), language: self.language, selections: self.selections }
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin's status bar item.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStatusItem {
|
||||
pub plugin_id: String,
|
||||
pub id: String,
|
||||
pub item: StatusItem,
|
||||
}
|
||||
|
||||
/// A gutter marker from a plugin's `line-markers` provider.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LineMarkerInfo {
|
||||
pub plugin_id: String,
|
||||
/// 0-based.
|
||||
pub line: u32,
|
||||
/// `navigate` (default), `implemented`, `implementing`, `run`.
|
||||
pub icon: Option<String>,
|
||||
pub tooltip: String,
|
||||
/// Opened on click.
|
||||
pub target: Option<ide_core::Location>,
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Declarative UI contributions: 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 = "jobs"
|
||||
//! title = "Jobs"
|
||||
//! icon = "server"
|
||||
//! location = "right" # left, right, bottom
|
||||
//!
|
||||
//! [[contributes.customEditors]]
|
||||
//! id = "table"
|
||||
//! title = "Table"
|
||||
//! patterns = ["*.csv", "*.tsv"]
|
||||
//! priority = "default" # or "option": only through Open With
|
||||
//! layout = "tab" # or "split" / "preview": beside the text editor
|
||||
//! web = "web/index.html" # a web page from the package instead of rendered content
|
||||
//!
|
||||
//! [[contributes.fileIcons]]
|
||||
//! id = "drawing"
|
||||
//! patterns = ["*.excalidraw"]
|
||||
//! icon = "icons/drawing.svg"
|
||||
//! iconDark = "icons/drawing_dark.svg"
|
||||
//!
|
||||
//! [[contributes.fileTemplates]]
|
||||
//! id = "drawing"
|
||||
//! name = "Excalidraw Drawing"
|
||||
//! extension = "excalidraw"
|
||||
//! path = "templates/drawing.excalidraw"
|
||||
//! ```
|
||||
|
||||
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";
|
||||
pub const FILE_ICONS: &str = "fileIcons";
|
||||
pub const FILE_TEMPLATES: &str = "fileTemplates";
|
||||
|
||||
const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"];
|
||||
const MAX_TITLE: usize = 256;
|
||||
const MAX_SETTINGS: usize = 200;
|
||||
const MAX_PATTERNS: usize = 16;
|
||||
|
||||
/// Command ids the package declares.
|
||||
fn commands(pkg: &Package) -> BTreeSet<String> {
|
||||
pkg.manifest
|
||||
.declarations(super::COMMANDS)
|
||||
.iter()
|
||||
.filter_map(|d| d.get("id").and_then(|v| v.as_str()).map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn declared_command(pkg: &Package, point: &str, command: &str) -> Result<(), String> {
|
||||
if commands(pkg).contains(command) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("contributes.{point}: command {command:?} is not declared under [[contributes.commands]]"))
|
||||
}
|
||||
}
|
||||
|
||||
fn title(point: &str, t: &str) -> Result<(), String> {
|
||||
if t.trim().is_empty() || t.len() > MAX_TITLE {
|
||||
return Err(format!("contributes.{point}: titles are 1–{MAX_TITLE} bytes"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json<T: Serialize>(v: &T) -> Result<Value, String> {
|
||||
serde_json::to_value(v).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct MenuDecl {
|
||||
menu: String,
|
||||
command: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
when: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: MenuDecl = decode(MENUS, decl)?;
|
||||
if !MENU_IDS.contains(&d.menu.as_str()) {
|
||||
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
|
||||
}
|
||||
declared_command(pkg, MENUS, &d.command)?;
|
||||
if let Some(w) = &d.when {
|
||||
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
|
||||
}
|
||||
Ok(UiDecl::new(format!("{}/{}", d.menu, d.command), d.command.clone(), json(&d)?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct KeymapDecl {
|
||||
command: String,
|
||||
key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mac: Option<String>,
|
||||
}
|
||||
|
||||
const MODIFIERS: [&str; 4] = ["Meta", "Ctrl", "Alt", "Shift"];
|
||||
const NAMED_KEYS: [&str; 22] = [
|
||||
"Enter",
|
||||
"Escape",
|
||||
"Tab",
|
||||
"Space",
|
||||
"Backspace",
|
||||
"Delete",
|
||||
"Insert",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Up",
|
||||
"Down",
|
||||
"Left",
|
||||
"Right",
|
||||
"Minus",
|
||||
"Equal",
|
||||
"Comma",
|
||||
"Period",
|
||||
"Slash",
|
||||
"Semicolon",
|
||||
"Quote",
|
||||
];
|
||||
|
||||
/// `Ctrl+Alt+C`, `Meta+K Meta+C` (a chord): at least one modifier per
|
||||
/// stroke but for function keys, so plugins can't take plain typing.
|
||||
pub fn validate_binding(binding: &str) -> Result<(), String> {
|
||||
let strokes: Vec<&str> = binding.split(' ').collect();
|
||||
if strokes.is_empty() || strokes.len() > 2 {
|
||||
return Err(format!("{binding:?}: one stroke, or two for a chord"));
|
||||
}
|
||||
for stroke in strokes {
|
||||
let parts: Vec<&str> = stroke.split('+').collect();
|
||||
let (key, mods) = parts.split_last().ok_or_else(|| format!("{binding:?}: empty stroke"))?;
|
||||
let mut seen = BTreeSet::new();
|
||||
for m in mods {
|
||||
if !MODIFIERS.contains(m) || !seen.insert(*m) {
|
||||
return Err(format!("{binding:?}: unknown or repeated modifier {m:?}"));
|
||||
}
|
||||
}
|
||||
let function = key.strip_prefix('F').and_then(|n| n.parse::<u8>().ok()).is_some_and(|n| (1..=24).contains(&n));
|
||||
let plain = key.len() == 1 && key.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());
|
||||
if !(function || plain || NAMED_KEYS.contains(key)) {
|
||||
return Err(format!("{binding:?}: unknown key {key:?}"));
|
||||
}
|
||||
if !function && seen.iter().all(|m| *m == "Shift") {
|
||||
return Err(format!("{binding:?}: needs Ctrl, Alt or Meta (plain keys are for typing)"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn keymap(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: KeymapDecl = decode(KEYMAPS, decl)?;
|
||||
declared_command(pkg, KEYMAPS, &d.command)?;
|
||||
validate_binding(&d.key).map_err(|e| format!("contributes.keymaps: {e}"))?;
|
||||
if let Some(m) = &d.mac {
|
||||
validate_binding(m).map_err(|e| format!("contributes.keymaps: {e}"))?;
|
||||
}
|
||||
Ok(UiDecl::new(d.command.clone(), d.key.clone(), json(&d)?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StatusDecl {
|
||||
id: String,
|
||||
text: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tooltip: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
command: Option<String>,
|
||||
#[serde(default = "right")]
|
||||
align: String,
|
||||
#[serde(default)]
|
||||
priority: u32,
|
||||
}
|
||||
|
||||
fn right() -> String {
|
||||
"right".into()
|
||||
}
|
||||
|
||||
pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: StatusDecl = decode(STATUS_BAR, decl)?;
|
||||
validate_item_id(&d.id, "status bar item id")?;
|
||||
if d.text.is_empty() || d.text.len() > 128 || d.tooltip.as_ref().is_some_and(|t| t.len() > 1024) {
|
||||
return Err(format!("status bar item {:?}: `text` 1–128 bytes, `tooltip` up to 1 KiB", d.id));
|
||||
}
|
||||
if d.align != "left" && d.align != "right" {
|
||||
return Err(format!("status bar item {:?}: `align` is left or right", d.id));
|
||||
}
|
||||
if d.priority > 1000 {
|
||||
return Err(format!("status bar item {:?}: `priority` is 0–1000", d.id));
|
||||
}
|
||||
if let Some(c) = &d.command {
|
||||
declared_command(pkg, STATUS_BAR, c)?;
|
||||
}
|
||||
Ok(UiDecl::new(d.id.clone(), d.text.clone(), json(&d)?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SettingsDecl {
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
schema: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SchemaFile {
|
||||
properties: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One setting of a plugin.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingSchema {
|
||||
pub key: String,
|
||||
/// `boolean`, `string`, `number` or `integer`.
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub default: Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub multiline: bool,
|
||||
}
|
||||
|
||||
impl SettingSchema {
|
||||
/// Whether `v` is a valid value of this setting.
|
||||
pub fn accepts(&self, v: &Value) -> bool {
|
||||
match self.kind.as_str() {
|
||||
"boolean" => v.is_boolean(),
|
||||
"integer" => v.is_i64() || v.is_u64(),
|
||||
"number" => v.is_number(),
|
||||
_ => v
|
||||
.as_str()
|
||||
.is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
|
||||
if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) {
|
||||
return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]"));
|
||||
}
|
||||
let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
|
||||
let kind = o.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let default = match kind {
|
||||
"boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)),
|
||||
"string" => o.get("default").cloned().unwrap_or(Value::String(String::new())),
|
||||
"number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)),
|
||||
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
|
||||
};
|
||||
let options = match o.get("enum") {
|
||||
None => None,
|
||||
Some(Value::Array(a)) if kind == "string" && !a.is_empty() && a.len() <= 100 => Some(
|
||||
a.iter()
|
||||
.map(|v| v.as_str().map(str::to_owned).ok_or_else(|| format!("setting {key:?}: `enum` lists strings")))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
),
|
||||
Some(_) => return Err(format!("setting {key:?}: `enum` is 1–100 strings of a string setting")),
|
||||
};
|
||||
let text = |name: &str, max: usize| -> Result<Option<String>, String> {
|
||||
match o.get(name) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(s)) if s.len() <= max => Ok(Some(s.clone())),
|
||||
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
|
||||
}
|
||||
};
|
||||
let s = SettingSchema {
|
||||
key: key.to_owned(),
|
||||
kind: kind.to_owned(),
|
||||
title: text("title", MAX_TITLE)?.unwrap_or_else(|| key.to_owned()),
|
||||
description: text("description", 4096)?,
|
||||
default,
|
||||
options,
|
||||
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
|
||||
};
|
||||
if !s.accepts(&s.default) {
|
||||
return Err(format!("setting {key:?}: the default does not match its type"));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The validated settings of a `[contributes.settings]` schema file.
|
||||
pub fn parse_settings_schema(text: &str) -> Result<Vec<SettingSchema>, String> {
|
||||
let file: SchemaFile = serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
if file.properties.len() > MAX_SETTINGS {
|
||||
return Err(format!("more than {MAX_SETTINGS} settings"));
|
||||
}
|
||||
file.properties.iter().map(|(k, v)| setting(k, v)).collect()
|
||||
}
|
||||
|
||||
pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: SettingsDecl = decode(SETTINGS, decl)?;
|
||||
let text = pkg.read_text(&d.schema)?;
|
||||
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
|
||||
let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
|
||||
title_ok(&title)?;
|
||||
let mut decl =
|
||||
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
|
||||
decl.files.push(d.schema);
|
||||
Ok(decl)
|
||||
}
|
||||
|
||||
fn title_ok(t: &str) -> Result<(), String> {
|
||||
title(SETTINGS, t)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ViewDecl {
|
||||
id: String,
|
||||
title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
icon: Option<String>,
|
||||
#[serde(default = "right")]
|
||||
location: String,
|
||||
/// A web page of the package (`web/index.html`) instead of content the code renders.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
web: Option<String>,
|
||||
}
|
||||
|
||||
/// The files of a `web` entry, or a check that the code renders the view.
|
||||
fn web_or_code(pkg: &Package, web: Option<&str>, what: &str) -> Result<Vec<PathBuf>, String> {
|
||||
match web {
|
||||
Some(entry) => super::web::web_files(pkg, entry, what),
|
||||
None if pkg.manifest.wasm.is_none() => {
|
||||
Err(format!("{what}: give a web page (`web`) or the code that renders it (`wasm`)"))
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: ViewDecl = decode(VIEWS, decl)?;
|
||||
validate_item_id(&d.id, "view id")?;
|
||||
title(VIEWS, &d.title)?;
|
||||
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", 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));
|
||||
}
|
||||
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
|
||||
out.files = files;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CustomEditorDecl {
|
||||
id: String,
|
||||
title: String,
|
||||
patterns: Vec<String>,
|
||||
#[serde(default = "default_priority")]
|
||||
priority: String,
|
||||
/// `tab`: the file opens in this editor's own tab. `split` / `preview`:
|
||||
/// the file opens in the text editor with this editor beside it (Editor /
|
||||
/// Editor and Preview / Preview); the value is the layout files start in.
|
||||
#[serde(default = "default_layout")]
|
||||
layout: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
web: Option<String>,
|
||||
}
|
||||
|
||||
fn default_priority() -> String {
|
||||
"default".into()
|
||||
}
|
||||
|
||||
fn default_layout() -> String {
|
||||
"tab".into()
|
||||
}
|
||||
|
||||
/// File name globs (`*.csv`, `{a,b}.json`): no directories.
|
||||
fn file_patterns(patterns: &[String], what: &str) -> Result<(), String> {
|
||||
if patterns.is_empty() || patterns.len() > MAX_PATTERNS {
|
||||
return Err(format!("{what}: 1–{MAX_PATTERNS} file name `patterns`"));
|
||||
}
|
||||
for p in patterns {
|
||||
if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() {
|
||||
return Err(format!("{what}: invalid file name pattern {p:?}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: CustomEditorDecl = decode(CUSTOM_EDITORS, decl)?;
|
||||
validate_item_id(&d.id, "custom editor id")?;
|
||||
title(CUSTOM_EDITORS, &d.title)?;
|
||||
let what = format!("custom editor {:?}", d.id);
|
||||
let files = web_or_code(pkg, d.web.as_deref(), &what)?;
|
||||
file_patterns(&d.patterns, &what)?;
|
||||
if d.priority != "default" && d.priority != "option" {
|
||||
return Err(format!("{what}: `priority` is default or option"));
|
||||
}
|
||||
if !["tab", "split", "preview"].contains(&d.layout.as_str()) {
|
||||
return Err(format!("{what}: `layout` is tab, split or preview"));
|
||||
}
|
||||
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
|
||||
out.files = files;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct FileIconDecl {
|
||||
id: String,
|
||||
patterns: Vec<String>,
|
||||
icon: PathBuf,
|
||||
#[serde(default)]
|
||||
icon_dark: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Asset id of a file icon's SVG (`dark`: its dark-theme variant).
|
||||
pub fn file_icon_asset(id: &str, dark: bool) -> String {
|
||||
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
|
||||
}
|
||||
|
||||
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
|
||||
/// whatever icon theme is in use (the user's own file type icons still win).
|
||||
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
|
||||
validate_item_id(&d.id, "file icon id")?;
|
||||
let what = format!("file icon {:?}", d.id);
|
||||
file_patterns(&d.patterns, &what)?;
|
||||
let mut files = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
|
||||
let Some(path) = path else { continue };
|
||||
let svg = super::icons::read_icon(pkg, path)?;
|
||||
assets.push((file_icon_asset(&d.id, dark), svg));
|
||||
files.push(path.clone());
|
||||
}
|
||||
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
|
||||
out.files = files;
|
||||
out.assets = assets;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct FileTemplateDecl {
|
||||
id: String,
|
||||
name: String,
|
||||
/// Without the dot.
|
||||
extension: String,
|
||||
/// The new file's content (`$NAME$`, `$END$` and the other template variables work).
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
const MAX_TEMPLATE: usize = 256 << 10;
|
||||
|
||||
/// `[[contributes.fileTemplates]]`: an entry of the New menu.
|
||||
pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: FileTemplateDecl = decode(FILE_TEMPLATES, decl)?;
|
||||
validate_item_id(&d.id, "file template id")?;
|
||||
title(FILE_TEMPLATES, &d.name)?;
|
||||
let what = format!("file template {:?}", d.id);
|
||||
let ext = d.extension.trim_start_matches('.');
|
||||
if ext.is_empty()
|
||||
|| ext.len() > 32
|
||||
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||
{
|
||||
return Err(format!("{what}: `extension` is a file extension like \"md\" ({:?})", d.extension));
|
||||
}
|
||||
let body = pkg.read_text(&d.path)?;
|
||||
if body.len() > MAX_TEMPLATE {
|
||||
return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10));
|
||||
}
|
||||
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
|
||||
out.files = vec![d.path.clone()];
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bindings_need_a_modifier() {
|
||||
for ok in ["Ctrl+Alt+C", "Meta+Shift+K", "F5", "Shift+F6", "Meta+K Meta+C", "Alt+Enter"] {
|
||||
assert!(validate_binding(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in ["C", "Shift+C", "Hyper+C", "Ctrl+Ctrl+C", "Ctrl+é", "Ctrl+A B C", ""] {
|
||||
assert!(validate_binding(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_schemas_are_typed() {
|
||||
let s = parse_settings_schema(
|
||||
r#"{"properties": {
|
||||
"context": {"type": "string", "title": "Context", "default": ""},
|
||||
"watch": {"type": "boolean", "default": true},
|
||||
"mode": {"type": "string", "enum": ["a", "b"], "default": "b"},
|
||||
"limit": {"type": "integer", "default": 100}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.len(), 4);
|
||||
assert!(s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("a")));
|
||||
assert!(!s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("c")));
|
||||
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err());
|
||||
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Declarative views and custom editors as the
|
||||
//! window commands `plugin_view_render` / `plugin_view_event` serve them —
|
||||
//! the desktop for local projects, `openidea-server` for remote ones.
|
||||
|
||||
use super::{PluginEditor, 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 editor the call is about: the document a
|
||||
/// custom editor shows, else (a tool window) the window's active editor, as
|
||||
/// a command gets it, so a view's buttons can act on what the user is editing.
|
||||
pub fn view_invocation(
|
||||
host: &crate::Host,
|
||||
window: &str,
|
||||
doc: Option<DocId>,
|
||||
editor: Option<PluginEditor>,
|
||||
) -> IdeResult<Invocation> {
|
||||
let project = host.project(window).ok();
|
||||
let editor = match doc {
|
||||
None => editor.map(PluginEditor::context),
|
||||
Some(doc) => {
|
||||
let project = project.as_ref().ok_or_else(|| IdeError::not_found("no project is open in this window"))?;
|
||||
let info = project.documents.snapshot(doc)?;
|
||||
Some(EditorContext { path: info.path.0, language: info.language.0, selections: Vec::new() })
|
||||
}
|
||||
};
|
||||
Ok(Invocation { client: Some(window.to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor })
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn render(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
plugin_id: String,
|
||||
view: String,
|
||||
viewport: Option<Viewport>,
|
||||
doc: Option<DocId>,
|
||||
editor: Option<PluginEditor>,
|
||||
) -> IdeResult<View> {
|
||||
let invocation = view_invocation(host, window, doc, editor)?;
|
||||
blocking(move || plugins.render_view(&plugin_id, &view, viewport, invocation)).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn event(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
plugin_id: String,
|
||||
view: String,
|
||||
event: ViewEvent,
|
||||
doc: Option<DocId>,
|
||||
editor: Option<PluginEditor>,
|
||||
) -> IdeResult<()> {
|
||||
let invocation = view_invocation(host, window, doc, editor)?;
|
||||
blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Host, HostConfig};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_tool_window_call_carries_the_active_editor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let range = ide_core::Range::new(ide_core::Pos::new(1, 0), ide_core::Pos::new(1, 4));
|
||||
let active = PluginEditor { path: "/p/a.rs".into(), language: "rust".into(), selections: vec![range] };
|
||||
let inv = view_invocation(&host, "main", None, Some(active)).unwrap();
|
||||
let editor = inv.editor.expect("the active editor");
|
||||
assert_eq!((editor.path.to_string_lossy().as_ref(), editor.selections.as_slice()), ("/p/a.rs", &[range][..]));
|
||||
assert_eq!(inv.client.as_deref(), Some("main"));
|
||||
assert!(view_invocation(&host, "main", None, None).unwrap().editor.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Web views: a view or custom editor whose UI is a web page shipped in the
|
||||
//! package (`web = "web/index.html"`) instead of content the plugin's code
|
||||
//! renders.
|
||||
//!
|
||||
//! The entry's directory is the page's root. Every file under it is part of
|
||||
//! the declaration: it is checked when the package is prepared (count, size,
|
||||
//! no links leaving the package), copied on install and covered by the
|
||||
//! package digest and signature. The client loads the page in a sandboxed
|
||||
//! frame (an opaque origin with no network and no IDE API) and fetches its
|
||||
//! files through [`PluginHost::web_file`] (the desktop's `openidea-plugin`
|
||||
//! URL scheme); the page talks to the IDE only through the frame bridge.
|
||||
|
||||
use super::PluginHost;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::{Package, check_relative};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// Most files under one web root.
|
||||
const MAX_FILES: usize = 4096;
|
||||
/// Most bytes under one web root.
|
||||
const MAX_BYTES: u64 = 256 << 20;
|
||||
/// Deepest directory nesting under a web root.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
/// Checks a declaration's `web` entry: an `.html` file inside a directory of
|
||||
/// the package (the directory is what the page can load, so it can't be the
|
||||
/// package root), and everything under that directory. Returns those files,
|
||||
/// package-relative.
|
||||
pub(super) fn web_files(pkg: &Package, entry: &str, what: &str) -> Result<Vec<PathBuf>, String> {
|
||||
let path = Path::new(entry);
|
||||
check_relative(path, "web")?;
|
||||
if entry.contains('\\') || !entry.to_ascii_lowercase().ends_with(".html") {
|
||||
return Err(format!("{what}: `web` names an .html file, `/`-separated ({entry:?})"));
|
||||
}
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| {
|
||||
format!("{what}: put the page in a directory of its own (`web/index.html`): the directory is what it can load")
|
||||
})?;
|
||||
let real_entry = pkg.resolve(path)?;
|
||||
if !real_entry.is_file() {
|
||||
return Err(format!("{what}: {entry} is not a file"));
|
||||
}
|
||||
let root = pkg.resolve(dir)?;
|
||||
let mut files = Vec::new();
|
||||
let mut total = 0u64;
|
||||
let mut stack = vec![(root.clone(), dir.to_path_buf(), 0usize)];
|
||||
while let Some((real, rel, depth)) = stack.pop() {
|
||||
let mut entries: Vec<_> =
|
||||
std::fs::read_dir(&real).map_err(|e| format!("{}: {e}", rel.display()))?.filter_map(Result::ok).collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for e in entries {
|
||||
let name = e.file_name();
|
||||
let rel = rel.join(&name);
|
||||
// Links could point anywhere: only what is really in the package counts.
|
||||
let ty = e.file_type().map_err(|err| format!("{}: {err}", rel.display()))?;
|
||||
if ty.is_symlink() {
|
||||
return Err(format!("{what}: {} is a link; web files must be real files", rel.display()));
|
||||
}
|
||||
if ty.is_dir() {
|
||||
if depth + 1 > MAX_DEPTH {
|
||||
return Err(format!("{what}: {} is nested deeper than {MAX_DEPTH} directories", rel.display()));
|
||||
}
|
||||
stack.push((e.path(), rel, depth + 1));
|
||||
continue;
|
||||
}
|
||||
if name.to_string_lossy() == ".DS_Store" {
|
||||
continue;
|
||||
}
|
||||
let len = e.metadata().map_err(|err| format!("{}: {err}", rel.display()))?.len();
|
||||
if len > ide_extension::MAX_PACKAGE_FILE {
|
||||
return Err(format!(
|
||||
"{what}: {} is larger than {} MiB",
|
||||
rel.display(),
|
||||
ide_extension::MAX_PACKAGE_FILE >> 20
|
||||
));
|
||||
}
|
||||
total += len;
|
||||
files.push(rel);
|
||||
if files.len() > MAX_FILES || total > MAX_BYTES {
|
||||
return Err(format!(
|
||||
"{what}: {} holds more than {MAX_FILES} files or {} MiB",
|
||||
dir.display(),
|
||||
MAX_BYTES >> 20
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// The page of a web view, for the client to load in its frame.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebPage {
|
||||
/// `<plugin id>/<revision>/<entry>` under the `openidea-plugin` scheme. The
|
||||
/// revision changes with the installed package, so a cached file is never
|
||||
/// stale; relative URLs in the page resolve to the package's files.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// A file of a web view.
|
||||
pub struct WebFile {
|
||||
pub bytes: Vec<u8>,
|
||||
pub mime: &'static str,
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated).
|
||||
fn web_dirs(&self, plugin: &str) -> Vec<String> {
|
||||
[super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
|
||||
.iter()
|
||||
.flat_map(|point| self.ui.items(point, Some(plugin)))
|
||||
.filter_map(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned))
|
||||
.filter_map(|entry| entry.rsplit_once('/').map(|(dir, _)| format!("{dir}/")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Changes whenever the installed package does (cache key of its web files).
|
||||
fn web_revision(&self, plugin: &super::LoadedPlugin) -> String {
|
||||
let digest = plugin.pkg.digest.as_deref().unwrap_or_default();
|
||||
let short: String = digest.chars().filter(char::is_ascii_alphanumeric).take(16).collect();
|
||||
if short.is_empty() { format!("v{}", plugin.pkg.manifest.version) } else { short }
|
||||
}
|
||||
|
||||
/// The page of `plugin`'s view or custom editor `view`.
|
||||
pub fn web_page(&self, plugin_id: &str, view: &str) -> IdeResult<WebPage> {
|
||||
let plugin = self.get(plugin_id)?;
|
||||
if !plugin.status().is_live() {
|
||||
return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled")));
|
||||
}
|
||||
let entry = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
|
||||
.iter()
|
||||
.flat_map(|point| self.ui.items(point, Some(plugin_id)))
|
||||
.find(|item| item.key == view)
|
||||
.and_then(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned))
|
||||
.ok_or_else(|| IdeError::not_found(format!("plugin {plugin_id:?} has no web view {view:?}")))?;
|
||||
Ok(WebPage { path: format!("{plugin_id}/{}/{entry}", self.web_revision(&plugin)) })
|
||||
}
|
||||
|
||||
/// A file of one of `plugin`'s web views: `path` is `<revision>/<package path>`
|
||||
/// as [`WebPage::path`] makes them. Only files under a declared web root
|
||||
/// of a live plugin are served; the revision must be the installed one.
|
||||
pub fn web_file(&self, plugin_id: &str, path: &str) -> IdeResult<WebFile> {
|
||||
let plugin = self.get(plugin_id)?;
|
||||
if !plugin.status().is_live() {
|
||||
return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled")));
|
||||
}
|
||||
let (revision, rel) = path.split_once('/').ok_or_else(|| IdeError::not_found(path.to_owned()))?;
|
||||
if revision != self.web_revision(&plugin) {
|
||||
return Err(IdeError::not_found(format!("{path}: not the installed version")));
|
||||
}
|
||||
let rel_path = Path::new(rel);
|
||||
if !rel_path.components().all(|c| matches!(c, Component::Normal(_))) {
|
||||
return Err(IdeError::invalid(format!("{rel}: not a plain relative path")));
|
||||
}
|
||||
if !self.web_dirs(plugin_id).iter().any(|dir| rel.starts_with(dir.as_str())) {
|
||||
return Err(IdeError::not_found(format!("{rel}: not part of a web view")));
|
||||
}
|
||||
let bytes = plugin.pkg.read_bytes(rel_path).map_err(IdeError::not_found)?;
|
||||
Ok(WebFile { bytes, mime: mime(rel) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Content type by extension; unknown files are opaque bytes (never sniffed as script).
|
||||
pub fn mime(path: &str) -> &'static str {
|
||||
let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"html" | "htm" => "text/html; charset=utf-8",
|
||||
"js" | "mjs" | "cjs" => "text/javascript; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"json" | "map" => "application/json",
|
||||
"wasm" => "application/wasm",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"avif" => "image/avif",
|
||||
"ico" => "image/x-icon",
|
||||
"woff2" => "font/woff2",
|
||||
"woff" => "font/woff",
|
||||
"ttf" => "font/ttf",
|
||||
"otf" => "font/otf",
|
||||
"txt" | "md" => "text/plain; charset=utf-8",
|
||||
"mp3" => "audio/mpeg",
|
||||
"mp4" => "video/mp4",
|
||||
"webm" => "video/webm",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_extension::Manifest;
|
||||
|
||||
fn pkg(dir: &Path) -> Package {
|
||||
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n";
|
||||
Package::new(Manifest::parse(src).unwrap(), dir.to_path_buf())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_root_is_its_directory_and_nothing_else() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let d = tmp.path();
|
||||
std::fs::create_dir_all(d.join("web/assets/fonts")).unwrap();
|
||||
std::fs::write(d.join("web/index.html"), "<p>hi</p>").unwrap();
|
||||
std::fs::write(d.join("web/assets/app.js"), "1").unwrap();
|
||||
std::fs::write(d.join("web/assets/fonts/a.woff2"), "f").unwrap();
|
||||
std::fs::write(d.join("web/.DS_Store"), "x").unwrap();
|
||||
std::fs::write(d.join("secret.txt"), "no").unwrap();
|
||||
let files = web_files(&pkg(d), "web/index.html", "v").unwrap();
|
||||
let files: Vec<String> = files.iter().map(|f| f.to_string_lossy().replace('\\', "/")).collect();
|
||||
assert_eq!(files, ["web/assets/app.js", "web/assets/fonts/a.woff2", "web/index.html"]);
|
||||
|
||||
std::fs::write(d.join("index.html"), "x").unwrap();
|
||||
let err = |entry: &str| web_files(&pkg(d), entry, "v").err().unwrap_or_default();
|
||||
assert!(err("index.html").contains("directory of its own"), "{}", err("index.html"));
|
||||
assert!(err("web/app.js").contains(".html"));
|
||||
assert!(err("../web/index.html").contains("relative"));
|
||||
assert!(err("web/missing.html").contains("no such file"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_a_web_root_are_refused() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let d = tmp.path();
|
||||
std::fs::create_dir_all(d.join("web")).unwrap();
|
||||
std::fs::write(d.join("web/index.html"), "x").unwrap();
|
||||
std::os::unix::fs::symlink("/etc/hosts", d.join("web/hosts")).unwrap();
|
||||
assert!(web_files(&pkg(d), "web/index.html", "v").unwrap_err().contains("is a link"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_types_never_make_unknown_files_scripts() {
|
||||
assert_eq!(mime("a/b.JS"), "text/javascript; charset=utf-8");
|
||||
assert_eq!(mime("x.woff2"), "font/woff2");
|
||||
assert_eq!(mime("x.wasm"), "application/wasm");
|
||||
assert_eq!(mime("x.bin"), "application/octet-stream");
|
||||
assert_eq!(mime("noext"), "application/octet-stream");
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,10 @@
|
||||
//! * server → client notification `event` `{ window, event }` (an `IdeEvent`
|
||||
//! for that window, or every window when `window` is null);
|
||||
//! * server → client notification `channel` `{ id, data }`: one value of a
|
||||
//! streaming argument.
|
||||
//! streaming argument;
|
||||
//! * server → client notification `plugin` `{ window, event }` and request
|
||||
//! `plugin_secret` `{ plugin, name }` → `string | null`: code plugins of the
|
||||
//! remote project (`plugins::remote_host`).
|
||||
|
||||
use crate::Host;
|
||||
use crate::ops::{self, Emit};
|
||||
|
||||
@@ -273,6 +273,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "plugin_set_theme"
|
||||
| "plugin_set_theme_frame"
|
||||
| "plugin_catalog"
|
||||
| "plugin_assets"
|
||||
| "notebook_open"
|
||||
| "notebook_get"
|
||||
| "notebook_edit"
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Versioned installs with automatic rollback, signed packages from
|
||||
//! a static marketplace index, and revocation.
|
||||
|
||||
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, Publisher, PublisherKey, Revocation};
|
||||
use ide_host::plugins::*;
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
type Events = Arc<Mutex<Vec<PluginEvent>>>;
|
||||
|
||||
fn repo() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if entry.file_name() == "target" {
|
||||
continue;
|
||||
}
|
||||
if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of `from` (under `dir/name`) whose manifest is rewritten by `f`.
|
||||
fn variant(from: &Path, dir: &Path, name: &str, f: impl Fn(&str) -> String) -> PathBuf {
|
||||
let pkg = dir.join(name);
|
||||
copy_dir(from, &pkg);
|
||||
let manifest = pkg.join("plugin.toml");
|
||||
std::fs::write(&manifest, f(&std::fs::read_to_string(&manifest).unwrap())).unwrap();
|
||||
pkg
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: PluginHost,
|
||||
plugins_dir: PathBuf,
|
||||
events: Events,
|
||||
_tmp: tempfile::TempDir,
|
||||
work: PathBuf,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = tmp.path().join("plugins");
|
||||
let events: Events = Arc::default();
|
||||
let sink = {
|
||||
let events = events.clone();
|
||||
move |_: Option<&str>, e: PluginEvent| events.lock().push(e)
|
||||
};
|
||||
let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), Limits::default()).unwrap();
|
||||
host.load_all();
|
||||
let work = tmp.path().join("work");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
Fixture { host, plugins_dir, events, work, _tmp: tmp }
|
||||
}
|
||||
|
||||
fn inv() -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: None, editor: None }
|
||||
}
|
||||
|
||||
fn misbehave() -> PathBuf {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
|
||||
}
|
||||
|
||||
/// P8 acceptance: an update that fails to start rolls back to the version it replaced.
|
||||
#[test]
|
||||
fn a_failing_update_rolls_back_automatically() {
|
||||
let f = fixture();
|
||||
let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\""));
|
||||
f.host.install_from_dir(&v1, &Consent::all()).unwrap();
|
||||
assert_eq!(f.host.run_command("misbehave", "echo", Some("hi"), inv()).unwrap().as_deref(), Some("hi"));
|
||||
|
||||
let broken =
|
||||
variant(&misbehave(), &f.work, "v2", |t| t.replace("version = \"0.0.2\"", "version = \"2.0.0-broken\""));
|
||||
let info = f.host.install_from_dir(&broken, &Consent::all()).unwrap();
|
||||
assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("2.0.0-broken", Some("1.0.0")));
|
||||
let root = f.plugins_dir.join("misbehave");
|
||||
assert!(root.join("2.0.0-broken").is_dir() && root.join("1.0.0").is_dir(), "versions side by side");
|
||||
|
||||
// Its first start fails: back to 1.0.0, without the user doing anything.
|
||||
f.events.lock().clear();
|
||||
let err = f.host.run_command("misbehave", "echo", Some("x"), inv()).unwrap_err();
|
||||
assert!(err.message.contains("version 1.0.0 was restored"), "{}", err.message);
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.version.as_str(), info.status), ("1.0.0", PluginStatus::Inactive));
|
||||
assert!(!root.join("2.0.0-broken").exists(), "the failed version is removed");
|
||||
assert!(
|
||||
f.events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| matches!(e, PluginEvent::Notification { title, .. } if title.contains("rolled back")))
|
||||
);
|
||||
assert_eq!(f.host.run_command("misbehave", "echo", Some("ok"), inv()).unwrap().as_deref(), Some("ok"));
|
||||
|
||||
// A working update is confirmed by its first start; the user can still roll it back.
|
||||
let v3 = variant(&misbehave(), &f.work, "v3", |t| t.replace("version = \"0.0.2\"", "version = \"3.0.0\""));
|
||||
f.host.install_from_dir(&v3, &Consent::all()).unwrap();
|
||||
f.host.run_command("misbehave", "echo", None, inv()).unwrap();
|
||||
assert_eq!(f.host.info_of("misbehave").unwrap().version, "3.0.0");
|
||||
let info = f.host.roll_back_update("misbehave").unwrap();
|
||||
assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", None));
|
||||
assert!(f.host.roll_back_update("misbehave").is_err(), "nothing older to go back to");
|
||||
|
||||
// A restart loads the active version.
|
||||
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
again.load_all();
|
||||
assert_eq!(again.info_of("misbehave").unwrap().version, "1.0.0");
|
||||
f.host.uninstall("misbehave").unwrap();
|
||||
assert!(!root.exists(), "every version goes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_from_before_versioning_keep_working_and_move_on_update() {
|
||||
let f = fixture();
|
||||
// The flat layout of earlier releases.
|
||||
copy_dir(&misbehave(), &f.plugins_dir.join("misbehave"));
|
||||
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
again.load_all();
|
||||
assert_eq!(again.info_of("misbehave").unwrap().version, "0.0.2");
|
||||
let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\""));
|
||||
let info = again.install_from_dir(&v1, &Consent::all()).unwrap();
|
||||
assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", Some("0.0.2")));
|
||||
assert!(f.plugins_dir.join("misbehave/0.0.2/plugin.toml").is_file());
|
||||
assert!(f.plugins_dir.join("misbehave/1.0.0/plugin.toml").is_file());
|
||||
}
|
||||
|
||||
// ---- marketplace ------------------------------------------------------------------
|
||||
|
||||
/// `hello` as `acme.hello` at `version`, packed and signed with `key`.
|
||||
fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost) -> (PathBuf, IndexEntry) {
|
||||
let src = variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{version}"), |t| {
|
||||
t.replace("id = \"hello\"", "id = \"acme.hello\"")
|
||||
.replace("version = \"0.3.0\"", &format!("version = \"{version}\""))
|
||||
});
|
||||
let files = host.package_files_of(&src).unwrap();
|
||||
let digest = package::package_digest(&src, &files).unwrap();
|
||||
let sig = key.sign("acme", "acme.hello", version, &digest);
|
||||
let out = dir.join(format!("acme.hello-{version}.oiplugin"));
|
||||
package::pack(&src, &files, &out, Some(&sig)).unwrap();
|
||||
let entry = IndexEntry {
|
||||
id: "acme.hello".into(),
|
||||
name: "Hello".into(),
|
||||
version: version.into(),
|
||||
description: String::new(),
|
||||
authors: vec![],
|
||||
publisher: "acme".into(),
|
||||
download: out.file_name().unwrap().to_string_lossy().into_owned(),
|
||||
digest,
|
||||
capabilities: vec![],
|
||||
};
|
||||
(out, entry)
|
||||
}
|
||||
|
||||
fn write_index(
|
||||
dir: &Path,
|
||||
key: &PublisherKey,
|
||||
plugins: Vec<IndexEntry>,
|
||||
revoked: Vec<Revocation>,
|
||||
key_revoked: bool,
|
||||
) -> String {
|
||||
let index = Index {
|
||||
format: package::INDEX_FORMAT,
|
||||
publishers: vec![Publisher {
|
||||
id: "acme".into(),
|
||||
name: "ACME".into(),
|
||||
keys: vec![KeyEntry { key: key.public_base64(), revoked: key_revoked }],
|
||||
}],
|
||||
plugins,
|
||||
revoked,
|
||||
};
|
||||
let path = dir.join("index.json");
|
||||
std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
let url = write_index(&market, &key, vec![entry.clone()], vec![], false);
|
||||
|
||||
assert!(f.host.refresh_market().is_err(), "no marketplace configured yet");
|
||||
f.host.set_market_url(Some(url.clone()));
|
||||
let listed = f.host.refresh_market().unwrap();
|
||||
assert_eq!((listed[0].entry.id.as_str(), listed[0].installed.as_deref()), ("acme.hello", None));
|
||||
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert_eq!((preview.unsigned, preview.publisher.as_deref()), (false, Some("acme")));
|
||||
let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap();
|
||||
assert_eq!(info.publisher.as_deref(), Some("acme"));
|
||||
assert_eq!(f.host.market_plugins()[0].installed.as_deref(), Some("1.0.0"));
|
||||
|
||||
// A changed file breaks the signature: refused.
|
||||
let tampered = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let manifest = std::fs::read_to_string(tampered.join("plugin.toml")).unwrap();
|
||||
std::fs::write(tampered.join("plugin.toml"), manifest.replace("Sample plugin", "Totally safe plugin")).unwrap();
|
||||
let err = f.host.preview_install(&tampered).unwrap_err();
|
||||
assert!(err.message.contains("changed after signing"), "{}", err.message);
|
||||
|
||||
// Someone else's key, or another publisher's prefix: not trusted.
|
||||
let stranger = PublisherKey::from_seed([4; 32]);
|
||||
let (foreign, _) = signed_hello(&f.work, "1.0.1", &stranger, &f.host);
|
||||
let unpacked = f.host.unpack_package(&foreign).unwrap();
|
||||
assert!(f.host.preview_install(&unpacked).unwrap_err().message.contains("not one of"));
|
||||
|
||||
// The index revokes the installed version: disabled, the user told.
|
||||
f.events.lock().clear();
|
||||
let revoked =
|
||||
vec![Revocation { id: "acme.hello".into(), versions: vec!["1.0.0".into()], reason: "steals tokens".into() }];
|
||||
write_index(&market, &key, vec![entry.clone()], revoked, false);
|
||||
f.host.refresh_market().unwrap();
|
||||
let info = f.host.info_of("acme.hello").unwrap();
|
||||
assert_eq!((info.status, info.error.as_deref()), (PluginStatus::Failed, Some("revoked: steals tokens")));
|
||||
assert!(
|
||||
f.events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| matches!(e, PluginEvent::Notification { body, .. } if body.contains("steals tokens")))
|
||||
);
|
||||
assert!(f.host.market_plugins().is_empty(), "revoked versions are not offered");
|
||||
assert!(f.host.install_from_dir(&dir, &Consent::all()).is_err(), "nor installable");
|
||||
f.host.set_enabled("acme.hello", true).unwrap();
|
||||
assert_eq!(f.host.info_of("acme.hello").unwrap().status, PluginStatus::Failed, "enabling doesn't bring it back");
|
||||
|
||||
// Revocations hold across restarts (the index is kept).
|
||||
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
again.load_all();
|
||||
assert_eq!(again.info_of("acme.hello").unwrap().status, PluginStatus::Failed);
|
||||
|
||||
// A revoked signing key disables what it signed.
|
||||
write_index(&market, &key, vec![entry.clone()], vec![], true);
|
||||
f.host.refresh_market().unwrap();
|
||||
let info = f.host.info_of("acme.hello").unwrap();
|
||||
assert!(info.error.as_deref().unwrap_or("").contains("signing key was revoked"), "{:?}", info.error);
|
||||
|
||||
// Lifted: it runs again.
|
||||
write_index(&market, &key, vec![entry], vec![], false);
|
||||
f.host.refresh_market().unwrap();
|
||||
let info = f.host.info_of("acme.hello").unwrap();
|
||||
assert_eq!((info.status, info.error), (PluginStatus::Inactive, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_files_install_from_disk_unsigned() {
|
||||
let f = fixture();
|
||||
let src = variant(&misbehave(), &f.work, "pkg", |t| t.to_owned());
|
||||
let files = f.host.package_files_of(&src).unwrap();
|
||||
let out = f.work.join("misbehave-0.0.2.oiplugin");
|
||||
package::pack(&src, &files, &out, None).unwrap();
|
||||
let dir = f.host.unpack_package(&out).unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert_eq!((preview.unsigned, preview.publisher), (true, None));
|
||||
let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap();
|
||||
assert_eq!((info.status, info.publisher), (PluginStatus::Inactive, None));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
//! Code plugins of a remote project run on the
|
||||
//! server. The desktop's plugin host syncs its live
|
||||
//! code plugins over the remote protocol; Checkstyle then checks the file on
|
||||
//! the server's disk when it is saved, and secrets come from the desktop.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::IdeEvent;
|
||||
use ide_host::plugins::remote_host::{
|
||||
EVENT_NOTIFICATION, PackageSyncReport, PluginEventMessage, RemotePlugins, SECRET_REQUEST, SYNC_PACKAGES,
|
||||
SecretRequest,
|
||||
};
|
||||
use ide_host::plugins::{Consent, PluginEvent, PluginHost, PluginStatus, WorkbenchEvent};
|
||||
use ide_host::rpc::{self, Dispatcher, EventMessage, Invoke};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use ide_plugin::Limits;
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Client {
|
||||
events: Mutex<Vec<EventMessage>>,
|
||||
plugin_events: Mutex<Vec<PluginEventMessage>>,
|
||||
secret_requests: Mutex<Vec<SecretRequest>>,
|
||||
}
|
||||
|
||||
impl Handler for Client {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
match method {
|
||||
"event" => self.events.lock().push(serde_json::from_value(params).unwrap()),
|
||||
EVENT_NOTIFICATION => self.plugin_events.lock().push(serde_json::from_value(params).unwrap()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
if method == SECRET_REQUEST {
|
||||
let r: SecretRequest = serde_json::from_value(params).unwrap();
|
||||
let value = (r.name == "api-key").then(|| "sk-desktop".to_owned());
|
||||
self.secret_requests.lock().push(r);
|
||||
return Ok(serde_json::to_value(value).unwrap());
|
||||
}
|
||||
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result<Value, ide_core::IdeError> {
|
||||
let call = Invoke { window: "main".into(), cmd: cmd.into(), args };
|
||||
peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply)
|
||||
}
|
||||
|
||||
async fn sync(peer: &Peer, desktop: &PluginHost, files: &[&str]) -> PackageSyncReport {
|
||||
let files: serde_json::Map<String, Value> = files
|
||||
.iter()
|
||||
.map(|id| ((*id).to_owned(), serde_json::to_value(desktop.remote_package_files(id).unwrap()).unwrap()))
|
||||
.collect();
|
||||
let args = json!({ "packages": desktop.remote_packages(), "files": files });
|
||||
serde_json::from_value(invoke(peer, SYNC_PACKAGES, args).await.unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn official(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official").join(name)
|
||||
}
|
||||
|
||||
/// A test fixture plugin (`crates/ide-plugin/tests/fixtures/<name>`).
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures").join(name)
|
||||
}
|
||||
|
||||
/// A `checkstyle` that reports a tab on line 2 of the file it is given.
|
||||
fn fake_checkstyle(bin: &Path) {
|
||||
std::fs::create_dir_all(bin).unwrap();
|
||||
std::fs::write(
|
||||
bin.join("checkstyle"),
|
||||
"#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q '\t' \"$file\" || { echo '<checkstyle/>'; exit 0; }\n\
|
||||
echo '<?xml version=\"1.0\"?><checkstyle><file name=\"'\"$file\"'\">'\n\
|
||||
echo '<error line=\"2\" column=\"1\" severity=\"warning\" message=\"Line contains a tab character.\" source=\"com.puppycrawl.tools.checkstyle.checks.whitespace.FileTabCharacterCheck\"/>'\n\
|
||||
echo '</file></checkstyle>'\nexit 1\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(bin.join("checkstyle"), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
async fn until(what: &str, secs: u64, mut done: impl FnMut() -> bool) {
|
||||
for _ in 0..secs * 50 {
|
||||
if done() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
panic!("timed out waiting for {what}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = tmp.path().join("bin");
|
||||
fake_checkstyle(&bin);
|
||||
let path_var = {
|
||||
let bin = bin.clone();
|
||||
Arc::new(move || Some(bin.clone().into_os_string()))
|
||||
};
|
||||
|
||||
// The desktop: Checkstyle and AI Completion installed and granted.
|
||||
let desktop =
|
||||
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.unwrap();
|
||||
desktop.set_program_path(path_var.clone());
|
||||
for name in ["checkstyle", "ai-completion"] {
|
||||
let info = desktop.install_from_dir(&official(name), &Consent::all()).unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
}
|
||||
|
||||
// The server, with the project on "its" disk.
|
||||
// Canonical, as the UI reports paths (macOS: /var → /private/var).
|
||||
let project = tmp.path().join("remote/demo");
|
||||
std::fs::create_dir_all(project.join("src")).unwrap();
|
||||
let project = std::fs::canonicalize(project).unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer_slot.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
|
||||
plugins.host().set_program_path(path_var);
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let client = Arc::new(Client::default());
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
|
||||
|
||||
// Digests first; then the files of what the server lacks; then nothing to do.
|
||||
let first = sync(&peer, &desktop, &[]).await;
|
||||
assert_eq!(first.missing, ["openidea.ai-completion", "openidea.checkstyle"], "{first:?}");
|
||||
let second = sync(&peer, &desktop, &["openidea.ai-completion", "openidea.checkstyle"]).await;
|
||||
assert_eq!(second.installed.len(), 2, "{second:?}");
|
||||
assert!(second.failed.is_empty(), "{second:?}");
|
||||
assert_eq!(sync(&peer, &desktop, &[]).await, PackageSyncReport::default());
|
||||
let remote = plugins.host().info_of("openidea.checkstyle").unwrap();
|
||||
assert_eq!(remote.status, PluginStatus::Inactive, "{:?}", remote.error);
|
||||
assert!(remote.capabilities.iter().any(|c| c.id == "process:checkstyle" && c.granted));
|
||||
|
||||
// The remote window's UI routes the run-time commands to the server.
|
||||
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
|
||||
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
|
||||
|
||||
// Saving a file that exists only on the server: Checkstyle reads that file.
|
||||
let java = project.join("src/A.java");
|
||||
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
|
||||
let saved = WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned());
|
||||
invoke(&peer, "plugin_post_events", json!({ "events": [saved] })).await.unwrap();
|
||||
let published = || {
|
||||
client.events.lock().iter().find_map(|m| match &m.event {
|
||||
IdeEvent::Diagnostics { path, source, items } if source == "plugin:openidea.checkstyle" => {
|
||||
Some((path.0.clone(), items.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
until("remote checkstyle diagnostics", 20, || published().is_some()).await;
|
||||
let (path, items) = published().unwrap();
|
||||
assert_eq!(path, java);
|
||||
assert_eq!((items.len(), items[0].range.start.line), (1, 1));
|
||||
|
||||
// Secrets stay on the desktop: the server asks for them when used.
|
||||
let secrets = plugins.host().secrets("openidea.ai-completion").unwrap();
|
||||
assert_eq!(secrets, [("api-key".to_owned(), true)]);
|
||||
assert_eq!(client.secret_requests.lock()[0].plugin, "openidea.ai-completion");
|
||||
|
||||
// A command of a remote plugin, with this window as its client.
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({ "pluginId": "openidea.ai-completion", "commandId": "toggle", "arg": null, "editor": null }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
until("the command's notification", 5, || {
|
||||
client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::Notification { .. }))
|
||||
})
|
||||
.await;
|
||||
|
||||
// Disabled on the desktop: gone from the server.
|
||||
desktop.set_enabled("openidea.checkstyle", false).unwrap();
|
||||
let removed = sync(&peer, &desktop, &[]).await;
|
||||
assert_eq!(removed.removed, ["openidea.checkstyle"]);
|
||||
assert!(plugins.host().info_of("openidea.checkstyle").is_err());
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
|
||||
/// CSV Editor and a tool window with a terminal (the `jobs` fixture) in a
|
||||
/// remote project. The table edits the document on the server; the jobs are
|
||||
/// the server's `jobs` program's, and the 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("jobs"), "#!/bin/sh\n[ \"$1\" = list ] && echo remote-job\n").unwrap();
|
||||
std::fs::set_permissions(bin.join("jobs"), 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 dir in [official("csv-editor"), fixture("jobs")] {
|
||||
let info = desktop.install_from_dir(&dir, &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", "jobs"]).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");
|
||||
|
||||
// Jobs: the server's program; the terminal opens in this window.
|
||||
let jobs = |event: Value| json!({ "pluginId": "jobs", "view": "jobs", "doc": null, "event": event });
|
||||
let view =
|
||||
invoke(&peer, "plugin_view_render", json!({ "pluginId": "jobs", "view": "jobs", "doc": null })).await.unwrap();
|
||||
assert_eq!(view["body"]["value"][0]["label"], "remote-job", "{view}");
|
||||
invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "select", "key": "remote-job" }))).await.unwrap();
|
||||
invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "button", "id": "follow" }))).await.unwrap();
|
||||
until("the job's 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();
|
||||
}
|
||||
@@ -35,8 +35,16 @@ pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
|
||||
.with_platform_verifier()
|
||||
.map_err(|e| fail(e.to_string()))?
|
||||
.with_no_client_auth();
|
||||
// `localhost` is tried on IPv4 first: local servers (Ollama, dev servers)
|
||||
// usually listen there, and on Windows a refused `::1` costs ~2 s before
|
||||
// the system resolver's order falls back. Ports come from the URL.
|
||||
let loopback = [
|
||||
std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0)),
|
||||
std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 0)),
|
||||
];
|
||||
let builder = reqwest::Client::builder()
|
||||
.tls_backend_preconfigured(tls)
|
||||
.resolve_to_addrs("localhost", &loopback)
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")));
|
||||
with_proxy(builder, proxy).build().map_err(|e| fail(e.to_string()))
|
||||
@@ -113,7 +121,11 @@ mod tests {
|
||||
|
||||
/// A one-shot HTTP server on loopback: answers `200` and hands back the request head.
|
||||
fn server() -> (u16, std::thread::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
server_on("127.0.0.1:0")
|
||||
}
|
||||
|
||||
fn server_on(addr: &str) -> (u16, std::thread::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind(addr).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
@@ -141,6 +153,21 @@ mod tests {
|
||||
check_connection(proxy, url, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
/// `localhost` reaches a server on either loopback address.
|
||||
#[tokio::test]
|
||||
async fn localhost_reaches_ipv4_and_ipv6_servers() {
|
||||
let direct = ProxySettings::default();
|
||||
let (port, v4) = server();
|
||||
assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK");
|
||||
v4.join().unwrap();
|
||||
// Hosts without IPv6 loopback skip this half.
|
||||
if TcpListener::bind("[::1]:0").is_ok() {
|
||||
let (port, v6) = server_on("[::1]:0");
|
||||
assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK");
|
||||
v6.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_for_every_mode() {
|
||||
for mode in [ProxyMode::None, ProxyMode::Auto, ProxyMode::Manual] {
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-extension.workspace = true
|
||||
ide-text.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -8,16 +8,20 @@ use ide_core::{LanguageId, Range, SymbolKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod markers;
|
||||
pub mod plugin_point;
|
||||
mod registry;
|
||||
mod syntax;
|
||||
pub use markers::marked_root;
|
||||
pub use plugin_point::PluginLanguages;
|
||||
pub use registry::{FileTypes, LanguageRegistry};
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES,
|
||||
MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats,
|
||||
SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
|
||||
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
|
||||
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
|
||||
WebReferenceKind, merge_visibility, set_external_syntax,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
@@ -138,6 +142,9 @@ pub struct LanguageServerConfig {
|
||||
/// Register source files with tsserver when no project configuration exists.
|
||||
#[serde(default)]
|
||||
pub tsserver_project: bool,
|
||||
/// Set on plugin languages: start exactly this approved program.
|
||||
#[serde(skip)]
|
||||
pub pinned: Option<ProgramPin>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -157,6 +164,41 @@ pub struct DebuggerConfig {
|
||||
pub connect: Option<String>,
|
||||
#[serde(default)]
|
||||
pub install_hint: Option<String>,
|
||||
/// Set on plugin languages: start exactly this approved program.
|
||||
#[serde(skip)]
|
||||
pub pinned: Option<ProgramPin>,
|
||||
}
|
||||
|
||||
/// The program a plugin's `process:` grant approved:
|
||||
/// a plugin language's server or debug adapter starts from this exact path,
|
||||
/// and only while the file still has the approved content. Set by the plugin
|
||||
/// host, never read from a definition.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ProgramPin {
|
||||
pub path: PathBuf,
|
||||
/// blake3 of the file when approved.
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
impl ProgramPin {
|
||||
/// Approves `path` as it is now.
|
||||
pub fn approve(path: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||
let path = path.into();
|
||||
let hash = ide_extension::ledger::file_hash(&path)?;
|
||||
Ok(Self { path, hash })
|
||||
}
|
||||
|
||||
/// Checks the file right before it is started; the error tells the user what to do.
|
||||
pub fn verify(&self) -> Result<&Path, String> {
|
||||
match ide_extension::ledger::file_hash(&self.path) {
|
||||
Ok(hash) if hash == self.hash => Ok(&self.path),
|
||||
Ok(_) => Err(format!(
|
||||
"{} changed since it was approved; review the plugin's permissions in Settings › Plugins",
|
||||
self.path.display()
|
||||
)),
|
||||
Err(e) => Err(format!("{}: {e}", self.path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_transport() -> String {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! The `languages` extension point: language definitions contributed by
|
||||
//! plugins, in the same format as `languages/<id>.toml`.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.languages]]
|
||||
//! path = "languages/solidity.toml"
|
||||
//! ```
|
||||
//!
|
||||
//! A definition that starts programs (`language-servers.command`,
|
||||
//! `debugger.command`) needs `process:<program>` for each: a declaration is a
|
||||
//! capability too, or registering languages would bypass the sandbox.
|
||||
//! Committed definitions live in [`PluginLanguages`], an overlay over the
|
||||
//! built-in registry (built-in definitions win on id clashes): see
|
||||
//! [`crate::LanguageRegistry::attach_plugins`]. A server or debugger whose
|
||||
//! program is not granted is left out; the language itself still works.
|
||||
|
||||
use crate::{LanguageConfig, ProgramPin};
|
||||
use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode};
|
||||
use parking_lot::RwLock;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub const POINT_ID: &str = "languages";
|
||||
|
||||
/// Decides whether a plugin may start a program: the approved file for a
|
||||
/// granted `process:` capability, or `None`.
|
||||
pub type ProcessPolicy = dyn Fn(&str, &Capability) -> Option<ProgramPin> + Send + Sync;
|
||||
|
||||
/// Languages of enabled plugins, in commit order.
|
||||
#[derive(Default)]
|
||||
pub struct PluginLanguages {
|
||||
items: RwLock<Vec<(u64, String, LanguageConfig)>>,
|
||||
seq: AtomicU64,
|
||||
revision: AtomicU64,
|
||||
policy: RwLock<Option<Arc<ProcessPolicy>>>,
|
||||
}
|
||||
|
||||
impl PluginLanguages {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<LanguageConfig> {
|
||||
self.items.read().iter().map(|(_, _, c)| c.clone()).collect()
|
||||
}
|
||||
|
||||
/// `(plugin id, definition)` pairs.
|
||||
pub fn with_owners(&self) -> Vec<(String, LanguageConfig)> {
|
||||
self.items.read().iter().map(|(_, p, c)| (p.clone(), c.clone())).collect()
|
||||
}
|
||||
|
||||
/// The plugin that contributes language `id`.
|
||||
pub fn owner_of(&self, id: &ide_core::LanguageId) -> Option<String> {
|
||||
self.items.read().iter().find(|(_, _, c)| &c.id == id).map(|(_, p, _)| p.clone())
|
||||
}
|
||||
|
||||
/// Changes whenever [`effective`](Self::effective) may give something else.
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Who may start what. Without a policy no plugin program starts.
|
||||
pub fn set_process_policy(&self, policy: Arc<ProcessPolicy>) {
|
||||
*self.policy.write() = Some(policy);
|
||||
self.invalidate();
|
||||
}
|
||||
|
||||
/// Grants changed: registries recompute [`effective`](Self::effective).
|
||||
pub fn invalidate(&self) {
|
||||
self.revision.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
|
||||
/// The definitions as they take effect: servers and the debugger whose
|
||||
/// program is granted are pinned to the approved file, the others are
|
||||
/// left out.
|
||||
pub fn effective(&self) -> Vec<LanguageConfig> {
|
||||
let policy = self.policy.read().clone();
|
||||
let pin = |plugin: &str, program: &str| {
|
||||
policy.as_ref().and_then(|p| p(plugin, &Capability::Process(program.to_owned())))
|
||||
};
|
||||
self.items
|
||||
.read()
|
||||
.iter()
|
||||
.map(|(_, plugin, cfg)| {
|
||||
let mut cfg = cfg.clone();
|
||||
cfg.language_servers.retain_mut(|s| {
|
||||
s.pinned = pin(plugin, &s.command);
|
||||
s.pinned.is_some()
|
||||
});
|
||||
let debugger_pin = cfg.debugger.as_ref().and_then(|d| pin(plugin, &d.command));
|
||||
match (&mut cfg.debugger, debugger_pin) {
|
||||
(Some(d), Some(p)) => d.pinned = Some(p),
|
||||
(debugger, _) => *debugger = None,
|
||||
}
|
||||
cfg
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Adds a definition until the returned handle is dropped.
|
||||
pub fn add(self: &Arc<Self>, plugin: String, cfg: LanguageConfig) -> Disposable {
|
||||
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
self.items.write().push((seq, plugin, cfg));
|
||||
self.revision.fetch_add(1, Ordering::Release);
|
||||
let weak = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(this) = weak.upgrade() {
|
||||
this.items.write().retain(|(s, _, _)| *s != seq);
|
||||
this.revision.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LanguagesPoint {
|
||||
store: Arc<PluginLanguages>,
|
||||
}
|
||||
|
||||
impl LanguagesPoint {
|
||||
pub fn new(store: Arc<PluginLanguages>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
/// A `languages/<id>.toml`-style file in the package.
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
/// Parses and checks one language definition.
|
||||
pub fn parse_definition(src: &str) -> Result<LanguageConfig, String> {
|
||||
let cfg: LanguageConfig = toml::from_str(src).map_err(|e| format!("invalid language definition: {e}"))?;
|
||||
validate_definition(&cfg)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Checks a definition that did not come from a package file (a remote sync).
|
||||
pub fn validate_definition(cfg: &LanguageConfig) -> Result<(), String> {
|
||||
let id = cfg.id.as_str();
|
||||
if id.is_empty() || cfg.name.trim().is_empty() {
|
||||
return Err("language `id` and `name` must not be empty".into());
|
||||
}
|
||||
if !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+')) || id.len() > 64 {
|
||||
return Err(format!("invalid language id {id:?}"));
|
||||
}
|
||||
if let Some(first) = &cfg.first_line {
|
||||
regex_lite::Regex::new(first).map_err(|e| format!("invalid first-line pattern: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The programs a definition starts, as `process:` capabilities.
|
||||
pub fn required_capabilities(cfg: &LanguageConfig) -> Vec<Capability> {
|
||||
let mut caps: Vec<Capability> = Vec::new();
|
||||
let servers = cfg.language_servers.iter().map(|s| s.command.as_str());
|
||||
for program in servers.chain(cfg.debugger.iter().map(|d| d.command.as_str())) {
|
||||
let cap = Capability::Process(program.to_owned());
|
||||
if !program.trim().is_empty() && !caps.contains(&cap) {
|
||||
caps.push(cap);
|
||||
}
|
||||
}
|
||||
caps
|
||||
}
|
||||
|
||||
impl ExtensionPoint for LanguagesPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
POINT_ID
|
||||
}
|
||||
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Workspace
|
||||
}
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let decl: Decl = decode(POINT_ID, decl)?;
|
||||
let src = pkg.read_text(&decl.path)?;
|
||||
let cfg = parse_definition(&src).map_err(|e| format!("{}: {e}", decl.path.display()))?;
|
||||
let caps = required_capabilities(&cfg);
|
||||
let (store, plugin, key, label) = (
|
||||
self.store.clone(),
|
||||
pkg.id().to_owned(),
|
||||
cfg.id.to_string(),
|
||||
format!("{} (.{})", cfg.name, cfg.extensions.join(", .")),
|
||||
);
|
||||
let mut prepared = Prepared::new(label, move || Ok(store.add(plugin, cfg))).key(key).file(decl.path);
|
||||
for cap in caps {
|
||||
prepared = prepared.capability(cap);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_extension::{ExtensionRegistry, Manifest};
|
||||
|
||||
#[test]
|
||||
fn languages_point_registers_and_requires_process_capabilities() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("l")).unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("l/sol.toml"),
|
||||
"id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\
|
||||
[[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\nargs = [\"--lsp\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
|
||||
[[contributes.languages]]\npath = \"l/sol.toml\"";
|
||||
let pkg = Package::new(Manifest::parse(src).unwrap(), tmp.path().to_path_buf());
|
||||
let store = PluginLanguages::new();
|
||||
let reg = ExtensionRegistry::new();
|
||||
reg.register(Arc::new(LanguagesPoint::new(store.clone()))).unwrap();
|
||||
let set = reg.prepare(&pkg).unwrap();
|
||||
assert_eq!(set.capabilities, [Capability::Process("solc".into())]);
|
||||
assert_eq!(set.files, [PathBuf::from("l/sol.toml")]);
|
||||
reg.commit(set).unwrap();
|
||||
assert_eq!(store.all()[0].id.as_str(), "solidity");
|
||||
reg.dispose_plugin("p");
|
||||
assert!(store.all().is_empty());
|
||||
|
||||
let bad = |def: &str| parse_definition(def).unwrap_err();
|
||||
assert!(bad("not toml [").contains("invalid language"));
|
||||
assert!(bad("id = \"\"\nname = \"x\"").contains("must not be empty"));
|
||||
assert!(bad("id = \"a b\"\nname = \"x\"").contains("invalid language id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_overlay_follows_the_store_and_the_grants() {
|
||||
use crate::LanguageRegistry;
|
||||
use ide_core::LanguageId;
|
||||
use std::path::Path;
|
||||
|
||||
let base = LanguageRegistry::load(None).unwrap();
|
||||
let store = PluginLanguages::new();
|
||||
base.attach_plugins(store.clone());
|
||||
// Projects fork before plugins load: the fork follows the store anyway.
|
||||
let project = base.fork();
|
||||
assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text());
|
||||
|
||||
let sol = parse_definition(
|
||||
"id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\
|
||||
[[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\n\
|
||||
[debugger]\nadapter = \"soldbg\"\ncommand = \"soldbg\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let rust_clash = parse_definition("id = \"rust\"\nname = \"Evil Rust\"\nextensions = [\"rs\"]").unwrap();
|
||||
let ext_clash = parse_definition(
|
||||
"id = \"rusty\"\nname = \"Rusty\"\nextensions = [\"rs\", \"rsy\"]\nfilenames = [\"Cargo.lock\"]",
|
||||
)
|
||||
.unwrap();
|
||||
let _sol = store.add("acme.sol".into(), sol);
|
||||
let _clash = store.add("acme.evil".into(), rust_clash);
|
||||
let _ext = store.add("acme.evil".into(), ext_clash);
|
||||
|
||||
assert_eq!(project.detect(Path::new("a.sol"), None).as_str(), "solidity");
|
||||
let rust = project.get(&LanguageId::new("rust")).unwrap();
|
||||
assert_eq!(rust.name, "Rust", "built-in definitions win on id clashes");
|
||||
assert_eq!(project.detect(Path::new("main.rs"), None).as_str(), "rust", "and keep their extensions");
|
||||
assert_ne!(project.detect(Path::new("Cargo.lock"), None).as_str(), "rusty", "and file names");
|
||||
assert_eq!(project.detect(Path::new("x.rsy"), None).as_str(), "rusty");
|
||||
// No policy: the language works, its programs do not start.
|
||||
let cfg = project.get(&LanguageId::new("solidity")).unwrap();
|
||||
assert!(cfg.language_servers.is_empty() && cfg.debugger.is_none());
|
||||
|
||||
let pin = ProgramPin { path: "/opt/solc".into(), hash: "h".into() };
|
||||
let granted = pin.clone();
|
||||
store.set_process_policy(Arc::new(move |plugin, cap| {
|
||||
(plugin == "acme.sol" && *cap == Capability::Process("solc".into())).then(|| granted.clone())
|
||||
}));
|
||||
let cfg = project.get(&LanguageId::new("solidity")).unwrap();
|
||||
assert_eq!(cfg.language_servers[0].pinned.as_ref(), Some(&pin));
|
||||
assert!(cfg.debugger.is_none(), "soldbg was not granted");
|
||||
|
||||
drop(_sol);
|
||||
assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text());
|
||||
assert!(base.get(&LanguageId::new("solidity")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_pins_check_the_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = tmp.path().join("tool");
|
||||
std::fs::write(&bin, "v1").unwrap();
|
||||
let pin = ProgramPin { path: bin.clone(), hash: ide_extension::ledger::file_hash(&bin).unwrap() };
|
||||
assert_eq!(pin.verify().unwrap(), bin);
|
||||
std::fs::write(&bin, "v2").unwrap();
|
||||
assert!(pin.verify().unwrap_err().contains("changed since it was approved"));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::LanguageConfig;
|
||||
use crate::plugin_point::PluginLanguages;
|
||||
use ide_core::settings::{FileSettings, FileTypeAssociation};
|
||||
use ide_core::{IdeError, IdeResult, LanguageId};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Built-in definitions, embedded at compile time. Add new languages here.
|
||||
const BUILTIN: &[(&str, &str)] = &[
|
||||
@@ -88,12 +89,22 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
];
|
||||
|
||||
pub struct LanguageRegistry {
|
||||
languages: Vec<LanguageConfig>,
|
||||
/// Lookup tables of the definitions with the user's file types applied.
|
||||
tables: RwLock<Arc<Tables>>,
|
||||
/// Built-in definitions and the user's `languages/*.toml`.
|
||||
base: Vec<LanguageConfig>,
|
||||
/// Languages of enabled plugins, shared by every fork; `base` wins on id clashes.
|
||||
plugins: OnceLock<Arc<PluginLanguages>>,
|
||||
state: RwLock<Arc<State>>,
|
||||
file_types: RwLock<FileTypes>,
|
||||
}
|
||||
|
||||
/// The effective definitions and their lookup tables (the user's file types applied).
|
||||
struct State {
|
||||
languages: Vec<LanguageConfig>,
|
||||
tables: Tables,
|
||||
/// [`PluginLanguages::revision`] they were built from.
|
||||
plugins_revision: u64,
|
||||
}
|
||||
|
||||
/// The user's file type associations (Settings › Editor › File Types) and
|
||||
/// per-file overrides (Override File Type), resolved for one project.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -247,12 +258,73 @@ impl LanguageRegistry {
|
||||
}
|
||||
}
|
||||
let tables = Tables::build(&languages, &FileTypes::default());
|
||||
Self { languages, tables: RwLock::new(Arc::new(tables)), file_types: RwLock::default() }
|
||||
let state = State { languages: languages.clone(), tables, plugins_revision: 0 };
|
||||
Self {
|
||||
base: languages,
|
||||
plugins: OnceLock::new(),
|
||||
state: RwLock::new(Arc::new(state)),
|
||||
file_types: RwLock::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the languages of enabled plugins (once, before projects fork the
|
||||
/// registry). They follow the store: installing, disabling or changing a
|
||||
/// grant takes effect on the next lookup in every fork.
|
||||
pub fn attach_plugins(&self, plugins: Arc<PluginLanguages>) {
|
||||
if self.plugins.set(plugins).is_err() {
|
||||
tracing::warn!("plugin languages are already attached");
|
||||
}
|
||||
}
|
||||
|
||||
/// A registry with the same definitions and no user file types (one per project).
|
||||
pub fn fork(&self) -> Self {
|
||||
Self::from_configs(self.languages.clone())
|
||||
let fork = Self::from_configs(self.base.clone());
|
||||
if let Some(p) = self.plugins.get() {
|
||||
let _ = fork.plugins.set(p.clone());
|
||||
}
|
||||
fork
|
||||
}
|
||||
|
||||
/// The current effective state, rebuilt when the plugin languages changed.
|
||||
fn state(&self) -> Arc<State> {
|
||||
let Some(plugins) = self.plugins.get() else { return self.state.read().clone() };
|
||||
let revision = plugins.revision();
|
||||
{
|
||||
let state = self.state.read();
|
||||
if state.plugins_revision == revision {
|
||||
return state.clone();
|
||||
}
|
||||
}
|
||||
let file_types = self.file_types.read();
|
||||
let mut state = self.state.write();
|
||||
if state.plugins_revision != revision {
|
||||
*state = Arc::new(self.build(&file_types, Some(revision)));
|
||||
}
|
||||
state.clone()
|
||||
}
|
||||
|
||||
fn build(&self, file_types: &FileTypes, revision: Option<u64>) -> State {
|
||||
let mut languages = self.base.clone();
|
||||
let mut plugins_revision = 0;
|
||||
if let Some(plugins) = self.plugins.get() {
|
||||
plugins_revision = revision.unwrap_or_else(|| plugins.revision());
|
||||
// A plugin adds languages; it can't take over a built-in or user
|
||||
// definition's id, extensions or file names (File Types can).
|
||||
let base_ext: std::collections::HashSet<String> =
|
||||
self.base.iter().flat_map(|l| &l.extensions).map(|e| e.to_ascii_lowercase()).collect();
|
||||
let base_names: std::collections::HashSet<&str> =
|
||||
self.base.iter().flat_map(|l| &l.filenames).map(String::as_str).collect();
|
||||
for mut cfg in plugins.effective() {
|
||||
if languages.iter().any(|l| l.id == cfg.id) {
|
||||
continue;
|
||||
}
|
||||
cfg.extensions.retain(|e| !base_ext.contains(&e.to_ascii_lowercase()));
|
||||
cfg.filenames.retain(|n| !base_names.contains(n.as_str()));
|
||||
languages.push(cfg);
|
||||
}
|
||||
}
|
||||
let tables = Tables::build(&languages, file_types);
|
||||
State { languages, tables, plugins_revision }
|
||||
}
|
||||
|
||||
/// A copy with the same definitions and file types (to compare detection before / after a change).
|
||||
@@ -268,17 +340,19 @@ impl LanguageRegistry {
|
||||
if *current == file_types {
|
||||
return false;
|
||||
}
|
||||
*self.tables.write() = Arc::new(Tables::build(&self.languages, &file_types));
|
||||
let state = self.build(&file_types, None);
|
||||
*self.state.write() = Arc::new(state);
|
||||
*current = file_types;
|
||||
true
|
||||
}
|
||||
|
||||
/// Built-in, user and plugin definitions.
|
||||
pub fn all(&self) -> Vec<LanguageConfig> {
|
||||
self.languages.clone()
|
||||
self.state().languages.clone()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &LanguageId) -> Option<LanguageConfig> {
|
||||
self.languages.iter().find(|l| &l.id == id).cloned()
|
||||
self.state().languages.iter().find(|l| &l.id == id).cloned()
|
||||
}
|
||||
|
||||
/// The `framework-ui-modes` mode of a `language` file at `path`: the first
|
||||
@@ -286,7 +360,8 @@ impl LanguageRegistry {
|
||||
/// directory without one: loose files). `None` keeps the language's own
|
||||
/// `ui-mode`.
|
||||
pub fn framework_ui_mode(&self, language: &LanguageId, path: &Path, project_root: Option<&Path>) -> Option<String> {
|
||||
let cfg = self.languages.iter().find(|l| &l.id == language)?;
|
||||
let state = self.state();
|
||||
let cfg = state.languages.iter().find(|l| &l.id == language)?;
|
||||
cfg.framework_ui_modes
|
||||
.iter()
|
||||
.find(|f| crate::marked_root(project_root, path, &f.root_markers).is_some())
|
||||
@@ -296,7 +371,8 @@ impl LanguageRegistry {
|
||||
/// Creating or removing `path` can change a file's `framework_ui_mode`:
|
||||
/// it is a framework's root marker.
|
||||
pub fn is_framework_marker(&self, path: &Path) -> bool {
|
||||
self.languages
|
||||
self.state()
|
||||
.languages
|
||||
.iter()
|
||||
.flat_map(|l| &l.framework_ui_modes)
|
||||
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
|
||||
@@ -307,33 +383,34 @@ impl LanguageRegistry {
|
||||
/// then hashbang / first line; falls back to plaintext. The user's patterns
|
||||
/// win over the definitions'.
|
||||
pub fn detect(&self, path: &Path, first_line: Option<&str>) -> LanguageId {
|
||||
let t = self.tables.read().clone();
|
||||
let state = self.state();
|
||||
let (t, languages) = (&state.tables, &state.languages);
|
||||
if !t.overrides.is_empty()
|
||||
&& let Some(&i) = t.overrides.get(path)
|
||||
{
|
||||
return self.languages[i].id.clone();
|
||||
return languages[i].id.clone();
|
||||
}
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if let Some(&i) = t.by_name.get(file_name) {
|
||||
return self.languages[i].id.clone();
|
||||
return languages[i].id.clone();
|
||||
}
|
||||
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
|
||||
return self.languages[*i].id.clone();
|
||||
return languages[*i].id.clone();
|
||||
}
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
let mut rest = lower.as_str();
|
||||
while let Some(dot) = rest.find('.') {
|
||||
rest = &rest[dot + 1..];
|
||||
if let Some(&i) = t.by_ext.get(rest) {
|
||||
return self.languages[i].id.clone();
|
||||
return languages[i].id.clone();
|
||||
}
|
||||
}
|
||||
if let Some(line) = first_line {
|
||||
if let Some((_, i)) = t.hashbangs.iter().find(|(h, _)| hashbang_runs(line, h)) {
|
||||
return self.languages[*i].id.clone();
|
||||
return languages[*i].id.clone();
|
||||
}
|
||||
if let Some((_, i)) = t.first_line.iter().find(|(re, _)| re.is_match(line)) {
|
||||
return self.languages[*i].id.clone();
|
||||
return languages[*i].id.clone();
|
||||
}
|
||||
}
|
||||
LanguageId::plain_text()
|
||||
|
||||
@@ -197,6 +197,7 @@ impl SyntaxCache {
|
||||
let query = g.outline_query()?;
|
||||
Some(outline::outline(g, query, tree, text))
|
||||
})
|
||||
.or_else(|| super::external::outline(lang, text))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -211,6 +212,7 @@ impl SyntaxCache {
|
||||
self.with_tree(doc, lang, version, text, |g, tree, text| {
|
||||
Some(folding::folding(g.spec, g.kinds(), tree, text, self.syntax.comment_tokens(lang)))
|
||||
})
|
||||
.or_else(|| super::external::folding(lang, text))
|
||||
.or_else(|| Some(self.syntax.custom_folding(lang, text)))
|
||||
},
|
||||
)
|
||||
@@ -227,6 +229,7 @@ impl SyntaxCache {
|
||||
self.with_tree(doc, lang, version, text, |g, tree, text| {
|
||||
Some(highlight::highlights(g.highlight_query()?, tree, text))
|
||||
})
|
||||
.or_else(|| super::external::highlights(lang, text))
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Syntax from outside this crate for languages without a built-in grammar: a
|
||||
//! plugin's `syntax-provider`. Providers return flat
|
||||
//! results in UTF-8 byte offsets; they are validated and converted here into
|
||||
//! the same [`OutlineItem`], [`FoldRange`] and highlight data the grammars
|
||||
//! produce, so the editor can't tell the difference. Results are memoized per
|
||||
//! document version by the [`super::SyntaxCache`] like the grammars' answers.
|
||||
|
||||
use super::highlight::SyntaxHighlightKind;
|
||||
use super::lines::LineIndex;
|
||||
use crate::{FoldKind, FoldRange};
|
||||
use ide_core::{LanguageId, OutlineItem, SymbolKind};
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A highlighted range (UTF-8 byte offsets); `kind` is a kebab-case
|
||||
/// [`SyntaxHighlightKind`] name.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalToken {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// A structure-view symbol (UTF-8 byte offsets); `parent` indexes an earlier
|
||||
/// symbol of the same list.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalSymbol {
|
||||
pub name: String,
|
||||
pub detail: Option<String>,
|
||||
pub kind: String,
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub name_start: u32,
|
||||
pub name_end: u32,
|
||||
pub parent: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalFold {
|
||||
pub start_line: u32,
|
||||
pub end_line: u32,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// Answers for languages it provides, `None` for the others.
|
||||
pub trait ExternalSyntax: Send + Sync {
|
||||
fn highlights(&self, lang: &LanguageId, text: &str) -> Option<Vec<ExternalToken>>;
|
||||
fn outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<ExternalSymbol>>;
|
||||
fn folding(&self, lang: &LanguageId, text: &str) -> Option<Vec<ExternalFold>>;
|
||||
}
|
||||
|
||||
static PROVIDER: RwLock<Option<Arc<dyn ExternalSyntax>>> = RwLock::new(None);
|
||||
|
||||
/// Installs (or removes) the provider every project's syntax cache consults.
|
||||
pub fn set_external_syntax(provider: Option<Arc<dyn ExternalSyntax>>) {
|
||||
*PROVIDER.write() = provider;
|
||||
}
|
||||
|
||||
fn provider() -> Option<Arc<dyn ExternalSyntax>> {
|
||||
PROVIDER.read().clone()
|
||||
}
|
||||
|
||||
fn clamp(text: &str, offset: u32) -> usize {
|
||||
let mut at = (offset as usize).min(text.len());
|
||||
while !text.is_char_boundary(at) {
|
||||
at -= 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// `[from, to, kind]` triples in UTF-16 offsets, sorted and non-overlapping.
|
||||
pub(crate) fn highlights(lang: &LanguageId, text: &str) -> Option<Vec<u32>> {
|
||||
let mut tokens = provider()?.highlights(lang, text)?;
|
||||
tokens.sort_by_key(|t| (t.start, t.end));
|
||||
let lines = LineIndex::new(text);
|
||||
let mut data = Vec::with_capacity(tokens.len() * 3);
|
||||
let mut covered = 0usize;
|
||||
for t in tokens {
|
||||
let Some(kind) = SyntaxHighlightKind::from_name(&t.kind) else { continue };
|
||||
let (start, end) = (clamp(text, t.start), clamp(text, t.end));
|
||||
if end <= start || start < covered {
|
||||
continue;
|
||||
}
|
||||
covered = end;
|
||||
let index = SyntaxHighlightKind::ALL.iter().position(|k| *k == kind).unwrap_or_default() as u32;
|
||||
data.extend([lines.utf16_at(start) as u32, lines.utf16_at(end) as u32, index]);
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
|
||||
fn symbol_kind(name: &str) -> SymbolKind {
|
||||
let camel: String = {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
let mut upper = false;
|
||||
for c in name.chars() {
|
||||
if c == '-' {
|
||||
upper = true;
|
||||
} else if upper {
|
||||
out.push(c.to_ascii_uppercase());
|
||||
upper = false;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
serde_json::from_value(serde_json::Value::String(camel)).unwrap_or(SymbolKind::Unknown)
|
||||
}
|
||||
|
||||
pub(crate) fn outline(lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
|
||||
let symbols = provider()?.outline(lang, text)?;
|
||||
let lines = LineIndex::new(text);
|
||||
// Built bottom-up: children are attached to their parent (an earlier index).
|
||||
let mut items: Vec<Option<OutlineItem>> = Vec::with_capacity(symbols.len());
|
||||
let mut parents: Vec<Option<usize>> = Vec::with_capacity(symbols.len());
|
||||
for (i, s) in symbols.iter().enumerate() {
|
||||
let (start, end) = (clamp(text, s.start), clamp(text, s.end.max(s.start)));
|
||||
let name_start = clamp(text, s.name_start).clamp(start, end);
|
||||
let name_end = clamp(text, s.name_end).clamp(name_start, end);
|
||||
items.push(Some(OutlineItem {
|
||||
name: s.name.clone(),
|
||||
detail: s.detail.clone(),
|
||||
kind: symbol_kind(&s.kind),
|
||||
range: lines.range(start, end),
|
||||
selection_range: lines.range(name_start, name_end),
|
||||
children: Vec::new(),
|
||||
visibility: None,
|
||||
}));
|
||||
parents.push(s.parent.map(|p| p as usize).filter(|&p| p < i));
|
||||
}
|
||||
for i in (0..items.len()).rev() {
|
||||
if let Some(p) = parents[i] {
|
||||
let child = items[i].take().expect("each item is moved once");
|
||||
if let Some(parent) = items[p].as_mut() {
|
||||
parent.children.insert(0, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(items.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
pub(crate) fn folding(lang: &LanguageId, text: &str) -> Option<Vec<FoldRange>> {
|
||||
let folds = provider()?.folding(lang, text)?;
|
||||
let last_line = LineIndex::new(text).line_of(text.len()) as u32;
|
||||
let mut out: Vec<FoldRange> = folds
|
||||
.into_iter()
|
||||
.filter(|f| f.start_line < f.end_line && f.end_line <= last_line)
|
||||
.map(|f| FoldRange {
|
||||
start_line: f.start_line,
|
||||
end_line: f.end_line,
|
||||
kind: match f.kind.as_str() {
|
||||
"comment" => FoldKind::Comment,
|
||||
"imports" => FoldKind::Imports,
|
||||
_ => FoldKind::Region,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|f| (f.start_line, std::cmp::Reverse(f.end_line)));
|
||||
out.dedup_by_key(|f| (f.start_line, f.end_line));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Fake;
|
||||
|
||||
impl ExternalSyntax for Fake {
|
||||
fn highlights(&self, lang: &LanguageId, _: &str) -> Option<Vec<ExternalToken>> {
|
||||
(lang.as_str() == "fake").then(|| {
|
||||
vec![
|
||||
ExternalToken { start: 9, end: 12, kind: "keyword".into() },
|
||||
// "é" is 2 bytes, 1 UTF-16 unit.
|
||||
ExternalToken { start: 0, end: 6, kind: "string".into() },
|
||||
ExternalToken { start: 2, end: 4, kind: "number".into() }, // overlaps: dropped
|
||||
ExternalToken { start: 13, end: 14, kind: "nonsense".into() },
|
||||
ExternalToken { start: 7, end: 99, kind: "doc-comment".into() },
|
||||
]
|
||||
})
|
||||
}
|
||||
fn outline(&self, lang: &LanguageId, _: &str) -> Option<Vec<ExternalSymbol>> {
|
||||
let sym = |name: &str, kind: &str, start, end, parent| ExternalSymbol {
|
||||
name: name.into(),
|
||||
detail: None,
|
||||
kind: kind.into(),
|
||||
start,
|
||||
end,
|
||||
name_start: start,
|
||||
name_end: start + 1,
|
||||
parent,
|
||||
};
|
||||
(lang.as_str() == "fake").then(|| {
|
||||
vec![
|
||||
sym("C", "class", 0, 14, None),
|
||||
sym("f", "function", 8, 12, Some(0)),
|
||||
sym("g", "event", 12, 13, Some(0)),
|
||||
sym("bad", "method", 1, 2, Some(9)),
|
||||
]
|
||||
})
|
||||
}
|
||||
fn folding(&self, lang: &LanguageId, _: &str) -> Option<Vec<ExternalFold>> {
|
||||
(lang.as_str() == "fake").then(|| {
|
||||
vec![
|
||||
ExternalFold { start_line: 0, end_line: 1, kind: "comment".into() },
|
||||
ExternalFold { start_line: 1, end_line: 1, kind: "region".into() },
|
||||
ExternalFold { start_line: 0, end_line: 9, kind: "region".into() },
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_and_validates_provider_results() {
|
||||
set_external_syntax(Some(Arc::new(Fake)));
|
||||
let text = "\"héllo\"\nfn x(){}";
|
||||
let lang = LanguageId::new("fake");
|
||||
assert!(highlights(&LanguageId::new("other"), text).is_none());
|
||||
let data = highlights(&lang, text).unwrap();
|
||||
let string = SyntaxHighlightKind::ALL.iter().position(|k| *k == SyntaxHighlightKind::String).unwrap() as u32;
|
||||
let doc = SyntaxHighlightKind::ALL.iter().position(|k| *k == SyntaxHighlightKind::DocComment).unwrap() as u32;
|
||||
// UTF-16: `"héllo"` is 7 units (bytes 0..8 → 0..7); byte 6 is inside "o\"" → 5.
|
||||
assert_eq!(&data[..3], &[0, 5, string]);
|
||||
assert_eq!(&data[3..], &[6, 16, doc], "clamped to the text, keyword dropped as overlapping");
|
||||
|
||||
let outline = outline(&lang, text).unwrap();
|
||||
assert_eq!(outline.len(), 2, "orphan with a bad parent stays top-level");
|
||||
assert_eq!(outline[0].kind, SymbolKind::Class);
|
||||
assert_eq!(
|
||||
outline[0].children.iter().map(|c| c.kind).collect::<Vec<_>>(),
|
||||
[SymbolKind::Function, SymbolKind::Event]
|
||||
);
|
||||
assert_eq!(outline[1].kind, SymbolKind::Method);
|
||||
|
||||
let folds = folding(&lang, text).unwrap();
|
||||
assert_eq!(folds, [FoldRange { start_line: 0, end_line: 1, kind: FoldKind::Comment }]);
|
||||
set_external_syntax(None);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,24 @@ impl SyntaxHighlightKind {
|
||||
Self::Operator,
|
||||
Self::Punctuation,
|
||||
];
|
||||
|
||||
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|k| {
|
||||
let camel = serde_json::to_value(k).ok();
|
||||
let camel = camel.as_ref().and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let mut kebab = String::with_capacity(camel.len() + 2);
|
||||
for c in camel.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
kebab.push('-');
|
||||
kebab.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
kebab.push(c);
|
||||
}
|
||||
}
|
||||
kebab == name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlighted ranges of a whole document, packed like LSP semantic tokens.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod cache;
|
||||
mod configuration;
|
||||
mod external;
|
||||
mod folding;
|
||||
mod grammars;
|
||||
mod highlight;
|
||||
@@ -14,6 +15,7 @@ mod web;
|
||||
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
|
||||
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
|
||||
pub use configuration::ConfigurationKey;
|
||||
pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, set_external_syntax};
|
||||
use grammars::{SPECS, Spec};
|
||||
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
|
||||
use ide_core::{LanguageId, OutlineItem, Pos, Range};
|
||||
|
||||
@@ -96,8 +96,12 @@ impl Launcher for ProcessLauncher {
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
|
||||
let search = server_search_path(config);
|
||||
let binary = ide_core::tool_path::find_command(&config.command, &search, root)
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b));
|
||||
let binary = match &config.pinned {
|
||||
// A plugin's server: the approved program, unchanged since.
|
||||
Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()),
|
||||
None => ide_core::tool_path::find_command(&config.command, &search, root)
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b)),
|
||||
};
|
||||
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
|
||||
env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone())));
|
||||
if let Some(addr) = &config.connect {
|
||||
@@ -120,6 +124,9 @@ impl Launcher for ProcessLauncher {
|
||||
}
|
||||
|
||||
fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool {
|
||||
if let Some(pin) = &config.pinned {
|
||||
return pin.path.is_file();
|
||||
}
|
||||
ide_core::tool_path::find_command(&config.command, &server_search_path(config), root).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
pinned: None,
|
||||
};
|
||||
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
|
||||
let path = dir.path().join("a.nope");
|
||||
@@ -398,6 +399,7 @@ async fn typescript_language_server_end_to_end() {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
pinned: None,
|
||||
};
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
|
||||
let doc = DocId::next();
|
||||
@@ -444,6 +446,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
[package]
|
||||
name = "ide-plugin"
|
||||
description = "Sandboxed WebAssembly plugin host (wasmtime component model + WASI preview 2)"
|
||||
description = "WebAssembly runtime for plugins (wasmtime component model + WASI preview 2)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-extension.workspace = true
|
||||
blake3.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tempfile.workspace = true
|
||||
zip.workspace = true
|
||||
wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation"] }
|
||||
semver = "1"
|
||||
wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation", "async"] }
|
||||
wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] }
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//! One thread per active plugin owning its [`Runtime`].
|
||||
//!
|
||||
//! Callers queue jobs and wait on a reply channel instead of holding a lock
|
||||
//! across the call, so an edit-path caller can stop waiting at its soft
|
||||
//! deadline while the call runs on (and is interrupted at the hard deadline
|
||||
//! by the store's epoch callback), and the call can be cancelled from any
|
||||
//! thread. A trap poisons the instance: the actor drops it and ends; the
|
||||
//! host's supervisor decides when to start a new one.
|
||||
//!
|
||||
//! wasmtime's async API would let one thread drive every instance, but needs
|
||||
//! async host bindings throughout; with a plugin's instance only ever entered
|
||||
//! by one job at a time a parked thread per active plugin costs little, and
|
||||
//! idle reclaim ends it.
|
||||
|
||||
use crate::runtime::{CallCancel, CallError, Runtime};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type Job = Box<dyn FnOnce(&mut Option<Runtime>) + Send>;
|
||||
|
||||
/// Why an [`Actor::call`] produced no result.
|
||||
#[derive(Debug)]
|
||||
pub enum ActorError {
|
||||
/// The plugin's own outcome: an error it returned, or a trap (the actor has ended).
|
||||
Call(CallError),
|
||||
/// The caller's wait ran out; the result, if any, is dropped.
|
||||
SoftTimeout,
|
||||
/// The actor ended (an earlier call trapped, or it was stopped) before running the call.
|
||||
Stopped,
|
||||
/// The caller cancelled the call (`call_cancellable`).
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// A call queued with [`Actor::submit`].
|
||||
pub struct Pending<R> {
|
||||
/// The outcome and when the plugin's thread produced it.
|
||||
result: mpsc::Receiver<(Result<R, CallError>, Instant)>,
|
||||
give_up: Option<Instant>,
|
||||
alive: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl<R> Pending<R> {
|
||||
/// Waits for the outcome, until the call's soft deadline if it has one. A
|
||||
/// result produced after that deadline is dropped even when it is already
|
||||
/// there (the caller was not scheduled in time, or waited on another call
|
||||
/// first), so the deadline holds however busy the machine is; a failure is
|
||||
/// still reported.
|
||||
pub fn wait(self) -> Result<R, ActorError> {
|
||||
let outcome = match self.give_up {
|
||||
Some(t) => match self.result.recv_timeout(t.saturating_duration_since(Instant::now())) {
|
||||
Ok((Ok(_), done)) if done > t => return Err(ActorError::SoftTimeout),
|
||||
Ok((r, _)) => r,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => return Err(ActorError::SoftTimeout),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// Skipped as too late, or the actor ended.
|
||||
let alive = self.alive.load(Ordering::Acquire);
|
||||
return Err(if alive { ActorError::SoftTimeout } else { ActorError::Stopped });
|
||||
}
|
||||
},
|
||||
None => self.result.recv().map_err(|_| ActorError::Stopped)?.0,
|
||||
};
|
||||
outcome.map_err(ActorError::Call)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Actor {
|
||||
tx: Mutex<Option<mpsc::Sender<Job>>>,
|
||||
thread: Mutex<Option<JoinHandle<()>>>,
|
||||
cancel: Arc<CallCancel>,
|
||||
alive: Arc<AtomicBool>,
|
||||
/// The trap that ended the actor, until someone takes it to report it.
|
||||
crash: Arc<Mutex<Option<String>>>,
|
||||
busy: Arc<AtomicBool>,
|
||||
/// Milliseconds since `epoch` at the last call's end.
|
||||
last_used: Arc<AtomicU64>,
|
||||
epoch: Instant,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
/// Moves an activated runtime onto its own thread.
|
||||
pub fn spawn(name: &str, runtime: Runtime) -> std::io::Result<Self> {
|
||||
let (tx, rx) = mpsc::channel::<Job>();
|
||||
let cancel = runtime.cancel_handle();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let busy = Arc::new(AtomicBool::new(false));
|
||||
let last_used = Arc::new(AtomicU64::new(0));
|
||||
let epoch = Instant::now();
|
||||
let thread = {
|
||||
let (alive, busy, last_used) = (alive.clone(), busy.clone(), last_used.clone());
|
||||
std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || {
|
||||
let mut slot = Some(runtime);
|
||||
while let Ok(job) = rx.recv() {
|
||||
busy.store(true, Ordering::Release);
|
||||
job(&mut slot);
|
||||
last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
|
||||
busy.store(false, Ordering::Release);
|
||||
if slot.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
alive.store(false, Ordering::Release);
|
||||
// Queued jobs are dropped with `rx`: their callers see `Stopped`.
|
||||
drop(rx);
|
||||
if let Some(mut rt) = slot {
|
||||
rt.deactivate();
|
||||
}
|
||||
})?
|
||||
};
|
||||
Ok(Self {
|
||||
tx: Mutex::new(Some(tx)),
|
||||
thread: Mutex::new(Some(thread)),
|
||||
cancel,
|
||||
alive,
|
||||
crash: Arc::default(),
|
||||
busy,
|
||||
last_used,
|
||||
epoch,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `f` on the plugin's thread. With `wait`, gives up after that long
|
||||
/// (counting the time queued behind other calls) and the call, if it has
|
||||
/// not started yet, is skipped. Without, waits for the outcome: the
|
||||
/// runtime's own deadline bounds it.
|
||||
pub fn call<R: Send + 'static>(
|
||||
&self,
|
||||
wait: Option<Duration>,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
self.submit(wait, f)?.wait()
|
||||
}
|
||||
|
||||
/// Queues `f` like [`call`](Self::call) and returns at once, so a caller
|
||||
/// can start calls on several plugins and then wait for all of them: the
|
||||
/// wait is the slowest one's, not the sum.
|
||||
pub fn submit<R: Send + 'static>(
|
||||
&self,
|
||||
wait: Option<Duration>,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<Pending<R>, ActorError> {
|
||||
let give_up = wait.map(|w| Instant::now() + w);
|
||||
let (reply, result) = mpsc::sync_channel(1);
|
||||
let (crash, alive) = (self.crash.clone(), self.alive.clone());
|
||||
let job: Job = Box::new(move |slot| {
|
||||
if give_up.is_some_and(|t| Instant::now() >= t) {
|
||||
return;
|
||||
}
|
||||
let Some(rt) = slot.as_mut() else { return };
|
||||
let outcome = f(rt);
|
||||
if let Err(CallError::Trap(e)) = &outcome {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
|
||||
// Before the reply: the caller sees the actor dead.
|
||||
alive.store(false, Ordering::Release);
|
||||
*slot = None;
|
||||
}
|
||||
let _ = reply.send((outcome, Instant::now()));
|
||||
});
|
||||
{
|
||||
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)?;
|
||||
}
|
||||
Ok(Pending { result, give_up, alive: self.alive.clone() })
|
||||
}
|
||||
|
||||
/// Like [`call`](Self::call) without a soft deadline, but `cancel` (the
|
||||
/// caller's request was superseded) cancels the call cooperatively, and
|
||||
/// interrupts it if it has not returned `grace` later. Returns
|
||||
/// [`ActorError::Cancelled`] then.
|
||||
pub fn call_cancellable<R: Send + 'static>(
|
||||
&self,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
grace: Duration,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
let (reply, result) = mpsc::sync_channel(1);
|
||||
let (crash, alive) = (self.crash.clone(), self.alive.clone());
|
||||
let skip = cancel.clone();
|
||||
let job: Job = Box::new(move |slot| {
|
||||
if skip.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let Some(rt) = slot.as_mut() else { return };
|
||||
let outcome = f(rt);
|
||||
if let Err(CallError::Trap(e)) = &outcome {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
|
||||
alive.store(false, Ordering::Release);
|
||||
*slot = None;
|
||||
}
|
||||
let _ = reply.send(outcome);
|
||||
});
|
||||
{
|
||||
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) };
|
||||
tx.send(job).map_err(|_| ActorError::Stopped)?;
|
||||
}
|
||||
let mut cancelled_at: Option<Instant> = None;
|
||||
loop {
|
||||
match result.recv_timeout(Duration::from_millis(5)) {
|
||||
Ok(outcome) if cancelled_at.is_some() => {
|
||||
drop(outcome);
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
Ok(outcome) => return outcome.map_err(ActorError::Call),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped });
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if !cancel.is_cancelled() {
|
||||
continue;
|
||||
}
|
||||
match cancelled_at {
|
||||
None => {
|
||||
cancelled_at = Some(Instant::now());
|
||||
if self.is_busy() {
|
||||
self.cancel.cancel_soft();
|
||||
}
|
||||
}
|
||||
Some(at) if at.elapsed() >= grace => {
|
||||
// Did not return in time: interrupt it (the instance is rebuilt).
|
||||
self.cancel();
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues `f` without waiting; `done` gets the outcome on the actor's
|
||||
/// thread (`Stopped` if the actor ended before running it).
|
||||
pub fn post<R: Send + 'static>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
done: impl FnOnce(Result<R, ActorError>) + Send + 'static,
|
||||
) {
|
||||
let (crash, alive) = (self.crash.clone(), self.alive.clone());
|
||||
let job: Job = Box::new(move |slot| {
|
||||
let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) };
|
||||
let outcome = f(rt);
|
||||
if let Err(CallError::Trap(e)) = &outcome {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
|
||||
alive.store(false, Ordering::Release);
|
||||
*slot = None;
|
||||
}
|
||||
done(outcome.map_err(ActorError::Call));
|
||||
});
|
||||
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// A job that can't be queued is dropped, and with it `done` (unrun):
|
||||
// the caller sees the actor dead on its next look.
|
||||
if let Some(tx) = tx.as_ref() {
|
||||
let _ = tx.send(job);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cooperatively cancels the running call (see [`call_cancellable`](Self::call_cancellable)).
|
||||
pub fn cancel_soft(&self) {
|
||||
if self.is_busy() {
|
||||
self.cancel.cancel_soft();
|
||||
}
|
||||
}
|
||||
|
||||
/// Interrupts the running call (it fails as a trap: the instance is dropped).
|
||||
pub fn cancel(&self) {
|
||||
if self.busy.load(Ordering::Acquire) {
|
||||
self.cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// The trap that ended this actor; `Some` only for the first caller, which
|
||||
/// reports it (also when the caller that saw it had stopped waiting).
|
||||
pub fn take_crash(&self) -> Option<String> {
|
||||
self.crash.lock().unwrap_or_else(|e| e.into_inner()).take()
|
||||
}
|
||||
|
||||
/// The runtime is still usable (no call trapped, not stopped).
|
||||
pub fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn is_busy(&self) -> bool {
|
||||
self.busy.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Time since the last call ended (or since start).
|
||||
pub fn idle_for(&self) -> Duration {
|
||||
let last = Duration::from_millis(self.last_used.load(Ordering::Acquire));
|
||||
self.epoch.elapsed().saturating_sub(last)
|
||||
}
|
||||
|
||||
/// Deactivates the plugin and ends the thread after the queued calls.
|
||||
/// With `join`, waits for that (bounded by the runtime's deadlines).
|
||||
pub fn stop(&self, join: bool) {
|
||||
self.tx.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
if join {
|
||||
let thread = self.thread.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
if let Some(t) = thread
|
||||
&& t.thread().id() != std::thread::current().id()
|
||||
{
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Actor {
|
||||
fn drop(&mut self) {
|
||||
self.stop(false);
|
||||
}
|
||||
}
|
||||
+16
-763
@@ -1,771 +1,24 @@
|
||||
//! 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: `<plugins_dir>/<id>/` (installed) and `<project>/.openidea/plugins/*/`
|
||||
//! (dev plugins, loaded by [`PluginHost::sync_project`]).
|
||||
//! Each plugin with code runs in its own wasmtime `Store`: no files, env or
|
||||
//! network beyond what [`HostApi`] grants, a fuel budget and wall-clock
|
||||
//! deadline per call, and a linear-memory cap. A trap (panic, budget
|
||||
//! exhausted, permission violation, cancellation) poisons the instance; the
|
||||
//! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread.
|
||||
|
||||
mod manifest;
|
||||
mod package;
|
||||
mod actor;
|
||||
mod runtime;
|
||||
mod sandbox;
|
||||
mod theme;
|
||||
mod types;
|
||||
mod wit_host;
|
||||
|
||||
pub use manifest::{
|
||||
API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest, ThemeDecl,
|
||||
pub use actor::{Actor, ActorError, Pending};
|
||||
pub use runtime::{
|
||||
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
|
||||
component_api, host_api,
|
||||
};
|
||||
pub use sandbox::resolve_in_root;
|
||||
pub use theme::{ThemeDef, ThemeKind};
|
||||
pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root};
|
||||
pub use types::*;
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::LanguageConfig;
|
||||
use parking_lot::{Condvar, Mutex, RwLock};
|
||||
use runtime::{CallEffects, CallError, PluginEngine, Registrations, Runtime, RuntimeSetup};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const STATE_FILE: &str = "state.json";
|
||||
/// Top-level files copied with a package on install besides those the manifest references.
|
||||
const LICENSE_FILES: &[&str] =
|
||||
&["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING"];
|
||||
/// Dev plugins live here, relative to a project root.
|
||||
pub const DEV_PLUGINS_DIR: &str = ".openidea/plugins";
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct PersistedState {
|
||||
#[serde(default)]
|
||||
disabled: BTreeSet<String>,
|
||||
#[serde(default)]
|
||||
theme: Option<ThemeRef>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct ThemeRef {
|
||||
plugin: String,
|
||||
theme: String,
|
||||
/// The theme's window frame colour as the UI resolved it (`#rrggbb[aa]`), so
|
||||
/// new windows open in it before the plugin is loaded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
frame: Option<String>,
|
||||
}
|
||||
|
||||
struct Meta {
|
||||
status: PluginStatus,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
struct LoadedPlugin {
|
||||
manifest: PluginManifest,
|
||||
dir: PathBuf,
|
||||
source: PluginSource,
|
||||
/// Project that owns a dev plugin.
|
||||
dev_root: Option<PathBuf>,
|
||||
/// Manifest contributions, restored on every (re)start.
|
||||
static_regs: Registrations,
|
||||
registrations: Arc<Mutex<Registrations>>,
|
||||
meta: Mutex<Meta>,
|
||||
runtime: Mutex<Option<Runtime>>,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
fn is_live(&self) -> bool {
|
||||
matches!(self.meta.lock().status, PluginStatus::Active | PluginStatus::Static)
|
||||
}
|
||||
|
||||
fn set_failed(&self, error: String) {
|
||||
let mut meta = self.meta.lock();
|
||||
meta.status = PluginStatus::Failed;
|
||||
meta.error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
|
||||
|
||||
pub struct PluginHost {
|
||||
project_policy: RwLock<ProjectPolicy>,
|
||||
engine: PluginEngine,
|
||||
plugins_dir: PathBuf,
|
||||
sink: Arc<dyn PluginEventSink>,
|
||||
limits: Limits,
|
||||
plugins: RwLock<BTreeMap<String, Arc<LoadedPlugin>>>,
|
||||
state: Mutex<PersistedState>,
|
||||
/// client (window) → path of its focused editor.
|
||||
active_files: Arc<Mutex<HashMap<String, String>>>,
|
||||
/// Serializes loading / unloading so concurrent windows can't race.
|
||||
lifecycle: Mutex<()>,
|
||||
/// Set once the first [`load_all`](Self::load_all) has finished.
|
||||
initial_load: (Mutex<bool>, Condvar),
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Creates the host; call [`load_all`](Self::load_all) to load installed plugins.
|
||||
pub fn new(plugins_dir: PathBuf, sink: Arc<dyn PluginEventSink>, limits: Limits) -> IdeResult<Self> {
|
||||
std::fs::create_dir_all(&plugins_dir)?;
|
||||
let state = std::fs::read_to_string(plugins_dir.join(STATE_FILE))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
project_policy: RwLock::new(Arc::new(|_| false)),
|
||||
engine: PluginEngine::new().map_err(IdeError::internal)?,
|
||||
plugins_dir,
|
||||
sink,
|
||||
limits,
|
||||
plugins: RwLock::default(),
|
||||
state: Mutex::new(state),
|
||||
active_files: Arc::default(),
|
||||
lifecycle: Mutex::new(()),
|
||||
initial_load: (Mutex::new(false), Condvar::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plugins_dir(&self) -> &Path {
|
||||
&self.plugins_dir
|
||||
}
|
||||
|
||||
/// Loads every package under the plugins directory (already loaded ones are kept).
|
||||
pub fn load_all(&self) {
|
||||
let _guard = self.lifecycle.lock();
|
||||
for dir in package_dirs(&self.plugins_dir) {
|
||||
if !self.is_loaded_from(&dir) {
|
||||
self.load_dir(&dir, PluginSource::Installed, None);
|
||||
}
|
||||
}
|
||||
let (done, cv) = &self.initial_load;
|
||||
*done.lock() = true;
|
||||
cv.notify_all();
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
|
||||
/// Waits (at most `timeout`) for the first [`load_all`](Self::load_all), so a
|
||||
/// listing made at startup already holds the installed plugins and the
|
||||
/// selected theme. Returns whether loading has finished.
|
||||
pub fn wait_initial_load(&self, timeout: Duration) -> bool {
|
||||
let (done, cv) = &self.initial_load;
|
||||
let mut done = done.lock();
|
||||
if !*done {
|
||||
cv.wait_while_for(&mut done, |d| !*d, timeout);
|
||||
}
|
||||
*done
|
||||
}
|
||||
|
||||
/// Loads new dev plugins from `<root>/.openidea/plugins/` and unloads vanished ones.
|
||||
pub fn set_project_policy(&self, policy: ProjectPolicy) {
|
||||
*self.project_policy.write() = policy;
|
||||
}
|
||||
|
||||
pub fn restrict_project(&self, root: &Path) {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let ids: Vec<_> = self
|
||||
.plugins
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, p)| p.dev_root.as_deref() == Some(root))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids {
|
||||
self.unload(&id);
|
||||
}
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
|
||||
pub fn sync_project(&self, root: &Path) {
|
||||
if !(self.project_policy.read())(root) {
|
||||
self.restrict_project(root);
|
||||
return;
|
||||
}
|
||||
let _guard = self.lifecycle.lock();
|
||||
let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins"));
|
||||
let stale: Vec<String> = self
|
||||
.plugins
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, p)| p.dev_root.as_deref() == Some(root) && !dirs.contains(&p.dir))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
let mut changed = !stale.is_empty();
|
||||
for id in stale {
|
||||
self.unload(&id);
|
||||
}
|
||||
for dir in dirs {
|
||||
if !self.is_loaded_from(&dir) {
|
||||
self.load_dir(&dir, PluginSource::Dev, Some(root.to_path_buf()));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installed plugins plus the dev plugins of `project` (if any).
|
||||
pub fn list(&self, project: Option<&Path>) -> PluginsSnapshot {
|
||||
let mut plugins: Vec<PluginInfo> = self
|
||||
.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
|
||||
.map(|p| self.info(p))
|
||||
.collect();
|
||||
plugins.sort_by_key(|p| p.name.to_lowercase());
|
||||
PluginsSnapshot { plugins, active_theme: self.active_theme() }
|
||||
}
|
||||
|
||||
pub fn info_of(&self, id: &str) -> IdeResult<PluginInfo> {
|
||||
Ok(self.info(&*self.get(id)?))
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
{
|
||||
let mut state = self.state.lock();
|
||||
if enabled {
|
||||
state.disabled.remove(id)
|
||||
} else {
|
||||
state.disabled.insert(id.to_owned())
|
||||
};
|
||||
}
|
||||
self.save_state();
|
||||
self.stop(&plugin);
|
||||
if enabled {
|
||||
self.start(&plugin);
|
||||
} else {
|
||||
plugin.meta.lock().status = PluginStatus::Disabled;
|
||||
}
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.emit_theme_if_owned(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-reads a plugin from disk (e.g. after rebuilding a dev plugin).
|
||||
pub fn reload(&self, id: &str) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
let (dir, source, root) = (plugin.dir.clone(), plugin.source, plugin.dev_root.clone());
|
||||
self.unload(id);
|
||||
let new_id = self.load_dir(&dir, source, root);
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.emit_theme_if_owned(&new_id);
|
||||
self.info_of(&new_id)
|
||||
}
|
||||
|
||||
/// Installs the package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder.
|
||||
pub fn install_from_path(&self, path: &Path) -> IdeResult<PluginInfo> {
|
||||
if package::is_zip(path) && path.is_file() {
|
||||
// Outside the plugins directory: a crash must not leave a package that loads.
|
||||
let unpacked = tempfile::Builder::new().prefix("openidea-plugin-").tempdir()?;
|
||||
let root = package::unpack_zip(path, unpacked.path())?;
|
||||
return self.install_from_dir(&root);
|
||||
}
|
||||
if path.file_name().is_some_and(|name| name == MANIFEST_FILE) && path.is_file() {
|
||||
return self.install_from_dir(path.parent().unwrap_or(path));
|
||||
}
|
||||
self.install_from_dir(path)
|
||||
}
|
||||
|
||||
/// Copies a plugin package (manifest + referenced files) into the plugins
|
||||
/// directory, replacing an installed version with the same id.
|
||||
pub fn install_from_dir(&self, src: &Path) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let manifest = PluginManifest::load(src).map_err(IdeError::invalid)?;
|
||||
for file in manifest.files() {
|
||||
if !src.join(file).is_file() {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is missing (referenced by plugin.toml); build the plugin first",
|
||||
file.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(wasm) = &manifest.wasm {
|
||||
self.engine.compile(&src.join(wasm)).map_err(IdeError::invalid)?;
|
||||
}
|
||||
let id = manifest.id.clone();
|
||||
if let Some(existing) = self.plugins.read().get(&id)
|
||||
&& existing.source == PluginSource::Dev
|
||||
{
|
||||
return Err(IdeError::new(
|
||||
ide_core::error::ErrorKind::AlreadyExists,
|
||||
format!("a dev plugin with id {id:?} is loaded from {}", existing.dir.display()),
|
||||
));
|
||||
}
|
||||
let target = self.plugins_dir.join(&id);
|
||||
let same_dir = src.canonicalize().ok().zip(target.canonicalize().ok()).is_some_and(|(a, b)| a == b);
|
||||
self.unload(&id);
|
||||
if !same_dir {
|
||||
let staging = self.plugins_dir.join(format!(".install-{id}"));
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
let copy = || -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&staging)?;
|
||||
std::fs::copy(src.join(MANIFEST_FILE), staging.join(MANIFEST_FILE))?;
|
||||
for file in manifest.files() {
|
||||
let to = staging.join(file);
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(src.join(file), to)?;
|
||||
}
|
||||
for name in LICENSE_FILES {
|
||||
if src.join(name).is_file() {
|
||||
std::fs::copy(src.join(name), staging.join(name))?;
|
||||
}
|
||||
}
|
||||
if target.exists() {
|
||||
std::fs::remove_dir_all(&target)?;
|
||||
}
|
||||
std::fs::rename(&staging, &target)
|
||||
};
|
||||
if let Err(e) = copy() {
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
self.load_dir(&target, PluginSource::Installed, None);
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.emit_theme_if_owned(&id);
|
||||
self.info_of(&id)
|
||||
}
|
||||
|
||||
pub fn uninstall(&self, id: &str) -> IdeResult<()> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
if plugin.source != PluginSource::Installed {
|
||||
return Err(IdeError::invalid("dev plugins are removed by deleting them from .openidea/plugins"));
|
||||
}
|
||||
self.unload(id);
|
||||
std::fs::remove_dir_all(&plugin.dir)?;
|
||||
let theme_cleared = {
|
||||
let mut state = self.state.lock();
|
||||
state.disabled.remove(id);
|
||||
let owned = state.theme.as_ref().is_some_and(|t| t.plugin == id);
|
||||
if owned {
|
||||
state.theme = None;
|
||||
}
|
||||
owned
|
||||
};
|
||||
self.save_state();
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
if theme_cleared {
|
||||
self.emit(None, PluginEvent::ThemeChanged { theme: None });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs a plugin command. Blocks for up to the configured time limit.
|
||||
pub fn run_command(
|
||||
&self,
|
||||
id: &str,
|
||||
command: &str,
|
||||
arg: Option<&str>,
|
||||
invocation: Invocation,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let plugin = self.get(id)?;
|
||||
let client = invocation.client.clone();
|
||||
let mut runtime = plugin.runtime.lock();
|
||||
let Some(rt) = runtime.as_mut() else {
|
||||
let meta = plugin.meta.lock();
|
||||
return Err(IdeError::unavailable(match meta.status {
|
||||
PluginStatus::Disabled => format!("plugin {id:?} is disabled"),
|
||||
PluginStatus::Static => format!("plugin {id:?} has no code to run commands"),
|
||||
_ => format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed")),
|
||||
}));
|
||||
};
|
||||
match rt.run_command(command, arg, invocation) {
|
||||
Ok((value, fx)) => {
|
||||
drop(runtime);
|
||||
self.apply_effects(&plugin, fx);
|
||||
Ok(value)
|
||||
}
|
||||
Err(CallError::Guest(e)) => Err(IdeError::internal(e)),
|
||||
Err(CallError::Trap(e)) => {
|
||||
*runtime = None;
|
||||
drop(runtime);
|
||||
self.fail(&plugin, &e, client.as_deref());
|
||||
Err(IdeError::internal(format!("plugin {id:?} {e}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The UI reports which file its focused editor shows (for `active-file`).
|
||||
pub fn set_active_file(&self, client: &str, path: Option<String>) {
|
||||
let mut files = self.active_files.lock();
|
||||
match path {
|
||||
Some(p) => files.insert(client.to_owned(), p),
|
||||
None => files.remove(client),
|
||||
};
|
||||
}
|
||||
|
||||
/// Selects a plugin theme (`None` restores the built-in theme).
|
||||
pub fn set_theme(&self, selection: Option<(&str, &str)>) -> IdeResult<Option<ActiveTheme>> {
|
||||
if let Some((plugin, theme)) = selection {
|
||||
let p = self.get(plugin)?;
|
||||
if !p.registrations.lock().themes.iter().any(|t| t.id == theme) {
|
||||
return Err(IdeError::not_found(format!("plugin {plugin:?} has no theme {theme:?}")));
|
||||
}
|
||||
}
|
||||
self.state.lock().theme =
|
||||
selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into(), frame: None });
|
||||
self.save_state();
|
||||
let active = self.active_theme();
|
||||
self.emit(None, PluginEvent::ThemeChanged { theme: active.clone() });
|
||||
Ok(active)
|
||||
}
|
||||
|
||||
/// Records the selected theme's resolved window frame colour (`#rgb`,
|
||||
/// `#rrggbb` or `#rrggbbaa`); ignored without a selected theme.
|
||||
pub fn set_theme_frame(&self, frame: Option<&str>) -> IdeResult<()> {
|
||||
if let Some(f) = frame
|
||||
&& !is_hex_color(f)
|
||||
{
|
||||
return Err(IdeError::invalid(format!("not a hex colour: {f:?}")));
|
||||
}
|
||||
let changed = match self.state.lock().theme.as_mut() {
|
||||
Some(t) if t.frame.as_deref() != frame => {
|
||||
t.frame = frame.map(str::to_owned);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if changed {
|
||||
self.save_state();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Frame colour for a new window: the recorded one of the selected theme,
|
||||
/// unless its plugin is disabled. Available before plugins are loaded.
|
||||
pub fn startup_frame(&self) -> Option<String> {
|
||||
let state = self.state.lock();
|
||||
let t = state.theme.as_ref()?;
|
||||
if state.disabled.contains(&t.plugin) {
|
||||
return None;
|
||||
}
|
||||
t.frame.clone()
|
||||
}
|
||||
|
||||
/// The selected plugin theme, when its plugin is enabled and running.
|
||||
pub fn active_theme(&self) -> Option<ActiveTheme> {
|
||||
let sel = self.state.lock().theme.clone()?;
|
||||
let plugin = self.plugins.read().get(&sel.plugin).cloned()?;
|
||||
if !plugin.is_live() {
|
||||
return None;
|
||||
}
|
||||
let theme = plugin.registrations.lock().themes.iter().find(|t| t.id == sel.theme).cloned()?;
|
||||
Some(ActiveTheme { plugin_id: sel.plugin, theme })
|
||||
}
|
||||
|
||||
/// Languages contributed by live plugins (for runtime language registration).
|
||||
pub fn languages(&self) -> Vec<LanguageConfig> {
|
||||
self.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.is_live())
|
||||
.flat_map(|p| p.registrations.lock().languages.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Deactivates every plugin (also done on drop).
|
||||
pub fn shutdown(&self) {
|
||||
for plugin in self.plugins.read().values() {
|
||||
if let Some(mut rt) = plugin.runtime.lock().take() {
|
||||
rt.deactivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
fn get(&self, id: &str) -> IdeResult<Arc<LoadedPlugin>> {
|
||||
let plugin =
|
||||
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?;
|
||||
if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) {
|
||||
return Err(IdeError::unavailable("Project is in Restricted Mode"));
|
||||
}
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
fn is_loaded_from(&self, dir: &Path) -> bool {
|
||||
self.plugins.read().values().any(|p| p.dir == dir)
|
||||
}
|
||||
|
||||
/// Loads one package and starts it unless disabled. Returns the plugin id.
|
||||
fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option<PathBuf>) -> String {
|
||||
let (manifest, static_regs, error) = match PluginManifest::load(dir) {
|
||||
Ok(m) => match static_contributions(dir, &m) {
|
||||
Ok(regs) => (m, regs, None),
|
||||
Err(e) => (m, Registrations::default(), Some(e)),
|
||||
},
|
||||
Err(e) => (placeholder_manifest(dir), Registrations::default(), Some(e)),
|
||||
};
|
||||
let id = manifest.id.clone();
|
||||
let plugin = Arc::new(LoadedPlugin {
|
||||
manifest,
|
||||
dir: dir.to_path_buf(),
|
||||
source,
|
||||
dev_root,
|
||||
registrations: Arc::new(Mutex::new(static_regs.clone())),
|
||||
static_regs,
|
||||
meta: Mutex::new(Meta { status: PluginStatus::Disabled, error: None }),
|
||||
runtime: Mutex::new(None),
|
||||
});
|
||||
{
|
||||
let mut plugins = self.plugins.write();
|
||||
if let Some(existing) = plugins.get(&id) {
|
||||
tracing::warn!(
|
||||
"plugin {id:?} at {} ignored: already loaded from {}",
|
||||
dir.display(),
|
||||
existing.dir.display()
|
||||
);
|
||||
return id;
|
||||
}
|
||||
plugins.insert(id.clone(), plugin.clone());
|
||||
}
|
||||
if let Some(e) = error {
|
||||
tracing::warn!("plugin {}: {e}", dir.display());
|
||||
plugin.set_failed(e);
|
||||
} else if !self.state.lock().disabled.contains(&id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn unload(&self, id: &str) {
|
||||
let Some(plugin) = self.plugins.write().remove(id) else { return };
|
||||
self.stop(&plugin);
|
||||
}
|
||||
|
||||
fn start(&self, plugin: &Arc<LoadedPlugin>) {
|
||||
*plugin.registrations.lock() = plugin.static_regs.clone();
|
||||
let Some(wasm) = &plugin.manifest.wasm else {
|
||||
*plugin.meta.lock() = Meta { status: PluginStatus::Static, error: None };
|
||||
return;
|
||||
};
|
||||
let started = self.engine.compile(&plugin.dir.join(wasm)).and_then(|component| {
|
||||
let active_files = self.active_files.clone();
|
||||
let setup = RuntimeSetup {
|
||||
plugin_id: plugin.manifest.id.clone(),
|
||||
version: plugin.manifest.version.clone(),
|
||||
permissions: plugin.manifest.permissions.clone(),
|
||||
registrations: plugin.registrations.clone(),
|
||||
sink: self.sink.clone(),
|
||||
active_file: Arc::new(move |client| client.and_then(|c| active_files.lock().get(c).cloned())),
|
||||
limits: self.limits,
|
||||
};
|
||||
let mut rt = Runtime::instantiate(&self.engine, &component, setup)?;
|
||||
match rt.activate() {
|
||||
Ok(fx) => Ok((rt, fx)),
|
||||
Err(CallError::Guest(e)) => Err(format!("activation failed: {e}")),
|
||||
Err(CallError::Trap(e)) => Err(format!("activation {e}")),
|
||||
}
|
||||
});
|
||||
match started {
|
||||
Ok((rt, _fx)) => {
|
||||
*plugin.runtime.lock() = Some(rt);
|
||||
*plugin.meta.lock() = Meta { status: PluginStatus::Active, error: None };
|
||||
}
|
||||
Err(e) => self.fail(plugin, &e, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&self, plugin: &LoadedPlugin) {
|
||||
if let Some(mut rt) = plugin.runtime.lock().take() {
|
||||
rt.deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
|
||||
tracing::warn!(plugin = %plugin.manifest.id, "disabled after failure: {error}");
|
||||
plugin.set_failed(error.to_owned());
|
||||
self.emit(
|
||||
client,
|
||||
PluginEvent::Notification {
|
||||
plugin_id: plugin.manifest.id.clone(),
|
||||
level: NotificationLevel::Error,
|
||||
title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest.name),
|
||||
body: error.to_owned(),
|
||||
},
|
||||
);
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.emit_theme_if_owned(&plugin.manifest.id);
|
||||
}
|
||||
|
||||
fn apply_effects(&self, plugin: &LoadedPlugin, fx: CallEffects) {
|
||||
if fx.commands_changed {
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
let sel = self.state.lock().theme.clone();
|
||||
if let Some(sel) = sel
|
||||
&& sel.plugin == plugin.manifest.id
|
||||
&& fx.themes_changed.contains(&sel.theme)
|
||||
{
|
||||
self.emit(None, PluginEvent::ThemeChanged { theme: self.active_theme() });
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_theme_if_owned(&self, id: &str) {
|
||||
let owned = self.state.lock().theme.as_ref().is_some_and(|t| t.plugin == id);
|
||||
if owned {
|
||||
self.emit(None, PluginEvent::ThemeChanged { theme: self.active_theme() });
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&self, client: Option<&str>, event: PluginEvent) {
|
||||
self.sink.emit(client, event);
|
||||
}
|
||||
|
||||
fn save_state(&self) {
|
||||
let json = serde_json::to_string_pretty(&*self.state.lock()).unwrap_or_default();
|
||||
if let Err(e) = std::fs::write(self.plugins_dir.join(STATE_FILE), json) {
|
||||
tracing::warn!("saving plugin state: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn info(&self, p: &LoadedPlugin) -> PluginInfo {
|
||||
let m = &p.manifest;
|
||||
let regs = p.registrations.lock().clone();
|
||||
let meta = p.meta.lock();
|
||||
PluginInfo {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
authors: m.authors.clone(),
|
||||
homepage: m.homepage.clone(),
|
||||
change_notes: m.change_notes.clone(),
|
||||
source: p.source,
|
||||
path: p.dir.clone().into(),
|
||||
enabled: !self.state.lock().disabled.contains(&m.id),
|
||||
status: meta.status,
|
||||
error: meta.error.clone(),
|
||||
has_code: m.wasm.is_some(),
|
||||
permissions: m.permissions.clone(),
|
||||
commands: regs.commands,
|
||||
languages: regs
|
||||
.languages
|
||||
.iter()
|
||||
.map(|l| PluginLanguage {
|
||||
id: l.id.to_string(),
|
||||
name: l.name.clone(),
|
||||
extensions: l.extensions.clone(),
|
||||
})
|
||||
.collect(),
|
||||
themes: regs
|
||||
.themes
|
||||
.iter()
|
||||
.map(|t| PluginTheme { id: t.id.clone(), name: t.name.clone(), kind: t.kind })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PluginHost {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hex_color(s: &str) -> bool {
|
||||
s.strip_prefix('#').is_some_and(|h| matches!(h.len(), 3 | 6 | 8) && h.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
/// Plugin packages found in `dirs` (first occurrence of an id wins). Invalid
|
||||
/// manifests are skipped; packages with missing files carry a `problem`.
|
||||
pub fn scan_catalog(dirs: &[PathBuf]) -> Vec<CatalogPlugin> {
|
||||
let mut out: Vec<CatalogPlugin> = Vec::new();
|
||||
for dir in dirs.iter().flat_map(|d| package_dirs(d)) {
|
||||
let Ok(m) = PluginManifest::load(&dir) else { continue };
|
||||
if out.iter().any(|c| c.id == m.id) {
|
||||
continue;
|
||||
}
|
||||
let problem = m
|
||||
.files()
|
||||
.into_iter()
|
||||
.find(|f| !dir.join(f).is_file())
|
||||
.map(|f| format!("{} is missing; build the plugin first", f.display()));
|
||||
out.push(CatalogPlugin {
|
||||
has_code: m.wasm.is_some(),
|
||||
language_count: m.contributes.languages.len() as u32,
|
||||
theme_count: m.contributes.themes.len() as u32,
|
||||
commands: m.contributes.commands.clone(),
|
||||
permissions: m.permissions.clone(),
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
description: m.description,
|
||||
authors: m.authors,
|
||||
homepage: m.homepage,
|
||||
change_notes: m.change_notes,
|
||||
path: dir.into(),
|
||||
problem,
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|a| a.name.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
/// Subdirectories of `dir` that contain a `plugin.toml`, sorted.
|
||||
fn package_dirs(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut dirs: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.join(MANIFEST_FILE).is_file()
|
||||
&& !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))
|
||||
})
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
|
||||
fn static_contributions(dir: &Path, m: &PluginManifest) -> Result<Registrations, String> {
|
||||
let mut regs = Registrations::default();
|
||||
for cmd in &m.contributes.commands {
|
||||
regs.add_command(cmd.clone());
|
||||
}
|
||||
let read = |p: &Path| std::fs::read_to_string(dir.join(p)).map_err(|e| format!("{}: {e}", p.display()));
|
||||
for f in &m.contributes.languages {
|
||||
let cfg = runtime::parse_language(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
|
||||
regs.add_language(cfg);
|
||||
}
|
||||
for f in &m.contributes.themes {
|
||||
let mut theme = ThemeDef::parse(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
|
||||
if let Some(scheme) = &f.editor_scheme {
|
||||
theme.merge_editor_scheme(&read(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?;
|
||||
}
|
||||
regs.add_theme(theme);
|
||||
}
|
||||
Ok(regs)
|
||||
}
|
||||
|
||||
/// Stand-in for a package whose manifest failed to parse, so it still shows up (as failed).
|
||||
fn placeholder_manifest(dir: &Path) -> PluginManifest {
|
||||
let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
PluginManifest {
|
||||
id: format!("invalid-{}", name.to_lowercase().replace(|c: char| !c.is_ascii_alphanumeric(), "-")),
|
||||
name,
|
||||
version: "0.0.0".into(),
|
||||
description: String::new(),
|
||||
authors: Vec::new(),
|
||||
homepage: None,
|
||||
change_notes: None,
|
||||
openidea_api: API_VERSION.into(),
|
||||
wasm: None,
|
||||
permissions: Vec::new(),
|
||||
contributes: Contributions::default(),
|
||||
}
|
||||
}
|
||||
pub use wasmtime::component::Component;
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
//! `plugin.toml`: the plugin package manifest.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// Plugin API version implemented by this host (`openidea-api` in the manifest).
|
||||
pub const API_VERSION: &str = "0.1";
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
|
||||
/// Capabilities a plugin must declare to use the gated host functions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Permission {
|
||||
/// `workspace-root`, `read-file` (inside the project root only).
|
||||
ProjectRead,
|
||||
/// `active-file`.
|
||||
EditorRead,
|
||||
/// `insert-text`, `replace-selection`.
|
||||
EditorWrite,
|
||||
}
|
||||
|
||||
impl Permission {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Permission::ProjectRead => "project-read",
|
||||
Permission::EditorRead => "editor-read",
|
||||
Permission::EditorWrite => "editor-write",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct PluginManifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub homepage: Option<String>,
|
||||
/// Release notes of this version (Settings › Plugins › What's New).
|
||||
#[serde(default)]
|
||||
pub change_notes: Option<String>,
|
||||
pub openidea_api: String,
|
||||
/// Component file relative to the package directory; absent for
|
||||
/// contribution-only plugins (themes, languages).
|
||||
#[serde(default)]
|
||||
pub wasm: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<Permission>,
|
||||
#[serde(default)]
|
||||
pub contributes: Contributions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct Contributions {
|
||||
#[serde(default)]
|
||||
pub commands: Vec<CommandDecl>,
|
||||
#[serde(default)]
|
||||
pub languages: Vec<FileDecl>,
|
||||
#[serde(default)]
|
||||
pub themes: Vec<ThemeDecl>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandDecl {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// A contributed file, relative to the package directory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FileDecl {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// A contributed theme: the UI theme file plus an optional editor colour scheme
|
||||
/// whose tokens are merged over it (IntelliJ's `.theme.json` + `.icls`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct ThemeDecl {
|
||||
pub path: PathBuf,
|
||||
#[serde(default)]
|
||||
pub editor_scheme: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl PluginManifest {
|
||||
pub fn parse(src: &str) -> Result<Self, String> {
|
||||
let manifest: Self = toml::from_str(src).map_err(|e| e.to_string())?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<Self, String> {
|
||||
let path = dir.join(MANIFEST_FILE);
|
||||
let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
validate_plugin_id(&self.id)?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("`name` must not be empty".into());
|
||||
}
|
||||
if !is_version(&self.version) {
|
||||
return Err(format!("`version` must look like 1.2.3, got {:?}", self.version));
|
||||
}
|
||||
if !api_compatible(&self.openidea_api) {
|
||||
return Err(format!(
|
||||
"unsupported openidea-api {:?} (this IDE implements {API_VERSION})",
|
||||
self.openidea_api
|
||||
));
|
||||
}
|
||||
if let Some(wasm) = &self.wasm {
|
||||
check_relative(wasm, "wasm")?;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for c in &self.contributes.commands {
|
||||
validate_command_id(&c.id)?;
|
||||
if !seen.insert(c.id.as_str()) {
|
||||
return Err(format!("duplicate command id {:?}", c.id));
|
||||
}
|
||||
if c.title.trim().is_empty() {
|
||||
return Err(format!("command {:?} has an empty title", c.id));
|
||||
}
|
||||
}
|
||||
let theme_files = self.contributes.themes.iter().flat_map(|t| std::iter::once(&t.path).chain(&t.editor_scheme));
|
||||
for path in self.contributes.languages.iter().map(|f| &f.path).chain(theme_files) {
|
||||
check_relative(path, "contributes path")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file of the package besides the manifest (what an install copies).
|
||||
pub fn files(&self) -> Vec<&Path> {
|
||||
let mut files: Vec<&Path> = self.wasm.iter().map(PathBuf::as_path).collect();
|
||||
files.extend(self.contributes.languages.iter().map(|f| f.path.as_path()));
|
||||
for t in &self.contributes.themes {
|
||||
files.push(t.path.as_path());
|
||||
files.extend(t.editor_scheme.as_deref());
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
pub fn has(&self, p: Permission) -> bool {
|
||||
self.permissions.contains(&p)
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a-z0-9][a-z0-9_-]*`, at most 64 chars (no dots: it is one segment of an action id).
|
||||
pub fn validate_plugin_id(id: &str) -> Result<(), String> {
|
||||
validate_ident(id, "plugin id", false)
|
||||
}
|
||||
|
||||
/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars.
|
||||
pub fn validate_command_id(id: &str) -> Result<(), String> {
|
||||
validate_ident(id, "command id", true)
|
||||
}
|
||||
|
||||
fn validate_ident(id: &str, what: &str, dots: bool) -> Result<(), String> {
|
||||
let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
|
||||
let ok_rest =
|
||||
id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_' || (dots && c == '.'));
|
||||
if ok_first && ok_rest && id.len() <= 64 {
|
||||
Ok(())
|
||||
} else {
|
||||
let allowed = if dots { "a-z, 0-9, '.', '-', '_'" } else { "a-z, 0-9, '-', '_'" };
|
||||
Err(format!("invalid {what} {id:?}: use {allowed}, starting with a letter or digit (max 64)"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_version(v: &str) -> bool {
|
||||
let core = v.split(['-', '+']).next().unwrap_or_default();
|
||||
let parts: Vec<&str> = core.split('.').collect();
|
||||
parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// `0.1` and `0.1.x` are compatible with API 0.1 (pre-1.0: minor is breaking).
|
||||
fn api_compatible(v: &str) -> bool {
|
||||
v == API_VERSION || v.strip_prefix(API_VERSION).is_some_and(|rest| rest.starts_with('.'))
|
||||
}
|
||||
|
||||
/// Package paths must stay inside the package directory.
|
||||
fn check_relative(path: &Path, what: &str) -> Result<(), String> {
|
||||
let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)));
|
||||
if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FULL: &str = r#"
|
||||
id = "hello"
|
||||
name = "Hello"
|
||||
version = "0.1.0"
|
||||
description = "Says hello"
|
||||
authors = ["A"]
|
||||
openidea-api = "0.1"
|
||||
wasm = "dist/plugin.wasm"
|
||||
permissions = ["project-read", "editor-write"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "say.hi"
|
||||
title = "Say Hi"
|
||||
category = "Hello"
|
||||
|
||||
[[contributes.themes]]
|
||||
path = "themes/dark.json"
|
||||
editor-scheme = "themes/dark.scheme.json"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_full_manifest() {
|
||||
let m = PluginManifest::parse(FULL).unwrap();
|
||||
assert_eq!(m.id, "hello");
|
||||
assert_eq!(m.permissions, vec![Permission::ProjectRead, Permission::EditorWrite]);
|
||||
assert_eq!(m.contributes.commands[0].category.as_deref(), Some("Hello"));
|
||||
assert_eq!(
|
||||
m.files(),
|
||||
vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json"), Path::new("themes/dark.scheme.json")]
|
||||
);
|
||||
assert!(m.has(Permission::EditorWrite) && !m.has(Permission::EditorRead));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_contribution_only_manifest() {
|
||||
let m =
|
||||
PluginManifest::parse("id = \"t\"\nname = \"T\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1.3\"").unwrap();
|
||||
assert!(m.wasm.is_none() && m.permissions.is_empty());
|
||||
}
|
||||
|
||||
fn err(src: &str) -> String {
|
||||
PluginManifest::parse(src).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_manifests() {
|
||||
let base = |extra: &str, id: &str, api: &str| {
|
||||
format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nopenidea-api = \"{api}\"\n{extra}")
|
||||
};
|
||||
assert!(err(&base("", "Bad.Id", "0.1")).contains("invalid plugin id"));
|
||||
assert!(err(&base("", "ok", "0.2")).contains("unsupported openidea-api"));
|
||||
assert!(err(&base("", "ok", "0.10")).contains("unsupported openidea-api"));
|
||||
assert!(err(&base("permissions = [\"network\"]", "ok", "0.1")).contains("unknown variant"));
|
||||
assert!(err(&base("wasm = \"../evil.wasm\"", "ok", "0.1")).contains("relative path"));
|
||||
assert!(err(&base("wasm = \"/abs.wasm\"", "ok", "0.1")).contains("relative path"));
|
||||
assert!(err(&base("bogus = 1", "ok", "0.1")).contains("unknown field"));
|
||||
let dup =
|
||||
"[[contributes.commands]]\nid = \"a\"\ntitle = \"A\"\n[[contributes.commands]]\nid = \"a\"\ntitle = \"B\"";
|
||||
assert!(err(&base(dup, "ok", "0.1")).contains("duplicate command"));
|
||||
let traversal = "[[contributes.languages]]\npath = \"a/../../x.toml\"";
|
||||
assert!(err(&base(traversal, "ok", "0.1")).contains("relative path"));
|
||||
let scheme_escape = "[[contributes.themes]]\npath = \"t.json\"\neditor-scheme = \"../s.json\"";
|
||||
assert!(err(&base(scheme_escape, "ok", "0.1")).contains("relative path"));
|
||||
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nopenidea-api = \"0.1\"").contains("version"));
|
||||
assert!(err("id = \"ok\"\nname = \" \"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"").contains("name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ident_rules() {
|
||||
assert!(validate_command_id("word-count.v2_x").is_ok());
|
||||
assert!(validate_command_id("-x").is_err());
|
||||
assert!(validate_command_id("").is_err());
|
||||
assert!(validate_command_id(&"a".repeat(65)).is_err());
|
||||
assert!(validate_plugin_id("a.b").is_err());
|
||||
assert!(is_version("1.2.3-beta.1+build"));
|
||||
assert!(!is_version("1.2.x"));
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//! Plugin packages on disk: a folder, its `plugin.toml`, or a `.zip` of the folder.
|
||||
|
||||
use crate::MANIFEST_FILE;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Far above any real package; stops a zip bomb before it fills the disk.
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
const MAX_UNPACKED_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
pub(crate) fn is_zip(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"))
|
||||
}
|
||||
|
||||
fn invalid_zip(zip: &Path, reason: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::invalid(format!("{} is not a valid plugin archive: {reason}", zip.display()))
|
||||
}
|
||||
|
||||
/// Unpacks `zip` into `dest` and returns the package folder inside it. Refuses the
|
||||
/// whole archive when an entry would leave `dest` or is a symbolic link.
|
||||
pub(crate) fn unpack_zip(zip: &Path, dest: &Path) -> IdeResult<PathBuf> {
|
||||
let mut archive = zip::ZipArchive::new(BufReader::new(File::open(zip)?)).map_err(|e| invalid_zip(zip, e))?;
|
||||
if archive.len() > MAX_ENTRIES {
|
||||
return Err(invalid_zip(zip, format!("more than {MAX_ENTRIES} entries")));
|
||||
}
|
||||
let mut budget = MAX_UNPACKED_BYTES;
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive.by_index(i).map_err(|e| invalid_zip(zip, e))?;
|
||||
let name = entry.name().to_owned();
|
||||
let Some(rel) = entry.enclosed_name() else {
|
||||
return Err(invalid_zip(zip, format!("unsafe path {name:?}")));
|
||||
};
|
||||
if entry.is_symlink() {
|
||||
return Err(invalid_zip(zip, format!("symbolic link {name:?}")));
|
||||
}
|
||||
let out = dest.join(rel);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&out)?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = out.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// One byte over the budget tells a full budget from a file that exactly fits.
|
||||
let written =
|
||||
io::copy(&mut (&mut entry).take(budget + 1), &mut File::create(&out)?).map_err(|e| invalid_zip(zip, e))?;
|
||||
if written > budget {
|
||||
return Err(invalid_zip(zip, "it unpacks to more than 1 GB"));
|
||||
}
|
||||
budget -= written;
|
||||
}
|
||||
package_root(dest).ok_or_else(|| {
|
||||
IdeError::invalid(format!(
|
||||
"{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder",
|
||||
zip.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// `dir` itself, or its only folder as when a zip holds the package folder.
|
||||
/// Archiver metadata (`__MACOSX`, dot files) does not count as a second folder.
|
||||
fn package_root(dir: &Path) -> Option<PathBuf> {
|
||||
if dir.join(MANIFEST_FILE).is_file() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
let mut folders = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy();
|
||||
p.is_dir() && !name.starts_with('.') && name != "__MACOSX"
|
||||
});
|
||||
let only = folders.next().filter(|_| folders.next().is_none())?;
|
||||
only.join(MANIFEST_FILE).is_file().then_some(only)
|
||||
}
|
||||
+877
-238
File diff suppressed because it is too large
Load Diff
+436
-138
@@ -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,457 @@ pub enum NotificationLevel {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PluginSource {
|
||||
/// Installed under `<app-data>/plugins/<id>/`.
|
||||
Installed,
|
||||
/// Loaded from `<project>/.openidea/plugins/<dir>/` while that project is open.
|
||||
Dev,
|
||||
/// The focused editor of the window a command came from.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EditorContext {
|
||||
pub path: PathBuf,
|
||||
pub language: String,
|
||||
/// Selections when the command was invoked (cursors are empty ranges).
|
||||
pub selections: Vec<ide_core::Range>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PluginStatus {
|
||||
/// Code loaded and activated.
|
||||
Active,
|
||||
/// No code; static contributions only.
|
||||
Static,
|
||||
Disabled,
|
||||
/// Failed to load, or trapped and was disabled for this session (see `error`).
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginLanguage {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginTheme {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: ThemeKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub change_notes: Option<String>,
|
||||
pub source: PluginSource,
|
||||
/// Package directory.
|
||||
pub path: ide_core::IpcPath,
|
||||
pub enabled: bool,
|
||||
pub status: PluginStatus,
|
||||
pub error: Option<String>,
|
||||
pub has_code: bool,
|
||||
pub permissions: Vec<Permission>,
|
||||
/// Commands contributed while enabled (manifest + registered at runtime).
|
||||
pub commands: Vec<CommandDecl>,
|
||||
pub languages: Vec<PluginLanguage>,
|
||||
pub themes: Vec<PluginTheme>,
|
||||
}
|
||||
|
||||
/// A plugin package available for installation from a local catalog
|
||||
/// directory (the samples shipped with the IDE). Not installed by listing it.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogPlugin {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub change_notes: Option<String>,
|
||||
/// Package directory; install with `install_from_path(path)`.
|
||||
pub path: ide_core::IpcPath,
|
||||
pub has_code: bool,
|
||||
pub permissions: Vec<Permission>,
|
||||
pub commands: Vec<CommandDecl>,
|
||||
pub language_count: u32,
|
||||
pub theme_count: u32,
|
||||
/// Why the package cannot be installed as is (e.g. its WASM is not built).
|
||||
pub problem: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActiveTheme {
|
||||
pub plugin_id: String,
|
||||
pub theme: ThemeDef,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginsSnapshot {
|
||||
pub plugins: Vec<PluginInfo>,
|
||||
pub active_theme: Option<ActiveTheme>,
|
||||
}
|
||||
|
||||
/// Backend → UI notifications (Tauri event `ide://plugin`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PluginEvent {
|
||||
/// Plugins, their status or their commands changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
InsertText { plugin_id: String, text: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ReplaceSelection { plugin_id: String, text: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ThemeChanged { theme: Option<ActiveTheme> },
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
/// (`None` = broadcast, e.g. during activation).
|
||||
pub trait PluginEventSink: Send + Sync {
|
||||
fn emit(&self, client: Option<&str>, event: PluginEvent);
|
||||
}
|
||||
|
||||
impl<F: Fn(Option<&str>, PluginEvent) + Send + Sync> PluginEventSink for F {
|
||||
fn emit(&self, client: Option<&str>, event: PluginEvent) {
|
||||
self(client, event)
|
||||
}
|
||||
}
|
||||
|
||||
pub type BufferText = Arc<dyn Fn(&Path) -> Option<String> + Send + Sync>;
|
||||
|
||||
/// Where a command runs: the invoking window and its project.
|
||||
#[derive(Clone, Default)]
|
||||
/// Where a command or event batch comes from: the window and its project.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Invocation {
|
||||
pub client: Option<String>,
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
/// Unsaved editor contents win over disk for `read-file`.
|
||||
pub buffer_text: Option<BufferText>,
|
||||
pub editor: Option<EditorContext>,
|
||||
}
|
||||
|
||||
/// Per-call resource limits for plugin code.
|
||||
/// A failed host call, as the plugin sees it (WIT `types.error`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HostError {
|
||||
/// Not granted; names the capability.
|
||||
Denied(String),
|
||||
/// The document changed since the version an edit was made for.
|
||||
Stale,
|
||||
NotFound(String),
|
||||
Invalid(String),
|
||||
Cancelled,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HostError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Denied(c) => write!(f, "permission denied: {c} is not granted"),
|
||||
Self::Stale => f.write_str("the document changed"),
|
||||
Self::NotFound(m) => write!(f, "not found: {m}"),
|
||||
Self::Invalid(m) => write!(f, "invalid: {m}"),
|
||||
Self::Cancelled => f.write_str("cancelled"),
|
||||
Self::Failed(m) => f.write_str(m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin's status bar item.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusItem {
|
||||
pub text: String,
|
||||
pub tooltip: Option<String>,
|
||||
/// One of the plugin's commands, run on click.
|
||||
pub command: Option<String>,
|
||||
}
|
||||
|
||||
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum EventKind {
|
||||
FileOpened,
|
||||
FileSaved,
|
||||
EditorFocused,
|
||||
ActionPerformed,
|
||||
VcsChanged,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub const ALL: [Self; 6] = [
|
||||
Self::FileOpened,
|
||||
Self::FileSaved,
|
||||
Self::EditorFocused,
|
||||
Self::ActionPerformed,
|
||||
Self::VcsChanged,
|
||||
Self::Scheduled,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::FileOpened => "file-opened",
|
||||
Self::FileSaved => "file-saved",
|
||||
Self::EditorFocused => "editor-focused",
|
||||
Self::ActionPerformed => "action-performed",
|
||||
Self::VcsChanged => "vcs-changed",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|k| k.name() == s)
|
||||
}
|
||||
|
||||
/// The capability a subscription needs (`None`: `scheduled`, which the
|
||||
/// manifest declares).
|
||||
pub fn capability(self) -> Option<ide_extension::Capability> {
|
||||
match self {
|
||||
Self::ActionPerformed => Some(ide_extension::Capability::InputEvents),
|
||||
Self::Scheduled => None,
|
||||
_ => Some(ide_extension::Capability::Events),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the user started an action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ActionSource {
|
||||
Keyboard,
|
||||
Menu,
|
||||
Toolbar,
|
||||
ContextMenu,
|
||||
Search,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionPerformed {
|
||||
pub action: String,
|
||||
pub title: String,
|
||||
pub source: ActionSource,
|
||||
pub shortcut: Option<String>,
|
||||
}
|
||||
|
||||
/// A workbench event delivered to plugins.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum WorkbenchEvent {
|
||||
FileOpened(String),
|
||||
FileSaved(String),
|
||||
EditorFocused(Option<String>),
|
||||
ActionPerformed(ActionPerformed),
|
||||
VcsChanged(String),
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl WorkbenchEvent {
|
||||
pub fn kind(&self) -> EventKind {
|
||||
match self {
|
||||
Self::FileOpened(_) => EventKind::FileOpened,
|
||||
Self::FileSaved(_) => EventKind::FileSaved,
|
||||
Self::EditorFocused(_) => EventKind::EditorFocused,
|
||||
Self::ActionPerformed(_) => EventKind::ActionPerformed,
|
||||
Self::VcsChanged(_) => EventKind::VcsChanged,
|
||||
Self::Scheduled => EventKind::Scheduled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the repository containing a project (WIT `vcs.repository-status`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RepositoryStatus {
|
||||
pub root: String,
|
||||
pub branch: Option<String>,
|
||||
pub commit: Option<String>,
|
||||
pub upstream: Option<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
pub operation: Option<String>,
|
||||
pub changed_files: u32,
|
||||
}
|
||||
|
||||
/// Resource limits and supervision policy for plugin code.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
/// Wasm fuel (≈ instructions) per host → plugin call.
|
||||
pub fuel: u64,
|
||||
/// Wall-clock limit per call (epoch interruption).
|
||||
/// Wall-clock limit of commands, events, activation and deactivation.
|
||||
pub timeout: Duration,
|
||||
/// Wall-clock limit of a call that began a task (`tasks.begin`).
|
||||
pub background: Duration,
|
||||
/// Edit-path calls (syntax): the caller stops waiting after this and the
|
||||
/// result is dropped; the plugin keeps its instance.
|
||||
pub edit_soft: Duration,
|
||||
/// Edit-path calls still running after this are interrupted (the instance
|
||||
/// is rebuilt on next use).
|
||||
pub edit_hard: Duration,
|
||||
/// Linear memory cap per plugin.
|
||||
pub memory_bytes: usize,
|
||||
/// Wait before restarting after a crash; doubles with each consecutive crash.
|
||||
pub restart_backoff: Duration,
|
||||
/// Consecutive crashes after which the plugin is stopped for the session.
|
||||
pub max_failures: u32,
|
||||
/// Instances unused for this long are reclaimed (`None`: kept).
|
||||
pub idle_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { fuel: 5_000_000_000, timeout: Duration::from_secs(5), memory_bytes: 256 << 20 }
|
||||
Self {
|
||||
fuel: 5_000_000_000,
|
||||
timeout: Duration::from_secs(5),
|
||||
background: Duration::from_secs(60),
|
||||
edit_soft: Duration::from_millis(50),
|
||||
edit_hard: Duration::from_millis(500),
|
||||
memory_bytes: 256 << 20,
|
||||
restart_backoff: Duration::from_secs(1),
|
||||
max_failures: 3,
|
||||
idle_timeout: Some(Duration::from_secs(300)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// Delay before the restart that follows the `failures`-th consecutive crash.
|
||||
pub fn backoff(&self, failures: u32) -> Duration {
|
||||
let factor = 1u32 << failures.saturating_sub(1).min(6);
|
||||
(self.restart_backoff * factor).min(Duration::from_secs(60).max(self.restart_backoff))
|
||||
}
|
||||
}
|
||||
|
||||
/// A highlighted range from a plugin's `syntax-provider` (UTF-8 byte offsets).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SyntaxToken {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// A structure-view symbol from a plugin's `syntax-provider` (UTF-8 byte offsets).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SyntaxSymbol {
|
||||
pub name: String,
|
||||
pub detail: Option<String>,
|
||||
pub kind: String,
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub name_start: u32,
|
||||
pub name_end: u32,
|
||||
/// Index of the enclosing symbol in the same list.
|
||||
pub parent: Option<u32>,
|
||||
}
|
||||
|
||||
/// A foldable line range from a plugin's `syntax-provider`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SyntaxFold {
|
||||
pub start_line: u32,
|
||||
pub end_line: u32,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// An HTTP request from a plugin (WIT `net.request`); headers may hold
|
||||
/// `{{secret:<name>}}` placeholders for the host to fill in.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HttpRequest {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<Vec<u8>>,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// What a one-off process printed (WIT `process.output`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProcessOutput {
|
||||
pub status: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// Where `net:` requests may go: the URL's host when it is `https`, or
|
||||
/// `http` to the loopback interface.
|
||||
pub fn net_host(url: &str) -> Result<String, String> {
|
||||
let (scheme, rest) = url.split_once("://").ok_or_else(|| format!("{url:?} is not an absolute URL"))?;
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||
let host = if let Some(v6) = authority.strip_prefix('[') {
|
||||
v6.split(']').next().unwrap_or_default().to_owned()
|
||||
} else {
|
||||
authority.split(':').next().unwrap_or_default().to_ascii_lowercase()
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err(format!("{url:?} has no host"));
|
||||
}
|
||||
let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"https" => Ok(host),
|
||||
"http" if loopback => Ok(host),
|
||||
"http" => Err(format!("{url:?}: plain http is only allowed to localhost")),
|
||||
other => Err(format!("{url:?}: unsupported scheme {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn net_hosts() {
|
||||
assert_eq!(net_host("https://API.example.com:8443/v1?x").unwrap(), "api.example.com");
|
||||
assert_eq!(net_host("http://localhost:11434/api").unwrap(), "localhost");
|
||||
assert_eq!(net_host("https://user: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<String>,
|
||||
/// WIT `completion-kind` name (`method`, `keyword`, …).
|
||||
pub kind: Option<String>,
|
||||
pub insert_text: String,
|
||||
pub replace: Option<ide_core::Range>,
|
||||
pub sort_text: Option<String>,
|
||||
pub documentation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InlineSuggestion {
|
||||
pub insert_text: String,
|
||||
pub replace: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CodeAction {
|
||||
pub title: String,
|
||||
pub kind: Option<String>,
|
||||
pub preferred: bool,
|
||||
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LineMarker {
|
||||
pub line: u32,
|
||||
pub icon: Option<String>,
|
||||
pub tooltip: String,
|
||||
pub target: Option<Location>,
|
||||
}
|
||||
|
||||
// ---- declarative views (wit/views.wit) ---------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewButton {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub icon: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewTreeItem {
|
||||
pub key: String,
|
||||
pub parent: Option<String>,
|
||||
pub label: String,
|
||||
pub icon: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
pub expandable: bool,
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewListItem {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub icon: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum FieldValue {
|
||||
Text(String),
|
||||
Multiline(String),
|
||||
Checkbox(bool),
|
||||
Choice { options: Vec<String>, selected: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewField {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub value: FieldValue,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewTable {
|
||||
pub columns: Vec<String>,
|
||||
pub total_rows: u32,
|
||||
pub first_row: u32,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum ViewBody {
|
||||
Message(String),
|
||||
Tree(Vec<ViewTreeItem>),
|
||||
List(Vec<ViewListItem>),
|
||||
Form(Vec<ViewField>),
|
||||
Table(ViewTable),
|
||||
Html(String),
|
||||
}
|
||||
|
||||
/// What a view or custom editor shows.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct View {
|
||||
pub toolbar: Vec<ViewButton>,
|
||||
pub body: ViewBody,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// The table rows the UI shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Viewport {
|
||||
pub first_row: u32,
|
||||
pub rows: u32,
|
||||
}
|
||||
|
||||
/// A user action in a view.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum ViewEvent {
|
||||
Button { id: String },
|
||||
Activate { key: String },
|
||||
Select { key: String },
|
||||
Expand { key: String },
|
||||
Collapse { key: String },
|
||||
Field { key: String, value: String },
|
||||
Cell { row: u32, column: u32, value: String },
|
||||
InsertRow { before: u32 },
|
||||
DeleteRows { rows: Vec<u32> },
|
||||
Message { text: String },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
//! 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/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/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/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::{
|
||||
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
|
||||
vcs, views, workspace,
|
||||
};
|
||||
|
||||
/// A project handle: its root.
|
||||
pub struct ProjectRes {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
/// The invoking window's focused editor.
|
||||
pub struct EditorRes {
|
||||
ctx: EditorContext,
|
||||
}
|
||||
|
||||
/// The current call's invocation (read from [`StoreData`]).
|
||||
pub struct InvocationRes;
|
||||
|
||||
pub struct TaskRes {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
const MAX_EDITS: usize = 10_000;
|
||||
const MAX_DIAGNOSTICS: usize = 5_000;
|
||||
const MAX_REQUEST_BODY: usize = 8 << 20;
|
||||
const MAX_FIND: u32 = 10_000;
|
||||
const MAX_STATUS_TEXT: usize = 128;
|
||||
|
||||
type Fallible<T> = wasmtime::Result<Result<T, wit::Error>>;
|
||||
|
||||
pub(crate) fn to_wit(e: HostError) -> wit::Error {
|
||||
match e {
|
||||
HostError::Denied(c) => wit::Error::Denied(c),
|
||||
HostError::Stale => wit::Error::Stale,
|
||||
HostError::NotFound(m) => wit::Error::NotFound(m),
|
||||
HostError::Invalid(m) => wit::Error::Invalid(m),
|
||||
HostError::Cancelled => wit::Error::Cancelled,
|
||||
HostError::Failed(m) => wit::Error::Failed(m),
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest's `error` as text (`denied: net:example.com`, `stale`, …).
|
||||
impl From<wit::Error> for crate::runtime::GuestError {
|
||||
fn from(e: wit::Error) -> Self {
|
||||
let clip = |m: String| clip(m, crate::runtime::MAX_TITLE * 16);
|
||||
match e {
|
||||
wit::Error::Denied(c) => Self::Denied(clip(c)),
|
||||
wit::Error::Stale => Self::Stale,
|
||||
wit::Error::NotFound(m) => Self::NotFound(clip(m)),
|
||||
wit::Error::Invalid(m) => Self::Invalid(clip(m)),
|
||||
wit::Error::Cancelled => Self::Cancelled,
|
||||
wit::Error::Failed(m) => Self::Failed(clip(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 },
|
||||
}
|
||||
}
|
||||
|
||||
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 push<T: Send + 'static>(&mut self, value: T) -> wasmtime::Result<Resource<T>> {
|
||||
Ok(self.table.push(value)?)
|
||||
}
|
||||
|
||||
pub(crate) fn new_invocation(&mut self) -> wasmtime::Result<Resource<InvocationRes>> {
|
||||
self.push(InvocationRes)
|
||||
}
|
||||
|
||||
pub(crate) fn drop_invocation(&mut self, res: Resource<InvocationRes>) {
|
||||
let _ = self.table.delete(res);
|
||||
}
|
||||
}
|
||||
|
||||
impl wit::Host for StoreData {}
|
||||
|
||||
impl log::Host for StoreData {
|
||||
fn log(&mut self, level: wit::LogLevel, message: String) -> wasmtime::Result<()> {
|
||||
let id = &self.plugin_id;
|
||||
let message = clip(message, crate::runtime::MAX_TEXT);
|
||||
match level {
|
||||
wit::LogLevel::Trace => tracing::trace!(plugin = %id, "{message}"),
|
||||
wit::LogLevel::Debug => tracing::debug!(plugin = %id, "{message}"),
|
||||
wit::LogLevel::Info => tracing::info!(plugin = %id, "{message}"),
|
||||
wit::LogLevel::Warn => tracing::warn!(plugin = %id, "{message}"),
|
||||
wit::LogLevel::Error => tracing::error!(plugin = %id, "{message}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ui::Host for StoreData {
|
||||
fn show_notification(
|
||||
&mut self,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
) -> wasmtime::Result<()> {
|
||||
let level = match level {
|
||||
wit::NotificationLevel::Info => NotificationLevel::Info,
|
||||
wit::NotificationLevel::Warning => NotificationLevel::Warning,
|
||||
wit::NotificationLevel::Error => NotificationLevel::Error,
|
||||
};
|
||||
let max = crate::runtime::MAX_TITLE;
|
||||
self.host.notify(self.client(), level, clip(title, max), clip(body, 16 << 10));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_status_item(&mut self, id: String, item: ui::StatusItem) -> wasmtime::Result<()> {
|
||||
let item = StatusItem {
|
||||
text: clip(item.text, MAX_STATUS_TEXT),
|
||||
tooltip: item.tooltip.map(|t| clip(t, 1024)),
|
||||
command: item.command.map(|c| clip(c, 128)),
|
||||
};
|
||||
self.host.status_item(&clip(id, 64), Some(item));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_status_item(&mut self, id: String) -> wasmtime::Result<()> {
|
||||
self.host.status_item(&clip(id, 64), None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl storage::Host for StoreData {
|
||||
fn get(&mut self, key: String) -> Fallible<Option<String>> {
|
||||
if let Err(e) = self.require(Capability::Storage) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.storage_get(&key).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn set(&mut self, key: String, value: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Storage) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.storage_set(&key, Some(value)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn delete(&mut self, key: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Storage) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.storage_set(&key, None).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn keys(&mut self, prefix: String) -> Fallible<Vec<String>> {
|
||||
if let Err(e) = self.require(Capability::Storage) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.storage_keys(&prefix).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl workspace::Host for StoreData {
|
||||
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for root in self.host.projects() {
|
||||
out.push(self.push(ProjectRes { root })?);
|
||||
}
|
||||
Ok(Ok(out))
|
||||
}
|
||||
}
|
||||
|
||||
impl workspace::HostProject for StoreData {
|
||||
fn root(&mut self, this: Resource<ProjectRes>) -> Fallible<String> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
fn read_file(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<String> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
if let Some(text) = self.host.buffer_text(&real) {
|
||||
return Ok(Ok(text));
|
||||
}
|
||||
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn find_files(&mut self, this: Resource<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
Ok(self.host.find_files(&root, &glob, limit.min(MAX_FIND) as usize).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<ProjectRes>) -> wasmtime::Result<()> {
|
||||
self.table.delete(rep)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl editor::Host for StoreData {}
|
||||
|
||||
impl editor::HostTextEditor for StoreData {
|
||||
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn language(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
Ok(self.table.get(&this)?.ctx.language.clone())
|
||||
}
|
||||
|
||||
fn document(&mut self, this: Resource<EditorRes>) -> Fallible<editor::Document> {
|
||||
if let Err(e) = self.require(Capability::EditorRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let path = self.table.get(&this)?.ctx.path.clone();
|
||||
Ok(match self.host.document(&path) {
|
||||
Some((version, text)) => Ok(editor::Document { version, text }),
|
||||
None => Err(wit::Error::NotFound(format!("{} is not open", path.display()))),
|
||||
})
|
||||
}
|
||||
|
||||
fn selections(&mut self, this: Resource<EditorRes>) -> Fallible<Vec<wit::Range>> {
|
||||
if let Err(e) = self.require(Capability::EditorRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(Ok(self.table.get(&this)?.ctx.selections.iter().map(range_to_wit).collect()))
|
||||
}
|
||||
|
||||
fn apply_edits(&mut self, this: Resource<EditorRes>, version: u64, edits: Vec<wit::TextEdit>) -> Fallible<u64> {
|
||||
if let Err(e) = self.require(Capability::EditorWrite) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if edits.len() > MAX_EDITS {
|
||||
return Ok(Err(wit::Error::Invalid(format!("more than {MAX_EDITS} edits"))));
|
||||
}
|
||||
let total: usize = edits.iter().map(|e| e.text.len()).sum();
|
||||
if total > crate::runtime::MAX_TEXT * 8 {
|
||||
return Ok(Err(wit::Error::Invalid("edits too large".into())));
|
||||
}
|
||||
let path = self.table.get(&this)?.ctx.path.clone();
|
||||
let edits: Vec<ide_core::TextEdit> = edits
|
||||
.into_iter()
|
||||
.map(|e| ide_core::TextEdit {
|
||||
range: ide_core::Range::new(pos(e.range.start), pos(e.range.end)),
|
||||
new_text: e.text,
|
||||
})
|
||||
.collect();
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.apply_edits(client.as_deref(), &path, version, edits).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<EditorRes>) -> wasmtime::Result<()> {
|
||||
self.table.delete(rep)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl invocation::Host for StoreData {}
|
||||
|
||||
impl invocation::HostInvocation for StoreData {
|
||||
fn project(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Option<Resource<ProjectRes>>> {
|
||||
match self.invocation.workspace_root.clone() {
|
||||
Some(root) => Ok(Some(self.push(ProjectRes { root })?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn editor(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Option<Resource<EditorRes>>> {
|
||||
match self.invocation.editor.clone() {
|
||||
Some(ctx) => Ok(Some(self.push(EditorRes { ctx })?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<InvocationRes>) -> wasmtime::Result<()> {
|
||||
// Owned by the host for the duration of the call.
|
||||
let _ = rep;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl vcs::Host for StoreData {
|
||||
fn status(&mut self, project: Resource<ProjectRes>) -> Fallible<Option<vcs::RepositoryStatus>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self
|
||||
.host
|
||||
.vcs_status(&root)
|
||||
.map(|s| {
|
||||
s.map(|s| vcs::RepositoryStatus {
|
||||
root: s.root,
|
||||
branch: s.branch,
|
||||
commit: s.commit,
|
||||
upstream: s.upstream,
|
||||
ahead: s.ahead,
|
||||
behind: s.behind,
|
||||
operation: s.operation,
|
||||
changed_files: s.changed_files,
|
||||
})
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
|
||||
fn fetch(&mut self, project: Resource<ProjectRes>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::VcsWrite) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
let token = self.cancel.token();
|
||||
Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from_wit(k: events::EventKind) -> EventKind {
|
||||
match k {
|
||||
events::EventKind::FileOpened => EventKind::FileOpened,
|
||||
events::EventKind::FileSaved => EventKind::FileSaved,
|
||||
events::EventKind::EditorFocused => EventKind::EditorFocused,
|
||||
events::EventKind::ActionPerformed => EventKind::ActionPerformed,
|
||||
events::EventKind::VcsChanged => EventKind::VcsChanged,
|
||||
events::EventKind::Scheduled => EventKind::Scheduled,
|
||||
}
|
||||
}
|
||||
|
||||
impl events::Host for StoreData {
|
||||
fn subscribe(&mut self, kind: events::EventKind) -> Fallible<()> {
|
||||
let kind = kind_from_wit(kind);
|
||||
if let Some(cap) = kind.capability()
|
||||
&& let Err(e) = self.require(cap)
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
self.host.subscribe(kind, true);
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, kind: events::EventKind) -> wasmtime::Result<()> {
|
||||
self.host.subscribe(kind_from_wit(kind), false);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl tasks::Host for StoreData {
|
||||
fn begin(&mut self, title: String, cancellable: bool) -> wasmtime::Result<Resource<TaskRes>> {
|
||||
let token = self.cancel.token();
|
||||
let id = self.host.task_begin(self.client(), clip(title, crate::runtime::MAX_TITLE), cancellable, token);
|
||||
self.open_tasks.push(id);
|
||||
// The call may now run for the background budget.
|
||||
let extended = self.call_started + self.limits.background;
|
||||
if extended > self.deadline {
|
||||
self.deadline = extended;
|
||||
self.timeout = self.limits.background;
|
||||
}
|
||||
self.push(TaskRes { id })
|
||||
}
|
||||
}
|
||||
|
||||
impl tasks::HostTask for StoreData {
|
||||
fn report(
|
||||
&mut self,
|
||||
this: Resource<TaskRes>,
|
||||
done: u32,
|
||||
total: Option<u32>,
|
||||
message: Option<String>,
|
||||
) -> wasmtime::Result<()> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
let now = Instant::now();
|
||||
// At most ~10 updates per second reach the UI.
|
||||
if self.last_report.is_none_or(|t| now.duration_since(t).as_millis() >= 100) || total.is_some_and(|t| done >= t)
|
||||
{
|
||||
self.last_report = Some(now);
|
||||
self.host.task_report(id, done, total, message.map(|m| clip(m, 256)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_cancelled(&mut self, this: Resource<TaskRes>) -> wasmtime::Result<bool> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
Ok(self.cancel.token().is_cancelled() || self.host.task_cancelled(id))
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<TaskRes>) -> wasmtime::Result<()> {
|
||||
let id = self.table.delete(rep)?.id;
|
||||
self.open_tasks.retain(|t| *t != id);
|
||||
self.host.task_end(id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreData {
|
||||
/// What is left of the running call's wall-clock budget (host operations
|
||||
/// can't be interrupted by the epoch callback, so they get this as timeout).
|
||||
fn remaining(&self) -> Duration {
|
||||
self.deadline.saturating_duration_since(Instant::now())
|
||||
}
|
||||
}
|
||||
|
||||
impl diagnostics::Host for StoreData {
|
||||
fn publish(&mut self, path: String, items: Vec<diagnostics::Diagnostic>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let source = self.plugin_id.clone();
|
||||
let items = items
|
||||
.into_iter()
|
||||
.take(MAX_DIAGNOSTICS)
|
||||
.map(|d| ide_core::Diagnostic {
|
||||
range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)),
|
||||
severity: match d.severity {
|
||||
diagnostics::Severity::Error => ide_core::Severity::Error,
|
||||
diagnostics::Severity::Warning => ide_core::Severity::Warning,
|
||||
diagnostics::Severity::Info => ide_core::Severity::Info,
|
||||
diagnostics::Severity::Hint => ide_core::Severity::Hint,
|
||||
},
|
||||
message: clip(d.message, 4096),
|
||||
source: Some(source.clone()),
|
||||
code: d.code.map(|c| clip(c, 128)),
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: Some(source.clone()),
|
||||
})
|
||||
.collect();
|
||||
Ok(self.host.publish_diagnostics(Path::new(&path), items).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl net::Host for StoreData {
|
||||
fn fetch(&mut self, req: net::Request) -> Fallible<net::Response> {
|
||||
let host = match net_host(&req.url) {
|
||||
Ok(h) => h,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
if let Err(e) = self.require(Capability::Net(host)) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if req.headers.iter().any(|h| h.value.contains("{{secret:"))
|
||||
&& let Err(e) = self.require(Capability::Secrets)
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if req.body.as_ref().is_some_and(|b| b.len() > MAX_REQUEST_BODY) {
|
||||
return Ok(Err(wit::Error::Invalid("request body larger than 8 MiB".into())));
|
||||
}
|
||||
let wanted = Duration::from_millis(u64::from(req.timeout_ms.unwrap_or(30_000)));
|
||||
let request = HttpRequest {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
|
||||
body: req.body,
|
||||
timeout: wanted.min(self.remaining()),
|
||||
};
|
||||
let token = self.cancel.token();
|
||||
Ok(self
|
||||
.host
|
||||
.fetch(request, &token)
|
||||
.map(|r| net::Response {
|
||||
status: r.status,
|
||||
headers: r.headers.into_iter().map(|(name, value)| net::Header { name, value }).collect(),
|
||||
body: r.body,
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl secrets::Host for StoreData {
|
||||
fn is_set(&mut self, name: String) -> wasmtime::Result<bool> {
|
||||
Ok(self.granted.iter().any(|g| g.covers(&Capability::Secrets)) && self.host.secret_is_set(&name))
|
||||
}
|
||||
}
|
||||
|
||||
impl process::Host for StoreData {
|
||||
fn run(
|
||||
&mut self,
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
cwd: Option<Resource<ProjectRes>>,
|
||||
stdin: Option<String>,
|
||||
) -> Fallible<process::Output> {
|
||||
if let Err(e) = self.require(Capability::Process(program.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let cwd = match &cwd {
|
||||
Some(p) => Some(self.table.get(p)?.root.clone()),
|
||||
None => None,
|
||||
};
|
||||
let token = self.cancel.token();
|
||||
let timeout = self.remaining();
|
||||
Ok(self
|
||||
.host
|
||||
.run_process(&program, args, cwd.as_deref(), stdin, &token, timeout)
|
||||
.map(|o| process::Output { status: o.status, stdout: o.stdout, stderr: o.stderr })
|
||||
.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl views::Host for StoreData {
|
||||
fn refresh(&mut self, view: String) -> wasmtime::Result<()> {
|
||||
self.host.refresh_view(&clip(view, 64));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn post_message(&mut self, view: String, message: String) -> Fallible<()> {
|
||||
if message.len() > MAX_VIEW_MESSAGE {
|
||||
return Ok(Err(wit::Error::Invalid(format!("messages are limited to {MAX_VIEW_MESSAGE} bytes"))));
|
||||
}
|
||||
Ok(self.host.post_view_message(&clip(view, 64), message).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn open_terminal(&mut self, title: String, program: String, args: Vec<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Process(program.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if args.len() > 256 || args.iter().any(|a| a.len() > 4096) {
|
||||
return Ok(Err(wit::Error::Invalid("too many or too long arguments".into())));
|
||||
}
|
||||
let cwd = self.invocation.workspace_root.clone();
|
||||
let client = self.invocation.client.clone();
|
||||
let title = clip(title, crate::runtime::MAX_TITLE);
|
||||
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl settings::Host for StoreData {
|
||||
fn get(&mut self, key: String) -> wasmtime::Result<Option<String>> {
|
||||
let root = self.invocation.workspace_root.clone();
|
||||
Ok(self.host.setting(root.as_deref(), &clip(key, 128)))
|
||||
}
|
||||
}
|
||||
|
||||
impl views_bindings::openidea::plugin::view_types::Host for StoreData {}
|
||||
|
||||
/// Largest message between a plugin and its view's frame.
|
||||
const MAX_VIEW_MESSAGE: usize = 1 << 20;
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Plugin API versioning (plugins/README.md, "Evolving the plugin API").
|
||||
//!
|
||||
//! - The `wit/` package version, `ENGINE_VERSION` and `API_VERSION` agree.
|
||||
//! - The version check accepts components the host can run and explains the rest.
|
||||
//! - Components built for 1.0 link against a host of a later 1.x minor that
|
||||
//! adds functions and interfaces (wasmtime resolves `@1.0.0` names to a
|
||||
//! semver-compatible definition), and the host finds their exports under
|
||||
//! the newer name; a 2.0 host does not link them. The newer host is
|
||||
//! simulated with a linker that stubs every plugin import the component has
|
||||
//! at the newer version, so no copy of the WIT is needed.
|
||||
//!
|
||||
//! Components: the checked-in `dist/plugin.wasm` of the `hello` fixture, the
|
||||
//! official plugins (`plugins/build.sh` rebuilds them).
|
||||
|
||||
use ide_plugin::{API_VERSION, PluginEngine, check_api, component_api, host_api};
|
||||
use std::path::{Path, PathBuf};
|
||||
use wasmtime::component::types::ComponentItem;
|
||||
use wasmtime::component::{Component, Linker, ResourceTable, ResourceType};
|
||||
use wasmtime::{Config, Engine};
|
||||
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
fn repo() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
/// Every component the repository builds for users: the official plugins with code, plus the `hello` fixture.
|
||||
fn components() -> Vec<PathBuf> {
|
||||
let mut out = vec![repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm")];
|
||||
let official = repo().join("plugins/official");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
dirs.sort();
|
||||
out.extend(dirs.into_iter().map(|d| d.join("dist/plugin.wasm")).filter(|p| p.is_file()));
|
||||
assert!(out.len() >= 9, "expected hello and the official plugins, found {out:?}");
|
||||
out
|
||||
}
|
||||
|
||||
fn v(s: &str) -> semver::Version {
|
||||
semver::Version::parse(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_package_engine_and_api_versions_agree() {
|
||||
let host = host_api();
|
||||
assert_eq!(API_VERSION, format!("{}.{}", host.major, host.minor));
|
||||
let want = format!("package openidea:plugin@{host};");
|
||||
let mut files = 0;
|
||||
for entry in std::fs::read_dir(repo().join("wit")).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_some_and(|e| e == "wit") {
|
||||
let src = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(src.lines().any(|l| l.trim() == want), "{} does not declare `{want}`", path.display());
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
assert!(files > 1);
|
||||
}
|
||||
|
||||
/// The SDK ships its own copy of `wit/` (it builds outside this repository);
|
||||
/// it must be the API this host implements.
|
||||
#[test]
|
||||
fn the_sdk_carries_the_hosts_wit() {
|
||||
let read = |dir: PathBuf| {
|
||||
let mut files: Vec<(String, String)> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "wit"))
|
||||
.map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read_to_string(&p).unwrap()))
|
||||
.collect();
|
||||
files.sort();
|
||||
files
|
||||
};
|
||||
let host = read(repo().join("wit"));
|
||||
let sdk = read(repo().join("sdk/rust/openidea-plugin/wit"));
|
||||
let names = |f: &[(String, String)]| f.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&sdk), names(&host), "sdk/rust/openidea-plugin/wit has other files than wit/");
|
||||
for ((name, a), (_, b)) in host.iter().zip(&sdk) {
|
||||
assert!(a == b, "sdk/rust/openidea-plugin/wit/{name} differs from wit/{name}: copy wit/ into the SDK");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
|
||||
let host = v("1.3.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.0.0-rc.1"] {
|
||||
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
|
||||
}
|
||||
let newer = check_api(&v("1.4.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.4") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
|
||||
let older = check_api(&v("0.2.0"), &host).unwrap_err();
|
||||
assert!(older.contains("rebuild it with the 1.3 SDK"), "{older}");
|
||||
// 0.x: every minor is its own API.
|
||||
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
|
||||
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
|
||||
assert!(check_api(&v("0.3.0"), &v("0.2.0")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_components_api_is_the_highest_plugin_interface_version() {
|
||||
let names = ["wasi:io/poll@0.2.6", "openidea:plugin/log@1.0.0", "openidea:plugin/views@1.2.0"];
|
||||
assert_eq!(component_api(names), Ok(Some(v("1.2.0"))));
|
||||
assert_eq!(component_api(["wasi:cli/stdout@0.2.6"]), Ok(None));
|
||||
assert!(component_api(["openidea:plugin/log"]).unwrap_err().contains("unversioned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_components_are_built_for_this_api() {
|
||||
let engine = PluginEngine::new().unwrap();
|
||||
let host = host_api();
|
||||
for wasm in components() {
|
||||
let component = engine.compile(&wasm).unwrap();
|
||||
let built_for = engine.check_component(&component).unwrap_or_else(|e| panic!("{}: {e}", wasm.display()));
|
||||
let built_for = built_for.unwrap_or_else(|| panic!("{} uses no plugin interface", wasm.display()));
|
||||
assert_eq!((built_for.major, built_for.minor), (host.major, host.minor), "{}", wasm.display());
|
||||
}
|
||||
}
|
||||
|
||||
// --- a later host, simulated -------------------------------------------------------------
|
||||
|
||||
struct Data {
|
||||
wasi: WasiCtx,
|
||||
table: ResourceTable,
|
||||
}
|
||||
|
||||
impl WasiView for Data {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView { ctx: &mut self.wasi, table: &mut self.table }
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct host types for the stubbed resources (a component may import several).
|
||||
struct Stub<const N: usize>;
|
||||
|
||||
fn stub_resource(n: usize) -> ResourceType {
|
||||
macro_rules! pick { ($($i:literal)*) => { match n { $($i => ResourceType::host::<Stub<$i>>(),)* _ => panic!("more than 16 resources") } } }
|
||||
pick!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15)
|
||||
}
|
||||
|
||||
enum Host<'a> {
|
||||
/// Defines everything the component imports, plus a function and an
|
||||
/// interface the component doesn't know.
|
||||
Superset,
|
||||
/// Leaves out one function, so linking must fail.
|
||||
Without(&'a str),
|
||||
}
|
||||
|
||||
/// A linker with WASI and every `openidea:plugin/*` interface `component`
|
||||
/// imports, defined at `version` with functions that are never called.
|
||||
fn host_at(engine: &Engine, component: &Component, version: &str, host: Host<'_>) -> Linker<Data> {
|
||||
let mut linker = Linker::new(engine);
|
||||
wasmtime_wasi::p2::add_to_linker_sync(&mut linker).unwrap();
|
||||
// The component's resource → its stub. A resource re-exported through
|
||||
// `use` (`invocation` uses `editor.text-editor`) is the same type, so it
|
||||
// must get the same stub.
|
||||
let mut resources: Vec<(ResourceType, ResourceType)> = Vec::new();
|
||||
for (name, item) in component.component_type().imports(engine) {
|
||||
let Some(rest) = name.strip_prefix("openidea:plugin/") else { continue };
|
||||
let iface = rest.split_once('@').map_or(rest, |(i, _)| i);
|
||||
let ComponentItem::ComponentInstance(instance) = item.ty else { panic!("{name} is not an instance") };
|
||||
let mut defined = linker.instance(&format!("openidea:plugin/{iface}@{version}")).unwrap();
|
||||
for (export, item) in instance.exports(engine) {
|
||||
match item.ty {
|
||||
ComponentItem::ComponentFunc(_) if !matches!(host, Host::Without(f) if f == export) => {
|
||||
defined.func_new(export, |_, _, _, _| Err(wasmtime::Error::msg("stub"))).unwrap();
|
||||
}
|
||||
ComponentItem::Resource(ty) => {
|
||||
let stub = match resources.iter().find(|(r, _)| *r == ty) {
|
||||
Some((_, stub)) => *stub,
|
||||
None => {
|
||||
let stub = stub_resource(resources.len());
|
||||
resources.push((ty, stub));
|
||||
stub
|
||||
}
|
||||
};
|
||||
defined.resource(export, stub, |_, _| Ok(())).unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if matches!(host, Host::Superset) {
|
||||
defined.func_new("added-in-a-later-minor", |_, _, _, _| Ok(())).unwrap();
|
||||
}
|
||||
}
|
||||
if matches!(host, Host::Superset) {
|
||||
let mut added = linker.instance(&format!("openidea:plugin/added-interface@{version}")).unwrap();
|
||||
added.func_new("call", |_, _, _, _| Ok(())).unwrap();
|
||||
}
|
||||
linker
|
||||
}
|
||||
|
||||
fn engine() -> Engine {
|
||||
let mut config = Config::new();
|
||||
config.wasm_component_model(true);
|
||||
Engine::new(&config).unwrap()
|
||||
}
|
||||
|
||||
/// The next minor of the API this host implements.
|
||||
fn next_minor() -> String {
|
||||
let host = host_api();
|
||||
format!("{}.{}.0", host.major, host.minor + 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_link_against_a_later_minor_that_adds_functions_and_interfaces() {
|
||||
let engine = engine();
|
||||
let next = next_minor();
|
||||
for wasm in components() {
|
||||
let component = Component::from_file(&engine, &wasm).unwrap();
|
||||
let linker = host_at(&engine, &component, &next, Host::Superset);
|
||||
linker.instantiate_pre(&component).unwrap_or_else(|e| panic!("{}: {e:?}", wasm.display()));
|
||||
|
||||
let exports: Vec<String> = component
|
||||
.component_type()
|
||||
.exports(&engine)
|
||||
.map(|(n, _)| n.to_owned())
|
||||
.filter(|n| n.starts_with("openidea:plugin/"))
|
||||
.collect();
|
||||
assert!(!exports.is_empty(), "{} exports no plugin interface", wasm.display());
|
||||
for name in exports {
|
||||
let iface = name.split_once('@').unwrap().0;
|
||||
let newer = format!("{iface}@{next}");
|
||||
assert!(
|
||||
component.get_export_index(None, &newer).is_some(),
|
||||
"{}: a {next} host can't find export {name}",
|
||||
wasm.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_do_not_link_against_another_major() {
|
||||
let engine = engine();
|
||||
let next_major = format!("{}.0.0", host_api().major + 1);
|
||||
let wasm = repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm");
|
||||
let component = Component::from_file(&engine, &wasm).unwrap();
|
||||
let linker = host_at(&engine, &component, &next_major, Host::Superset);
|
||||
assert!(linker.instantiate_pre(&component).is_err());
|
||||
let iface = component
|
||||
.component_type()
|
||||
.exports(&engine)
|
||||
.map(|(n, _)| n.to_owned())
|
||||
.find(|n| n.starts_with("openidea:plugin/"))
|
||||
.unwrap();
|
||||
let newer = format!("{}@{next_major}", iface.split_once('@').unwrap().0);
|
||||
assert!(component.get_export_index(None, &newer).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_minor_that_drops_a_function_does_not_link() {
|
||||
// Keeps the test above honest: the stub host really is type-checked.
|
||||
let engine = engine();
|
||||
let wasm = repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm");
|
||||
let component = Component::from_file(&engine, &wasm).unwrap();
|
||||
let (iface, func) = component
|
||||
.component_type()
|
||||
.imports(&engine)
|
||||
.filter(|(n, _)| n.starts_with("openidea:plugin/"))
|
||||
.find_map(|(n, item)| match item.ty {
|
||||
ComponentItem::ComponentInstance(i) => i
|
||||
.exports(&engine)
|
||||
.find(|(_, it)| matches!(it.ty, ComponentItem::ComponentFunc(_)))
|
||||
.map(|(f, _)| (n.to_owned(), f.to_owned())),
|
||||
_ => None,
|
||||
})
|
||||
.expect("hello imports a plugin function");
|
||||
let linker = host_at(&engine, &component, &next_minor(), Host::Without(&func));
|
||||
let err = linker.instantiate_pre(&component).err().unwrap_or_else(|| panic!("{iface} without {func} linked"));
|
||||
assert!(format!("{err:?}").contains(&func), "{err:?}");
|
||||
}
|
||||
+8
-1
@@ -95,12 +95,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-hello"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-hello"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
+2
-2
@@ -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"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
86dfe70a30ab1f7955a16e96c1b32c8be663456b871cc4d3bed7dc6052c062da
|
||||
@@ -0,0 +1,52 @@
|
||||
id = "hello"
|
||||
name = "Hello"
|
||||
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.
|
||||
"""
|
||||
engines.openidea = "^1.0"
|
||||
wasm = "dist/plugin.wasm"
|
||||
# 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;
|
||||
# a command must be declared here to exist.
|
||||
[[contributes.commands]]
|
||||
id = "insert-date"
|
||||
title = "Hello: Insert Date"
|
||||
category = "Hello"
|
||||
|
||||
[[contributes.commands]]
|
||||
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"
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
|
||||
//! Build with `openidea-plugin build plugins/hello` (see plugins/README.md).
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Hello {
|
||||
/// Expanded languages of the Greetings tree.
|
||||
expanded: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// 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 Plugin for Hello {
|
||||
fn activate(&mut self, ctx: &Context) -> Result<(), Error> {
|
||||
host::log(LogLevel::Info, &format!("hello {} activated", ctx.plugin_version));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deactivate(&mut self) {
|
||||
host::log(LogLevel::Info, "hello deactivated");
|
||||
}
|
||||
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
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();
|
||||
editor.transform_selections(|_| date.clone())?;
|
||||
Ok(Some(date))
|
||||
}
|
||||
"word-count" => {
|
||||
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::notify(NotificationLevel::Info, &format!("Word count: {name}"), &body);
|
||||
Ok(Some(body))
|
||||
}
|
||||
other => Err(Error::NotFound(format!("command {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (y, m, d) = civil_from_days(secs.div_euclid(86_400));
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
/// Howard Hinnant's days-to-civil algorithm.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
#[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_eq!(err, Error::Denied("editor.write".into()));
|
||||
assert_eq!(host.editor_text(), "");
|
||||
}
|
||||
}
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-jobs"
|
||||
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"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "openidea-plugin-jobs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# Test fixture for the plugin host: built by `plugins/build.sh <this dir>`, checked in as dist/plugin.wasm.
|
||||
[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"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
0dacb82ac35c1f132dc66e85dbf0663b729f818279d3b32cd06403e502fbf880
|
||||
@@ -0,0 +1,29 @@
|
||||
# Test fixture: a tool window listing what an external program reports, and a
|
||||
# terminal following one entry. Covers pinned programs (`process:`), terminals,
|
||||
# settings and tree views, locally and in remote projects.
|
||||
id = "jobs"
|
||||
name = "Jobs"
|
||||
version = "0.1.0"
|
||||
description = "Test fixture: lists the jobs `jobs list` prints and follows one in the terminal."
|
||||
authors = ["OpenIDEA contributors"]
|
||||
engines.openidea = "^1.0"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-view:jobs", "on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["process:jobs"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "jobs"
|
||||
title = "Jobs"
|
||||
icon = "toolwindow-services"
|
||||
location = "right"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "follow"
|
||||
title = "Jobs: Follow Selected Job"
|
||||
category = "Jobs"
|
||||
|
||||
[contributes.settings]
|
||||
title = "Jobs"
|
||||
schema = "settings.schema.json"
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"title": "Terminal title prefix",
|
||||
"default": "follow: "
|
||||
},
|
||||
"lines": {
|
||||
"type": "integer",
|
||||
"title": "Lines to show first",
|
||||
"default": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Test fixture: a tree of the jobs `jobs list` prints (one per line, through
|
||||
//! the pinned `process:jobs`), and a terminal running `jobs follow <job>` for
|
||||
//! the activated one, titled and sized by the plugin's settings.
|
||||
|
||||
use openidea_plugin::host;
|
||||
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
|
||||
|
||||
pub const VIEW: &str = "jobs";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Jobs {
|
||||
selected: Option<String>,
|
||||
}
|
||||
|
||||
fn list() -> Result<Vec<String>, Error> {
|
||||
let out = host::run_process("jobs", &["list".to_owned()], None)?;
|
||||
if out.status != Some(0) {
|
||||
return Err(Error::Failed(out.stderr.trim().to_owned()));
|
||||
}
|
||||
Ok(out.stdout.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_owned).collect())
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
fn follow(&self) -> Result<(), Error> {
|
||||
let job = self.selected.as_deref().ok_or_else(|| Error::Invalid("select a job first".into()))?;
|
||||
let title = host::setting_string("title").unwrap_or_else(|| "follow: ".into());
|
||||
let lines = host::setting_number("lines").map_or(20, |n| n.max(0.0) as u64);
|
||||
let args = ["follow".to_owned(), job.to_owned(), format!("--lines={lines}")];
|
||||
host::open_terminal(&format!("{title}{job}"), "jobs", &args)
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Jobs {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"follow" => self.follow().map(|()| None),
|
||||
_ => Err(Error::NotFound(format!("command {id}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Views for Jobs {
|
||||
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
|
||||
if view != VIEW {
|
||||
return Err(Error::NotFound(view.into()));
|
||||
}
|
||||
let follow = Button::new("follow", "Follow").icon("terminal").enabled(self.selected.is_some());
|
||||
Ok(match list() {
|
||||
Ok(jobs) => View::new(Body::Tree(jobs.iter().map(|j| TreeItem::new(j, j).icon("run")).collect())),
|
||||
Err(e) => View::message(&format!("jobs: {e}")),
|
||||
}
|
||||
.button(follow))
|
||||
}
|
||||
|
||||
fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
|
||||
if view != VIEW {
|
||||
return Err(Error::NotFound(view.into()));
|
||||
}
|
||||
match event {
|
||||
ViewEvent::Select(key) => self.selected = Some(key),
|
||||
ViewEvent::Activate(key) => {
|
||||
self.selected = Some(key);
|
||||
return self.follow();
|
||||
}
|
||||
ViewEvent::Button(b) if b == "follow" => return self.follow(),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
host::refresh_view(VIEW);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Jobs, views);
|
||||
Vendored
Binary file not shown.
@@ -0,0 +1 @@
|
||||
0ad2e4c6977a576b7eb27fc1e535fd056dd9da64bdfe6cfda9ca9f57537d0384
|
||||
+39
-4
@@ -1,7 +1,42 @@
|
||||
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"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "echo"
|
||||
title = "echo"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "panic"
|
||||
title = "panic"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "spin"
|
||||
title = "spin"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "alloc"
|
||||
title = "alloc"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "read"
|
||||
title = "read"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "root"
|
||||
title = "root"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "insert"
|
||||
title = "insert"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "cancel"
|
||||
title = "cancel"
|
||||
|
||||
+107
-16
@@ -1,25 +1,44 @@
|
||||
//! Test fixture for the ide-plugin sandbox. Never shipped.
|
||||
|
||||
// `plugin` with syntax and editor providers.
|
||||
wit_bindgen::generate!({
|
||||
world: "plugin",
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.0.0;
|
||||
export openidea:plugin/providers@1.0.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
world: "fixture:misbehave/misbehave",
|
||||
generate_all,
|
||||
});
|
||||
|
||||
use openidea::plugin::host;
|
||||
use exports::openidea::plugin::providers::{self, CodeAction, CompletionItem, Document, InlineSuggestion, LineMarker};
|
||||
use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token};
|
||||
use exports::openidea::plugin::{commands, event_handler, lifecycle};
|
||||
use openidea::plugin::{invocation::Invocation, types};
|
||||
|
||||
struct Misbehave;
|
||||
|
||||
impl Guest for Misbehave {
|
||||
fn activate(_ctx: Context) -> Result<(), String> {
|
||||
for id in ["echo", "panic", "spin", "alloc", "read", "root", "insert", "language", "theme"] {
|
||||
host::register_command(id, id, None);
|
||||
fn failed(message: &str) -> types::Error {
|
||||
types::Error::Failed(message.into())
|
||||
}
|
||||
|
||||
impl lifecycle::Guest for Misbehave {
|
||||
fn activate(ctx: types::Context) -> Result<(), types::Error> {
|
||||
// A deliberately broken build (rollback tests): `version = "…-broken"`.
|
||||
if ctx.plugin_version.ends_with("-broken") {
|
||||
return Err(failed("this build is broken"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deactivate() {}
|
||||
}
|
||||
|
||||
fn run_command(id: String, arg: Option<String>) -> Result<Option<String>, String> {
|
||||
impl commands::Guest for Misbehave {
|
||||
fn run(id: String, ctx: &Invocation, arg: Option<String>) -> Result<Option<String>, types::Error> {
|
||||
match id.as_str() {
|
||||
"echo" => Ok(arg),
|
||||
"panic" => panic!("boom"),
|
||||
@@ -36,18 +55,90 @@ 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_else(|| failed("no project is open"))?;
|
||||
project.read_file(&arg.unwrap_or_default()).map(Some)
|
||||
}
|
||||
"language" => host::register_language(&arg.unwrap_or_default()).map(|_| None),
|
||||
"theme" => host::register_theme(&arg.unwrap_or_default()).map(|_| None),
|
||||
other => Err(format!("unknown command: {other}")),
|
||||
"root" => ctx.project().map(|p| p.root()).transpose(),
|
||||
// `editor.write` is not granted: an error, not a trap.
|
||||
"insert" => {
|
||||
let editor = ctx.editor().ok_or_else(|| failed("no editor"))?;
|
||||
editor.apply_edits(0, &[]).map(|_| None)
|
||||
}
|
||||
// The user gave up: not the plugin failing.
|
||||
"cancel" => Err(types::Error::Cancelled),
|
||||
other => Err(types::Error::NotFound(format!("command {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl event_handler::Guest for Misbehave {
|
||||
fn handle(_events: Vec<openidea::plugin::events::Event>, _ctx: &Invocation) {}
|
||||
}
|
||||
|
||||
/// Edit-path budgets: the text says how to behave. `sleep:<ms>` busy-waits
|
||||
/// that long, `spin` never returns, `panic` traps.
|
||||
fn behave(text: &str) {
|
||||
if let Some(ms) = text.strip_prefix("sleep:").and_then(|n| n.trim().parse::<u64>().ok()) {
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_millis(ms);
|
||||
while std::time::Instant::now() < until {
|
||||
std::hint::black_box(0);
|
||||
}
|
||||
} else if text == "spin" {
|
||||
loop {
|
||||
std::hint::black_box(0);
|
||||
}
|
||||
} else if text == "panic" {
|
||||
panic!("boom");
|
||||
}
|
||||
}
|
||||
|
||||
/// One keyword token over the whole text, after [`behave`].
|
||||
impl syntax_provider::Guest for Misbehave {
|
||||
fn highlights(_language: String, text: String) -> Vec<Token> {
|
||||
behave(&text);
|
||||
vec![Token { start: 0, end: text.len() as u32, kind: "keyword".into() }]
|
||||
}
|
||||
|
||||
fn outline(_language: String, _text: String) -> Vec<Symbol> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn folding(_language: String, _text: String) -> Vec<Fold> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion: one item, labelled with the document's path, after [`behave`].
|
||||
impl providers::Guest for Misbehave {
|
||||
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
|
||||
behave(&doc.text);
|
||||
vec![CompletionItem {
|
||||
label: doc.path.clone(),
|
||||
detail: None,
|
||||
kind: Some("keyword".into()),
|
||||
insert_text: doc.path,
|
||||
replace: None,
|
||||
sort_text: None,
|
||||
documentation: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn inline_completion(_doc: Document, _at: types::Position) -> Option<InlineSuggestion> {
|
||||
None
|
||||
}
|
||||
|
||||
fn code_actions(_doc: Document, _selection: types::Range) -> Vec<CodeAction> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn definition(_doc: Document, _at: types::Position) -> Vec<types::Location> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn line_markers(_doc: Document) -> Vec<LineMarker> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
export!(Misbehave);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
!dist/
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-p0-probe"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "openidea-p0-probe"
|
||||
version = "0.0.1"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# Standalone: not a member of the IDE workspace (different target).
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
# Builds the `probe-full` world (with the optional exports); default is `probe-min`.
|
||||
full = []
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.63"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds dist/min.wasm (probe-min) and dist/full.wasm (probe-full).
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
export CARGO_TARGET_DIR="$here/target"
|
||||
mkdir -p "$here/dist"
|
||||
cargo build --manifest-path "$here/Cargo.toml" --release --target wasm32-wasip2
|
||||
cp "$CARGO_TARGET_DIR/wasm32-wasip2/release/openidea_p0_probe.wasm" "$here/dist/min.wasm"
|
||||
cargo build --manifest-path "$here/Cargo.toml" --release --target wasm32-wasip2 --features full
|
||||
cp "$CARGO_TARGET_DIR/wasm32-wasip2/release/openidea_p0_probe.wasm" "$here/dist/full.wasm"
|
||||
ls -l "$here/dist"
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
//! P0 probe guest. Built twice (see `build.sh`): `probe-min` and, with
|
||||
//! `--features full`, `probe-full` with the optional exports.
|
||||
|
||||
#[cfg(feature = "full")]
|
||||
wit_bindgen::generate!({ world: "probe-full", path: "wit", generate_all });
|
||||
#[cfg(not(feature = "full"))]
|
||||
wit_bindgen::generate!({ world: "probe-min", path: "wit", generate_all });
|
||||
|
||||
use exports::openidea::probe::base;
|
||||
use openidea::probe::host_api;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static CALLS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
struct Probe;
|
||||
|
||||
impl base::Guest for Probe {
|
||||
fn run(cmd: String, arg: String) -> Result<String, String> {
|
||||
let n = CALLS.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
match cmd.as_str() {
|
||||
"echo" => Ok(arg),
|
||||
// Instance state survives between calls (and is lost with the instance).
|
||||
"count" => Ok(n.to_string()),
|
||||
"spin" => {
|
||||
let mut x: u64 = 0;
|
||||
loop {
|
||||
x = std::hint::black_box(x.wrapping_add(1));
|
||||
}
|
||||
}
|
||||
"sleep" => {
|
||||
host_api::sleep_ms(arg.parse().map_err(|_| "ms")?);
|
||||
Ok("slept".into())
|
||||
}
|
||||
// Streams items pushed by the host until it cancels.
|
||||
"task" => {
|
||||
let task = host_api::current_task();
|
||||
let mut got = Vec::new();
|
||||
loop {
|
||||
task.next_ready().block();
|
||||
if task.is_cancelled() {
|
||||
return Ok(format!("cancelled after {}", got.join(",")));
|
||||
}
|
||||
if let Some(item) = task.take() {
|
||||
got.push(item);
|
||||
task.report(got.len() as u32, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
other => Err(format!("unknown: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "full")]
|
||||
impl exports::openidea::probe::code_actions::Guest for Probe {
|
||||
fn actions(path: String) -> Vec<String> {
|
||||
vec![format!("fix:{path}")]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "full")]
|
||||
impl exports::openidea::probe::completion::Guest for Probe {
|
||||
fn complete(prefix: String) -> Vec<String> {
|
||||
vec![format!("{prefix}1"), format!("{prefix}2")]
|
||||
}
|
||||
}
|
||||
|
||||
export!(Probe);
|
||||
@@ -0,0 +1,11 @@
|
||||
// Subset of wasi: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<borrow<pollable>>) -> list<u32>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/// Runtime probe: 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<u32>);
|
||||
/// Set by the host when the user cancels.
|
||||
is-cancelled: func() -> bool;
|
||||
/// Ready when the host pushed the next item, or on cancellation.
|
||||
next-ready: func() -> pollable;
|
||||
/// The pushed item, if any (non-blocking).
|
||||
take: func() -> option<string>;
|
||||
}
|
||||
|
||||
/// The task of the current call.
|
||||
current-task: func() -> task;
|
||||
|
||||
/// An async host import: sleeps without blocking the host thread.
|
||||
sleep-ms: func(ms: u32);
|
||||
}
|
||||
|
||||
interface base {
|
||||
run: func(cmd: string, arg: string) -> result<string, string>;
|
||||
}
|
||||
|
||||
/// Optional exports: a plugin implements only the providers it needs.
|
||||
interface code-actions {
|
||||
actions: func(path: string) -> list<string>;
|
||||
}
|
||||
|
||||
interface completion {
|
||||
complete: func(prefix: string) -> list<string>;
|
||||
}
|
||||
|
||||
world probe-min {
|
||||
import host-api;
|
||||
export base;
|
||||
}
|
||||
|
||||
world probe-full {
|
||||
import host-api;
|
||||
export base;
|
||||
export code-actions;
|
||||
export completion;
|
||||
}
|
||||
|
||||
/// The host binds this world; guests implement `probe-min` or `probe-full`.
|
||||
world probe-host {
|
||||
import host-api;
|
||||
export base;
|
||||
export code-actions;
|
||||
export completion;
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
//! End-to-end tests against real components.
|
||||
//!
|
||||
//! The components are checked in (`plugins/hello/dist/plugin.wasm`,
|
||||
//! `tests/fixtures/misbehave/dist/plugin.wasm`) so tests need no wasm
|
||||
//! toolchain. Rebuild them with `plugins/build.sh hello` and
|
||||
//! `plugins/build.sh crates/ide-plugin/tests/fixtures/misbehave` after
|
||||
//! changing their source or `wit/plugin.wit`.
|
||||
|
||||
use ide_plugin::*;
|
||||
use parking_lot::Mutex;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
type Events = Arc<Mutex<Vec<(Option<String>, PluginEvent)>>>;
|
||||
|
||||
fn repo() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn sample(name: &str) -> PathBuf {
|
||||
repo().join("plugins").join(name)
|
||||
}
|
||||
|
||||
fn theme_template() -> PathBuf {
|
||||
repo().join("templates/theme-plugin")
|
||||
}
|
||||
|
||||
/// A theme package with a light and a dark theme, plus licence files, in `dir`.
|
||||
fn write_theme_pair(dir: &Path) {
|
||||
std::fs::create_dir_all(dir.join("t")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("plugin.toml"),
|
||||
"id = \"theme-pair\"\nname = \"Pair\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\
|
||||
[[contributes.themes]]\npath = \"t/day.json\"\n[[contributes.themes]]\npath = \"t/night.json\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("t/day.json"),
|
||||
r##"{"id":"day","name":"Day","kind":"light","tokens":{"--bg-frame":"#eeeeee"}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("t/night.json"),
|
||||
r##"{"id":"night","name":"Night","kind":"dark","tokens":{"--bg-frame":"#111111"}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("LICENSE"), "MIT License\n").unwrap();
|
||||
std::fs::write(dir.join("NOTICE"), "Palette notice\n").unwrap();
|
||||
}
|
||||
|
||||
fn misbehave_pkg() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/misbehave")
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: PluginHost,
|
||||
events: Events,
|
||||
_tmp: tempfile::TempDir,
|
||||
plugins_dir: PathBuf,
|
||||
project: PathBuf,
|
||||
}
|
||||
|
||||
fn fixture(limits: Limits) -> Fixture {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = tmp.path().join("plugins");
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir_all(project.join("src")).unwrap();
|
||||
std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap();
|
||||
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
|
||||
let project = project.canonicalize().unwrap();
|
||||
let events: Events = Arc::default();
|
||||
let sink = {
|
||||
let events = events.clone();
|
||||
move |client: Option<&str>, e: PluginEvent| events.lock().push((client.map(str::to_owned), e))
|
||||
};
|
||||
let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), limits).unwrap();
|
||||
Fixture { host, events, _tmp: tmp, plugins_dir, project }
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn inv(&self) -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), buffer_text: None }
|
||||
}
|
||||
|
||||
fn run(&self, plugin: &str, cmd: &str, arg: Option<&str>) -> Result<Option<String>, String> {
|
||||
self.host.run_command(plugin, cmd, arg, self.inv()).map_err(|e| e.message)
|
||||
}
|
||||
|
||||
fn status(&self, id: &str) -> PluginStatus {
|
||||
self.host.info_of(id).unwrap().status
|
||||
}
|
||||
|
||||
fn take_events(&self) -> Vec<(Option<String>, PluginEvent)> {
|
||||
std::mem::take(&mut *self.events.lock())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_plugin_commands() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.host.install_from_dir(&sample("hello")).unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Active, "{:?}", info.error);
|
||||
assert!(f.plugins_dir.join("hello/plugin.toml").is_file());
|
||||
assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file());
|
||||
let titles: Vec<_> = info.commands.iter().map(|c| c.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Hello: Insert Date", "Hello: Word Count of Active File"]);
|
||||
f.take_events();
|
||||
|
||||
// Insert Date: returns YYYY-MM-DD and asks the invoking window to insert it.
|
||||
let date = f.run("hello", "insert-date", None).unwrap().unwrap();
|
||||
assert!(date.len() == 10 && date.as_bytes()[4] == b'-' && date.starts_with("20"), "{date}");
|
||||
let events = f.take_events();
|
||||
assert!(events.iter().any(|(client, e)| client.as_deref() == Some("main")
|
||||
&& *e == PluginEvent::InsertText { plugin_id: "hello".into(), text: date.clone() }));
|
||||
|
||||
// Word Count needs an active file.
|
||||
assert!(f.run("hello", "word-count", None).unwrap_err().contains("no file is open"));
|
||||
f.host.set_active_file("main", Some(f.project.join("src/notes.txt").to_string_lossy().into_owned()));
|
||||
let summary = f.run("hello", "word-count", None).unwrap().unwrap();
|
||||
assert!(summary.starts_with("5 words, 2 lines"), "{summary}");
|
||||
assert!(f.take_events().iter().any(|(_, e)| matches!(e,
|
||||
PluginEvent::Notification { title, level: NotificationLevel::Info, .. } if title == "Word count: notes.txt")));
|
||||
|
||||
// Unsaved buffer contents win over disk.
|
||||
let mut inv = f.inv();
|
||||
inv.buffer_text = Some(Arc::new(|_: &Path| Some("just three words".to_owned())));
|
||||
let summary = f.host.run_command("hello", "word-count", None, inv).unwrap().unwrap();
|
||||
assert!(summary.starts_with("3 words"), "{summary}");
|
||||
|
||||
// Guest errors don't disable the plugin.
|
||||
assert!(f.run("hello", "nope", None).unwrap_err().contains("unknown command"));
|
||||
assert_eq!(f.status("hello"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_file_is_confined_to_the_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&misbehave_pkg()).unwrap();
|
||||
assert_eq!(f.run("misbehave", "read", Some("src/notes.txt")).unwrap().unwrap(), "one two three\nfour five\n");
|
||||
let abs = f.project.join("src/notes.txt");
|
||||
assert!(f.run("misbehave", "read", Some(abs.to_str().unwrap())).is_ok());
|
||||
let err = f.run("misbehave", "read", Some("../secret.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
|
||||
// Without a project there is nothing to read.
|
||||
let inv = Invocation { client: None, workspace_root: None, buffer_text: None };
|
||||
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
|
||||
assert!(err.contains("no project is open"), "{err}");
|
||||
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy());
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_permission_traps_and_disables() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&misbehave_pkg()).unwrap();
|
||||
let err = f.run("misbehave", "insert", None).unwrap_err();
|
||||
assert!(err.contains("permission denied") && err.contains("editor-write"), "{err}");
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Failed);
|
||||
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::InsertText { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panic_is_isolated_and_plugin_can_be_restarted() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&sample("hello")).unwrap();
|
||||
f.host.install_from_dir(&misbehave_pkg()).unwrap();
|
||||
f.take_events();
|
||||
|
||||
let err = f.run("misbehave", "panic", None).unwrap_err();
|
||||
assert!(err.contains("crashed"), "{err}");
|
||||
assert!(err.contains("boom"), "stderr panic message is surfaced: {err}");
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Failed);
|
||||
assert!(info.enabled, "failure is per session, not persisted");
|
||||
let events = f.take_events();
|
||||
assert!(events.iter().any(|(client, e)| client.as_deref() == Some("main")
|
||||
&& matches!(e, PluginEvent::Notification { level: NotificationLevel::Error, title, .. } if title.contains("was disabled"))));
|
||||
assert!(events.iter().any(|(_, e)| *e == PluginEvent::CommandsChanged));
|
||||
|
||||
assert!(f.run("misbehave", "echo", Some("x")).unwrap_err().contains("not running"));
|
||||
// Other plugins are unaffected.
|
||||
assert!(f.run("hello", "insert-date", None).is_ok());
|
||||
|
||||
// Toggling restarts it with a fresh instance.
|
||||
f.host.set_enabled("misbehave", true).unwrap();
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
assert_eq!(f.run("misbehave", "echo", Some("back")).unwrap().as_deref(), Some("back"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuel_exhaustion_traps() {
|
||||
let f = fixture(Limits { fuel: 50_000_000, timeout: Duration::from_secs(30), ..Limits::default() });
|
||||
f.host.install_from_dir(&misbehave_pkg()).unwrap();
|
||||
let err = f.run("misbehave", "spin", None).unwrap_err();
|
||||
assert!(err.contains("CPU budget"), "{err}");
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_timeout_traps() {
|
||||
let f = fixture(Limits { fuel: u64::MAX, timeout: Duration::from_millis(200), ..Limits::default() });
|
||||
f.host.install_from_dir(&misbehave_pkg()).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let err = f.run("misbehave", "spin", None).unwrap_err();
|
||||
assert!(err.contains("timed out"), "{err}");
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_limit_traps() {
|
||||
let f = fixture(Limits { memory_bytes: 32 << 20, ..Limits::default() });
|
||||
f.host.install_from_dir(&misbehave_pkg()).unwrap();
|
||||
let err = f.run("misbehave", "alloc", None).unwrap_err();
|
||||
assert!(err.contains("memory"), "{err}");
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_language_and_theme_registration() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&misbehave_pkg()).unwrap();
|
||||
let lang = "id = \"zz\"\nname = \"ZZ\"\nextensions = [\"zz\"]";
|
||||
f.run("misbehave", "language", Some(lang)).unwrap();
|
||||
assert!(f.run("misbehave", "language", Some("not toml [")).unwrap_err().contains("invalid language"));
|
||||
assert_eq!(f.host.languages().iter().map(|l| l.id.to_string()).collect::<Vec<_>>(), ["zz"]);
|
||||
|
||||
let theme = r##"{"id":"t1","name":"T1","kind":"light","tokens":{"--bg-app":"#fff"}}"##;
|
||||
f.run("misbehave", "theme", Some(theme)).unwrap();
|
||||
assert!(
|
||||
f.run("misbehave", "theme", Some(r#"{"id":"t2","name":"x","kind":"dark","tokens":{"--a":"url(x)"}}"#)).is_err()
|
||||
);
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!(info.languages[0].extensions, ["zz"]);
|
||||
assert_eq!(info.themes[0].kind, ThemeKind::Light);
|
||||
let active = f.host.set_theme(Some(("misbehave", "t1"))).unwrap().unwrap();
|
||||
assert_eq!(active.theme.tokens["--bg-app"], "#fff");
|
||||
assert!(f.host.set_theme(Some(("misbehave", "nope"))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contribution_only_plugins() {
|
||||
let f = fixture(Limits::default());
|
||||
let theme = f.host.install_from_dir(&theme_template()).unwrap();
|
||||
assert_eq!(theme.status, PluginStatus::Static);
|
||||
assert!(!theme.has_code);
|
||||
let ids: Vec<&str> = theme.themes.iter().map(|t| t.id.as_str()).collect();
|
||||
assert_eq!(ids, ["harbor-night"]);
|
||||
let lang = f.host.install_from_dir(&sample("language-toy")).unwrap();
|
||||
assert_eq!(lang.status, PluginStatus::Static);
|
||||
assert_eq!(lang.languages[0].id, "toy");
|
||||
assert_eq!(f.host.languages()[0].block_comment, Some(("(*".into(), "*)".into())));
|
||||
assert!(f.run("language-toy", "x", None).unwrap_err().contains("no code"));
|
||||
|
||||
f.take_events();
|
||||
let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap();
|
||||
assert_eq!(active.theme.tokens["--bg-app"], "#14171c");
|
||||
assert_eq!(active.theme.tokens["--syn-string"], "#9ccc7a");
|
||||
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ThemeChanged { theme: Some(_) })));
|
||||
assert_eq!(f.host.list(None).active_theme, Some(active));
|
||||
|
||||
// Disabling the plugin withdraws its theme and language.
|
||||
f.host.set_enabled("theme-harbor-night", false).unwrap();
|
||||
assert!(f.host.active_theme().is_none());
|
||||
assert!(f.take_events().iter().any(|(_, e)| *e == PluginEvent::ThemeChanged { theme: None }));
|
||||
f.host.set_enabled("language-toy", false).unwrap();
|
||||
assert!(f.host.languages().is_empty());
|
||||
assert_eq!(f.status("language-toy"), PluginStatus::Disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_template_installs_with_its_editor_scheme() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.host.install_from_dir(&theme_template()).unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Static);
|
||||
assert!(!info.has_code);
|
||||
assert_eq!(info.themes[0].id, "harbor-night");
|
||||
assert_eq!(info.themes[0].kind, ThemeKind::Dark);
|
||||
// The scheme file is copied with the package and merged over the UI tokens.
|
||||
assert!(f.plugins_dir.join("theme-harbor-night/themes/harbor-night.scheme.json").is_file());
|
||||
let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap();
|
||||
let tokens = &active.theme.tokens;
|
||||
assert!(
|
||||
tokens.contains_key("--bg-app") && tokens.contains_key("--syn-keyword") && tokens.contains_key("--ansi-red")
|
||||
);
|
||||
assert!(tokens.len() > 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_packages_install_with_their_licence() {
|
||||
let f = fixture(Limits::default());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_theme_pair(dir.path());
|
||||
let info = f.host.install_from_dir(dir.path()).unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
|
||||
let installed = f.plugins_dir.join("theme-pair");
|
||||
assert!(std::fs::read_to_string(installed.join("LICENSE")).unwrap().contains("MIT License"));
|
||||
assert!(installed.join("NOTICE").is_file());
|
||||
for (theme, kind) in [("day", ThemeKind::Light), ("night", ThemeKind::Dark)] {
|
||||
let active = f.host.set_theme(Some(("theme-pair", theme))).unwrap().unwrap();
|
||||
assert_eq!(active.theme.kind, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_frame_follows_the_selection() {
|
||||
let f = fixture(Limits::default());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_theme_pair(dir.path());
|
||||
f.host.install_from_dir(dir.path()).unwrap();
|
||||
// Nothing to attach it to without a selected theme.
|
||||
f.host.set_theme_frame(Some("#111111")).unwrap();
|
||||
assert_eq!(f.host.startup_frame(), None);
|
||||
f.host.set_theme(Some(("theme-pair", "night"))).unwrap();
|
||||
assert!(f.host.set_theme_frame(Some("hsl(220, 13%, 15%)")).is_err());
|
||||
f.host.set_theme_frame(Some("#111111")).unwrap();
|
||||
assert_eq!(f.host.startup_frame().as_deref(), Some("#111111"));
|
||||
f.host.set_enabled("theme-pair", false).unwrap();
|
||||
assert_eq!(f.host.startup_frame(), None);
|
||||
f.host.set_enabled("theme-pair", true).unwrap();
|
||||
assert_eq!(f.host.startup_frame().as_deref(), Some("#111111"));
|
||||
// A new selection drops the old theme's colour until the UI reports the new one.
|
||||
f.host.set_theme(Some(("theme-pair", "day"))).unwrap();
|
||||
assert_eq!(f.host.startup_frame(), None);
|
||||
f.host.set_theme_frame(Some("#eeeeee")).unwrap();
|
||||
f.host.set_theme(None).unwrap();
|
||||
assert_eq!(f.host.startup_frame(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_with_broken_editor_scheme_fails_to_load() {
|
||||
let f = fixture(Limits::default());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("t")).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("plugin.toml"),
|
||||
"id = \"bad-scheme\"\nname = \"B\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\
|
||||
[[contributes.themes]]\npath = \"t/a.json\"\neditor-scheme = \"t/a.scheme.json\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("t/a.json"), r##"{"id":"a","name":"A","kind":"light","tokens":{}}"##).unwrap();
|
||||
std::fs::write(dir.path().join("t/a.scheme.json"), "{\n \"tokens\": { \"--caret\": \"url(x)\" }\n}").unwrap();
|
||||
let info = f.host.install_from_dir(dir.path()).unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Failed);
|
||||
assert!(info.error.unwrap_or_default().contains("a.scheme.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_persists_across_restarts() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&sample("hello")).unwrap();
|
||||
f.host.install_from_dir(&theme_template()).unwrap();
|
||||
f.host.set_enabled("hello", false).unwrap();
|
||||
f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap();
|
||||
f.host.set_theme_frame(Some("#1f252e")).unwrap();
|
||||
|
||||
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
// Known before the plugins are loaded: new windows open in the theme's frame colour.
|
||||
assert_eq!(again.startup_frame().as_deref(), Some("#1f252e"));
|
||||
assert!(!again.wait_initial_load(Duration::from_millis(1)));
|
||||
again.load_all();
|
||||
assert!(again.wait_initial_load(Duration::ZERO));
|
||||
let list = again.list(None);
|
||||
let hello = list.plugins.iter().find(|p| p.id == "hello").unwrap();
|
||||
assert!(!hello.enabled);
|
||||
assert_eq!(hello.status, PluginStatus::Disabled);
|
||||
assert_eq!(list.active_theme.unwrap().theme.id, "harbor-night");
|
||||
assert!(
|
||||
again
|
||||
.run_command("hello", "insert-date", None, Invocation::default())
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("disabled")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_plugins_follow_the_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.set_project_policy(Arc::new(|_| true));
|
||||
let dev = f.project.join(DEV_PLUGINS_DIR).join("toy");
|
||||
copy_dir(&sample("language-toy"), &dev);
|
||||
f.host.sync_project(&f.project);
|
||||
let mine = f.host.list(Some(&f.project));
|
||||
assert_eq!(mine.plugins.len(), 1);
|
||||
assert_eq!(mine.plugins[0].source, PluginSource::Dev);
|
||||
assert!(f.host.list(None).plugins.is_empty(), "dev plugins only show for their project");
|
||||
assert!(f.host.uninstall("language-toy").is_err());
|
||||
assert!(f.host.install_from_dir(&sample("language-toy")).is_err(), "id clash with a dev plugin");
|
||||
|
||||
// Edit + reload picks up changes.
|
||||
let manifest = dev.join("plugin.toml");
|
||||
let src = std::fs::read_to_string(&manifest).unwrap().replace("Toy Language", "Toy Language (dev)");
|
||||
std::fs::write(&manifest, src).unwrap();
|
||||
assert_eq!(f.host.reload("language-toy").unwrap().name, "Toy Language (dev)");
|
||||
|
||||
std::fs::remove_dir_all(&dev).unwrap();
|
||||
f.host.sync_project(&f.project);
|
||||
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_validation_and_uninstall() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
// Missing component file.
|
||||
let pkg = tmp.path().join("broken");
|
||||
std::fs::create_dir_all(&pkg).unwrap();
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
"id = \"b\"\nname = \"B\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\nwasm = \"x.wasm\"",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(f.host.install_from_dir(&pkg).unwrap_err().message.contains("missing"));
|
||||
// Not a component.
|
||||
std::fs::write(pkg.join("x.wasm"), b"\0asm garbage").unwrap();
|
||||
assert!(f.host.install_from_dir(&pkg).unwrap_err().message.contains("not a valid plugin component"));
|
||||
// Bad manifest.
|
||||
std::fs::write(pkg.join("plugin.toml"), "id = \"B!\"").unwrap();
|
||||
assert!(f.host.install_from_dir(&pkg).is_err());
|
||||
assert!(f.host.list(None).plugins.is_empty(), "failed installs leave nothing behind");
|
||||
|
||||
// A broken package already in the plugins dir shows up as failed.
|
||||
std::fs::create_dir_all(f.plugins_dir.join("junk")).unwrap();
|
||||
std::fs::write(f.plugins_dir.join("junk/plugin.toml"), "garbage = ").unwrap();
|
||||
f.host.load_all();
|
||||
let junk = &f.host.list(None).plugins[0];
|
||||
assert_eq!(junk.status, PluginStatus::Failed);
|
||||
assert!(junk.error.is_some());
|
||||
|
||||
// Reinstall replaces; uninstall removes the directory.
|
||||
f.host.install_from_dir(&sample("hello")).unwrap();
|
||||
f.host.install_from_dir(&sample("hello")).unwrap();
|
||||
assert_eq!(f.status("hello"), PluginStatus::Active);
|
||||
f.host.uninstall("hello").unwrap();
|
||||
assert!(!f.plugins_dir.join("hello").exists());
|
||||
assert!(f.host.info_of("hello").is_err());
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_lists_sample_packages() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let broken = tmp.path().join("broken");
|
||||
std::fs::create_dir_all(&broken).unwrap();
|
||||
std::fs::write(
|
||||
broken.join("plugin.toml"),
|
||||
"id = \"x\"\nname = \"X\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("junk")).unwrap();
|
||||
std::fs::write(tmp.path().join("junk/plugin.toml"), "garbage = ").unwrap();
|
||||
|
||||
let catalog = scan_catalog(&[repo().join("plugins"), repo().join("templates"), tmp.path().to_path_buf()]);
|
||||
let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect();
|
||||
for id in ["hello", "theme-harbor-night", "language-toy", "x"] {
|
||||
assert!(ids.contains(&id), "{id} missing from {ids:?}");
|
||||
}
|
||||
let hello = catalog.iter().find(|c| c.id == "hello").unwrap();
|
||||
assert!(hello.has_code && hello.problem.is_none() && hello.change_notes.is_some());
|
||||
assert_eq!(hello.commands.len(), 2);
|
||||
let theme = catalog.iter().find(|c| c.id == "theme-harbor-night").unwrap();
|
||||
assert_eq!((theme.theme_count, theme.has_code), (1, false));
|
||||
let x = catalog.iter().find(|c| c.id == "x").unwrap();
|
||||
assert!(x.problem.as_deref().unwrap().contains("plugin.wasm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_dev_plugin_has_no_activation_registration() {
|
||||
let f = fixture(Limits::default());
|
||||
let dev = f.project.join(DEV_PLUGINS_DIR).join("hello");
|
||||
copy_dir(&sample("hello"), &dev);
|
||||
let manifest = dev.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest).unwrap();
|
||||
std::fs::write(&manifest, text.split("[[contributes.commands]]").next().unwrap()).unwrap();
|
||||
assert!(PluginManifest::load(&dev).unwrap().contributes.commands.is_empty());
|
||||
let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let policy = trusted.clone();
|
||||
f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire)));
|
||||
f.host.sync_project(&f.project);
|
||||
let untrusted = f.host.list(Some(&f.project));
|
||||
assert!(
|
||||
!untrusted.plugins.iter().flat_map(|plugin| &plugin.commands).any(|command| command.id == "word-count"),
|
||||
"untrusted development plugin registered its activation witness"
|
||||
);
|
||||
assert!(untrusted.plugins.is_empty());
|
||||
assert!(f.host.info_of("hello").is_err());
|
||||
|
||||
// Positive control: activation itself registers these commands, not the manifest.
|
||||
trusted.store(true, std::sync::atomic::Ordering::Release);
|
||||
f.host.sync_project(&f.project);
|
||||
let info = f.host.info_of("hello").unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Active);
|
||||
assert!(info.commands.iter().any(|c| c.id == "word-count"));
|
||||
trusted.store(false, std::sync::atomic::Ordering::Release);
|
||||
assert!(f.host.reload("hello").is_err());
|
||||
assert!(f.host.set_enabled("hello", true).is_err());
|
||||
assert!(f.run("hello", "word-count", None).is_err());
|
||||
f.host.sync_project(&f.project);
|
||||
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
|
||||
}
|
||||
|
||||
/// Zips the files of `dir` under `prefix` (recursively).
|
||||
fn add_dir(zip: &mut zip::ZipWriter<std::fs::File>, dir: &Path, prefix: &str) {
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let name = format!("{prefix}{}", entry.file_name().to_string_lossy());
|
||||
if entry.path().is_dir() {
|
||||
add_dir(zip, &entry.path(), &format!("{name}/"));
|
||||
} else {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(&std::fs::read(entry.path()).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn zip_of(dir: &Path, prefix: &str, out: &Path) -> PathBuf {
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap());
|
||||
add_dir(&mut zip, dir, prefix);
|
||||
zip.finish().unwrap();
|
||||
out.to_path_buf()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_zips_with_the_package_at_the_root_or_in_one_folder() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for (name, prefix) in [("root.ZIP", ""), ("folder.zip", "hello-1.0/")] {
|
||||
let zip = zip_of(&sample("hello"), prefix, &tmp.path().join(name));
|
||||
let info = f.host.install_from_path(&zip).unwrap();
|
||||
assert_eq!((info.id.as_str(), info.status), ("hello", PluginStatus::Active), "{:?}", info.error);
|
||||
assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file());
|
||||
}
|
||||
// Still runs from the installed copy, not the unpacked archive.
|
||||
assert!(f.run("hello", "insert-date", None).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_from_a_manifest_or_a_folder() {
|
||||
let f = fixture(Limits::default());
|
||||
assert_eq!(f.host.install_from_path(&sample("hello").join("plugin.toml")).unwrap().id, "hello");
|
||||
assert_eq!(f.host.install_from_path(&sample("hello")).unwrap().id, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_archiver_metadata_next_to_the_package_folder() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = tmp.path().join("mac.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
|
||||
add_dir(&mut zip, &sample("hello"), "hello/");
|
||||
zip.start_file("__MACOSX/hello/._plugin.toml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(b"metadata").unwrap();
|
||||
zip.finish().unwrap();
|
||||
assert_eq!(f.host.install_from_path(&out).unwrap().id, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_zips_that_are_not_safe_plugin_packages() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let options = zip::write::SimpleFileOptions::default;
|
||||
|
||||
let no_manifest = tmp.path().join("idea-plugin.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&no_manifest).unwrap());
|
||||
zip.start_file("idea-plugin/lib/plugin.jar", options()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let error = f.host.install_from_path(&no_manifest).unwrap_err();
|
||||
assert!(error.message.contains("no plugin.toml"), "{}", error.message);
|
||||
|
||||
let escape = tmp.path().join("escape.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&escape).unwrap());
|
||||
zip.start_file("../../evil.txt", options()).unwrap();
|
||||
zip.write_all(b"evil").unwrap();
|
||||
zip.finish().unwrap();
|
||||
let error = f.host.install_from_path(&escape).unwrap_err();
|
||||
assert!(error.message.contains("unsafe path"), "{}", error.message);
|
||||
|
||||
let link = tmp.path().join("link.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&link).unwrap());
|
||||
zip.add_symlink("plugin.toml", "/etc/passwd", options()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let error = f.host.install_from_path(&link).unwrap_err();
|
||||
assert!(error.message.contains("symbolic link"), "{}", error.message);
|
||||
|
||||
let garbage = tmp.path().join("garbage.zip");
|
||||
std::fs::write(&garbage, "not a zip").unwrap();
|
||||
let error = f.host.install_from_path(&garbage).unwrap_err();
|
||||
assert!(error.message.contains("not a valid plugin archive"), "{}", error.message);
|
||||
|
||||
assert!(!f.plugins_dir.join("hello").exists());
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Runtime experiments, kept as regression tests: they pin the
|
||||
//! wasmtime behaviour the plugin runtime is designed around.
|
||||
//!
|
||||
//! Components: `tests/fixtures/p0-probe/dist/{min,full}.wasm`, rebuilt with
|
||||
//! `tests/fixtures/p0-probe/build.sh`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use wasmtime::component::{Component, HasSelf, Linker, Resource, ResourceTable};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
use wasmtime_wasi::p2::{DynPollable, Pollable, subscribe};
|
||||
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
mod bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "probe-host",
|
||||
path: "tests/fixtures/p0-probe/wit",
|
||||
imports: {
|
||||
"openidea:probe/host-api.sleep-ms": async | trappable,
|
||||
default: trappable,
|
||||
},
|
||||
exports: { default: async },
|
||||
with: {
|
||||
"wasi:io/poll": wasmtime_wasi::p2::bindings::io::poll,
|
||||
"openidea:probe/host-api.task": super::Task,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
use bindings::openidea::probe::host_api;
|
||||
|
||||
/// Host side of the `task` resource: a queue the host pushes into.
|
||||
pub struct Task {
|
||||
queue: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
notify: Arc<tokio::sync::Notify>,
|
||||
progress: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// The pollable of `next-ready`: ready once an item is queued or the task is cancelled.
|
||||
struct NextReady {
|
||||
queue: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
notify: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
#[wasmtime_wasi::async_trait]
|
||||
impl Pollable for NextReady {
|
||||
async fn ready(&mut self) {
|
||||
loop {
|
||||
let notified = self.notify.notified();
|
||||
if self.cancelled.load(Ordering::Acquire) || !self.queue.lock().is_empty() {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct TaskHandle {
|
||||
queue: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
notify: Arc<tokio::sync::Notify>,
|
||||
progress: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl TaskHandle {
|
||||
fn push(&self, item: &str) {
|
||||
self.queue.lock().push(item.to_owned());
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
struct Data {
|
||||
wasi: WasiCtx,
|
||||
table: ResourceTable,
|
||||
task: TaskHandle,
|
||||
}
|
||||
|
||||
impl WasiView for Data {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView { ctx: &mut self.wasi, table: &mut self.table }
|
||||
}
|
||||
}
|
||||
|
||||
impl host_api::HostTask for Data {
|
||||
fn report(&mut self, task: Resource<Task>, done: u32, _total: Option<u32>) -> wasmtime::Result<()> {
|
||||
self.table.get(&task)?.progress.store(u64::from(done), Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
fn is_cancelled(&mut self, task: Resource<Task>) -> wasmtime::Result<bool> {
|
||||
Ok(self.table.get(&task)?.cancelled.load(Ordering::Acquire))
|
||||
}
|
||||
fn next_ready(&mut self, task: Resource<Task>) -> wasmtime::Result<Resource<DynPollable>> {
|
||||
let t = self.table.get(&task)?;
|
||||
let ready = NextReady { queue: t.queue.clone(), cancelled: t.cancelled.clone(), notify: t.notify.clone() };
|
||||
let r = self.table.push(ready)?;
|
||||
subscribe(&mut self.table, r)
|
||||
}
|
||||
fn take(&mut self, task: Resource<Task>) -> wasmtime::Result<Option<String>> {
|
||||
let mut q = self.table.get(&task)?.queue.lock();
|
||||
Ok(if q.is_empty() { None } else { Some(q.remove(0)) })
|
||||
}
|
||||
fn drop(&mut self, task: Resource<Task>) -> wasmtime::Result<()> {
|
||||
self.table.delete(task)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl host_api::Host for Data {
|
||||
fn current_task(&mut self) -> wasmtime::Result<Resource<Task>> {
|
||||
let h = self.task.clone();
|
||||
Ok(self.table.push(Task { queue: h.queue, cancelled: h.cancelled, notify: h.notify, progress: h.progress })?)
|
||||
}
|
||||
|
||||
async fn sleep_ms(&mut self, ms: u32) -> wasmtime::Result<()> {
|
||||
tokio::time::sleep(Duration::from_millis(u64::from(ms))).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn dist(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p0-probe/dist").join(name)
|
||||
}
|
||||
|
||||
struct Env {
|
||||
engine: Engine,
|
||||
linker: Linker<Data>,
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for Env {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn env() -> Env {
|
||||
let mut config = Config::new();
|
||||
config.wasm_component_model(true).epoch_interruption(true).consume_fuel(true);
|
||||
let engine = Engine::new(&config).unwrap();
|
||||
let mut linker = Linker::new(&engine);
|
||||
wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap();
|
||||
host_api::add_to_linker::<_, HasSelf<_>>(&mut linker, |d| d).unwrap();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (weak, flag) = (engine.weak(), stop.clone());
|
||||
std::thread::spawn(move || {
|
||||
while !flag.load(Ordering::Acquire)
|
||||
&& let Some(e) = weak.upgrade()
|
||||
{
|
||||
e.increment_epoch();
|
||||
drop(e);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
});
|
||||
Env { engine, linker, stop }
|
||||
}
|
||||
|
||||
fn store(env: &Env, task: TaskHandle) -> Store<Data> {
|
||||
let data = Data { wasi: WasiCtx::builder().build(), table: ResourceTable::new(), task };
|
||||
let mut store = Store::new(&env.engine, data);
|
||||
store.set_fuel(u64::MAX).unwrap();
|
||||
// Yield to the executor every tick instead of trapping: long calls share the thread.
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
store
|
||||
}
|
||||
|
||||
const BASE: &str = "openidea:probe/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();
|
||||
// How often depends on how busy the machine is; that it ran at all shows the guest yielded.
|
||||
assert!(ticks.load(Ordering::Relaxed) > 0, "the executor kept running other tasks while the guest spun");
|
||||
}
|
||||
|
||||
/// P0-2b: dropping the call future cancels the call — and poisons the instance:
|
||||
/// the next call fails with `CannotEnterComponent`. A fresh instance from the
|
||||
/// same `InstancePre` is cheap (no compilation) and starts with fresh state.
|
||||
#[tokio::test]
|
||||
async fn cancelled_instance_cannot_be_reused_but_reinstantiates_fast() {
|
||||
let env = env();
|
||||
let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap();
|
||||
let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap();
|
||||
let mut s = store(&env, TaskHandle::default());
|
||||
let inst = pre.instantiate_async(&mut s).await.unwrap();
|
||||
let base = idx.load(&mut s, &inst).unwrap();
|
||||
assert_eq!(base.call_run(&mut s, "count", "").await.unwrap().unwrap(), "1");
|
||||
|
||||
// Cancel while the guest computes.
|
||||
let _ = tokio::time::timeout(Duration::from_millis(50), base.call_run(&mut s, "spin", "")).await;
|
||||
let err = base.call_run(&mut s, "echo", "x").await.unwrap_err();
|
||||
assert_eq!(err.downcast_ref::<wasmtime::Trap>(), Some(&wasmtime::Trap::CannotEnterComponent), "{err:?}");
|
||||
|
||||
// Cancel while the guest waits in an async host import: same result.
|
||||
let mut s = store(&env, TaskHandle::default());
|
||||
let inst = pre.instantiate_async(&mut s).await.unwrap();
|
||||
let base = idx.load(&mut s, &inst).unwrap();
|
||||
let _ = tokio::time::timeout(Duration::from_millis(20), base.call_run(&mut s, "sleep", "5000")).await;
|
||||
assert!(base.call_run(&mut s, "echo", "x").await.is_err());
|
||||
|
||||
// Re-instantiation: a new store + instance from the pre-instantiated component.
|
||||
let started = Instant::now();
|
||||
let mut runs = 0;
|
||||
while runs < 20 {
|
||||
let mut s = store(&env, TaskHandle::default());
|
||||
let inst = pre.instantiate_async(&mut s).await.unwrap();
|
||||
let base = idx.load(&mut s, &inst).unwrap();
|
||||
assert_eq!(base.call_run(&mut s, "count", "").await.unwrap().unwrap(), "1", "state starts fresh");
|
||||
runs += 1;
|
||||
}
|
||||
let per = started.elapsed() / runs;
|
||||
eprintln!("re-instantiate + first call: {per:?} per instance");
|
||||
assert!(per < Duration::from_millis(30), "{per:?}");
|
||||
}
|
||||
|
||||
/// P0-2c: a guest that returns an error (not a trap) leaves the instance usable,
|
||||
/// and an async host import does not block the thread.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn async_host_import_does_not_block() {
|
||||
let env = env();
|
||||
let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap();
|
||||
let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap();
|
||||
let mut s = store(&env, TaskHandle::default());
|
||||
let inst = pre.instantiate_async(&mut s).await.unwrap();
|
||||
let base = idx.load(&mut s, &inst).unwrap();
|
||||
assert!(base.call_run(&mut s, "nope", "").await.unwrap().is_err());
|
||||
let other = tokio::spawn(async { tokio::time::sleep(Duration::from_millis(10)).await });
|
||||
let started = Instant::now();
|
||||
assert_eq!(base.call_run(&mut s, "sleep", "100").await.unwrap().unwrap(), "slept");
|
||||
assert!(other.is_finished(), "the other task ran during the host sleep");
|
||||
assert!(started.elapsed() >= Duration::from_millis(100));
|
||||
assert_eq!(base.call_run(&mut s, "echo", "still").await.unwrap().unwrap(), "still");
|
||||
}
|
||||
|
||||
/// P0-3: streaming + cancellation on WASI p2 alone: the guest blocks on a host
|
||||
/// `pollable` (which yields to the executor), consumes pushed items, reports
|
||||
/// progress, and returns cleanly when the host flags cancellation — the instance
|
||||
/// stays usable (cooperative cancellation, unlike dropping the future).
|
||||
#[tokio::test]
|
||||
async fn pollable_task_streams_and_cancels_cooperatively() {
|
||||
let env = env();
|
||||
let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap();
|
||||
let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap();
|
||||
let task = TaskHandle::default();
|
||||
let mut s = store(&env, task.clone());
|
||||
let inst = pre.instantiate_async(&mut s).await.unwrap();
|
||||
let base = idx.load(&mut s, &inst).unwrap();
|
||||
|
||||
let driver = {
|
||||
let task = task.clone();
|
||||
tokio::spawn(async move {
|
||||
for item in ["a", "b", "c"] {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
task.push(item);
|
||||
}
|
||||
// Wait until the guest consumed everything, then cancel.
|
||||
while task.progress.load(Ordering::Acquire) < 3 {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
task.cancel();
|
||||
})
|
||||
};
|
||||
let out = tokio::time::timeout(Duration::from_secs(5), base.call_run(&mut s, "task", "")).await.unwrap();
|
||||
driver.await.unwrap();
|
||||
assert_eq!(out.unwrap().unwrap(), "cancelled after a,b,c");
|
||||
assert_eq!(task.progress.load(Ordering::Acquire), 3);
|
||||
assert_eq!(base.call_run(&mut s, "echo", "reusable").await.unwrap().unwrap(), "reusable");
|
||||
}
|
||||
|
||||
/// Serialized compilation output loads without recompiling (P4 cache).
|
||||
#[test]
|
||||
fn serialized_component_round_trips() {
|
||||
let env = env();
|
||||
let bytes = std::fs::read(dist("full.wasm")).unwrap();
|
||||
let t = Instant::now();
|
||||
let compiled = Component::new(&env.engine, &bytes).unwrap();
|
||||
let compile = t.elapsed();
|
||||
let blob = compiled.serialize().unwrap();
|
||||
let t = Instant::now();
|
||||
// SAFETY: the blob was produced by this engine just above.
|
||||
let loaded = unsafe { Component::deserialize(&env.engine, &blob) }.unwrap();
|
||||
let load = t.elapsed();
|
||||
eprintln!("compile {compile:?}, deserialize {load:?}");
|
||||
assert!(loaded.get_export_index(None, CODE_ACTIONS).is_some());
|
||||
assert!(load < compile);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ pub(crate) fn detect_with_profiles(
|
||||
detect_make(root, &mut out);
|
||||
detect_just(root, &mut out);
|
||||
detect_godot(root, &mut out);
|
||||
crate::external::detect(root, &mut out);
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
out.retain(|c| seen.insert(c.id.clone()));
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Run configurations detected outside this crate: plugins' `runDetectors`.
|
||||
//! Their ids start with [`EXTERNAL_PREFIX`]; before
|
||||
//! one is started the provider names the program to run (the file approved
|
||||
//! for the plugin's `process:` capability) or refuses.
|
||||
|
||||
use crate::RunConfig;
|
||||
use parking_lot::RwLock;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Id prefix of externally detected configurations (`plugin:<plugin>:<id>`).
|
||||
pub const EXTERNAL_PREFIX: &str = "plugin:";
|
||||
|
||||
pub trait ExternalDetection: Send + Sync {
|
||||
/// Configurations for the project at `root`; ids start with [`EXTERNAL_PREFIX`].
|
||||
fn detect(&self, root: &Path) -> Vec<RunConfig>;
|
||||
/// The program to start for `config` (one this provider detected), or why
|
||||
/// it may not start.
|
||||
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String>;
|
||||
}
|
||||
|
||||
static PROVIDER: RwLock<Option<Arc<dyn ExternalDetection>>> = RwLock::new(None);
|
||||
|
||||
/// Installs (or removes) the provider [`crate::detect`] and runs consult.
|
||||
pub fn set_external_detection(provider: Option<Arc<dyn ExternalDetection>>) {
|
||||
*PROVIDER.write() = provider;
|
||||
}
|
||||
|
||||
pub(crate) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
let provider = PROVIDER.read().clone();
|
||||
if let Some(p) = provider {
|
||||
out.extend(p.detect(root).into_iter().filter(|c| c.id.starts_with(EXTERNAL_PREFIX)));
|
||||
}
|
||||
}
|
||||
|
||||
/// `config` with the program its provider approved, for an externally
|
||||
/// detected configuration; `None` for every other one.
|
||||
pub(crate) fn approved(config: &RunConfig) -> Option<Result<RunConfig, String>> {
|
||||
if !config.detected || !config.id.starts_with(EXTERNAL_PREFIX) {
|
||||
return None;
|
||||
}
|
||||
let provider = PROVIDER.read().clone();
|
||||
Some(match provider {
|
||||
Some(p) => p
|
||||
.launch(config)
|
||||
.map(|program| RunConfig { command: program.to_string_lossy().into_owned(), ..config.clone() }),
|
||||
None => Err("the plugin that detected this configuration is not loaded".into()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ProcessManager, StartOptions};
|
||||
|
||||
/// Answers only for projects with its own marker (tests share the provider).
|
||||
struct Fake;
|
||||
|
||||
const MARKER: &str = "fake-external-detection.marker";
|
||||
|
||||
impl ExternalDetection for Fake {
|
||||
fn detect(&self, root: &Path) -> Vec<RunConfig> {
|
||||
if !root.join(MARKER).exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut c: RunConfig = serde_json::from_value(serde_json::json!({
|
||||
"id": "plugin:acme:x", "name": "x", "kind": "plugin", "command": "x", "args": ["a"],
|
||||
"cwd": null, "env": {}, "detected": true, "isTest": false, "debugAdapter": null
|
||||
}))
|
||||
.unwrap();
|
||||
c.detected = true;
|
||||
let other = RunConfig { id: "not-prefixed".into(), ..c.clone() };
|
||||
vec![c, other]
|
||||
}
|
||||
fn launch(&self, _: &RunConfig) -> Result<PathBuf, String> {
|
||||
Err("not allowed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_configs_join_detection_and_launch_only_when_approved() {
|
||||
set_external_detection(Some(Arc::new(Fake)));
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join(MARKER), "").unwrap();
|
||||
let configs = crate::detect(tmp.path());
|
||||
assert_eq!(configs.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["plugin:acme:x"]);
|
||||
let err = ProcessManager::new()
|
||||
.start(tmp.path(), &configs[0], &StartOptions::default(), Arc::new(ide_core::NullSink), Box::new(|_| {}))
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied);
|
||||
assert!(err.message.contains("not allowed"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user