From fd1f465aeb25ce8083a79314de34b2d59b0af964 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:46:46 +0800 Subject: [PATCH] feat(plugins): extension points, capability consent, async runtime and plugin API 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the plugin system per docs/plugin_plan.md (P0–P8): - Extension-point registry (ide-extension) so new contribution types register without touching the kernel; languages, run detectors, providers, views and UI contributions go through it. - Per-plugin capability consent with a risk-graded ledger; process capabilities are pinned to resolved binary paths and hashes. - Async, actor-per-plugin Wasm runtime with lazy activation, cancellation, events, long-running tasks and idle reclamation. - Plugin host moved into ide-host, shared by local and remote projects. - Distribution: install/package tooling (openidea-plugin bin) and a static market index. - WIT frozen as openidea:plugin@1.0.0; Rust SDK with mock host. - Ten official validation plugins under plugins/official. --- Cargo.lock | 37 +- Cargo.toml | 5 + crates/ide-core/src/settings.rs | 4 + crates/ide-dap/src/adapter.rs | 26 +- crates/ide-dap/src/tests.rs | 56 + crates/ide-extension/Cargo.toml | 24 + crates/ide-extension/src/capability.rs | 261 ++ crates/ide-extension/src/index.rs | 104 + crates/ide-extension/src/ledger.rs | 483 ++++ crates/ide-extension/src/lib.rs | 37 + crates/ide-extension/src/manifest.rs | 501 ++++ crates/ide-extension/src/package.rs | 378 +++ crates/ide-extension/src/point.rs | 175 ++ crates/ide-extension/src/registry.rs | 384 +++ crates/ide-extension/src/ui.rs | 247 ++ crates/ide-extension/src/when.rs | 136 + crates/ide-host/Cargo.toml | 13 + crates/ide-host/benches/plugins.rs | 147 ++ .../ide-host/src/bin/openidea-plugin/main.rs | 633 +++++ .../src/bin/openidea-plugin/templates.rs | 278 ++ crates/ide-host/src/bin/server.rs | 31 +- crates/ide-host/src/lib.rs | 14 +- crates/ide-host/src/ops/lsp.rs | 176 +- crates/ide-host/src/plugins/api.rs | 790 ++++++ crates/ide-host/src/plugins/icons.rs | 610 +++++ crates/ide-host/src/plugins/install.rs | 288 +++ crates/ide-host/src/plugins/market.rs | 293 +++ crates/ide-host/src/plugins/mod.rs | 2266 +++++++++++++++++ crates/ide-host/src/plugins/points.rs | 71 + crates/ide-host/src/plugins/providers.rs | 332 +++ crates/ide-host/src/plugins/remote.rs | 223 ++ crates/ide-host/src/plugins/remote_host.rs | 502 ++++ crates/ide-host/src/plugins/run_detectors.rs | 168 ++ .../src => ide-host/src/plugins}/theme.rs | 75 +- crates/ide-host/src/plugins/types.rs | 236 ++ crates/ide-host/src/plugins/ui_points.rs | 420 +++ crates/ide-host/src/plugins/views.rs | 53 + crates/ide-host/src/rpc.rs | 5 +- crates/ide-host/src/trust.rs | 1 + crates/ide-host/tests/distribution.rs | 262 ++ crates/ide-host/tests/plugins.rs | 1867 ++++++++++++++ crates/ide-host/tests/remote_plugins.rs | 311 +++ crates/ide-language/Cargo.toml | 1 + crates/ide-language/src/lib.rs | 48 +- crates/ide-language/src/plugin_point.rs | 313 +++ crates/ide-language/src/registry.rs | 113 +- crates/ide-language/src/syntax/cache.rs | 3 + crates/ide-language/src/syntax/external.rs | 239 ++ crates/ide-language/src/syntax/highlight.rs | 18 + crates/ide-language/src/syntax/mod.rs | 2 + crates/ide-lsp/src/launch.rs | 11 +- crates/ide-lsp/tests/mock_server.rs | 1 + crates/ide-lsp/tests/real_servers.rs | 3 + crates/ide-plugin/Cargo.toml | 13 +- crates/ide-plugin/src/actor.rs | 280 ++ crates/ide-plugin/src/lib.rs | 778 +----- crates/ide-plugin/src/manifest.rs | 279 -- crates/ide-plugin/src/package.rs | 74 - crates/ide-plugin/src/runtime.rs | 1010 ++++++-- crates/ide-plugin/src/types.rs | 575 ++++- crates/ide-plugin/src/wit_host.rs | 691 +++++ .../tests/fixtures/misbehave/dist/plugin.wasm | Bin 47332 -> 84266 bytes .../tests/fixtures/misbehave/plugin.toml | 11 +- .../tests/fixtures/misbehave/src/lib.rs | 76 +- .../tests/fixtures/p0-probe/.gitignore | 2 + .../tests/fixtures/p0-probe/Cargo.lock | 326 +++ .../tests/fixtures/p0-probe/Cargo.toml | 25 + .../tests/fixtures/p0-probe/build.sh | 11 + .../tests/fixtures/p0-probe/dist/full.wasm | Bin 0 -> 56940 bytes .../tests/fixtures/p0-probe/dist/min.wasm | Bin 0 -> 54275 bytes .../tests/fixtures/p0-probe/src/lib.rs | 68 + .../fixtures/p0-probe/wit/deps/io/poll.wit | 11 + .../tests/fixtures/p0-probe/wit/probe.wit | 59 + crates/ide-plugin/tests/host.rs | 603 ----- crates/ide-plugin/tests/p0_probe.rs | 352 +++ crates/ide-run/src/detect.rs | 1 + crates/ide-run/src/external.rs | 93 + crates/ide-run/src/lib.rs | 3 + crates/ide-run/src/process.rs | 6 + crates/ide-sdk/src/apply.rs | 3 + crates/ide-sdk/src/tests.rs | 1 + crates/ide-workspace/src/documents.rs | 21 + docs/plugin_p0.md | 159 ++ docs/plugin_plan.md | 601 +++++ perf/budgets.json | 8 +- perf/rust.ts | 2 +- plugins/README.md | 621 ++++- plugins/hello/Cargo.lock | 9 +- plugins/hello/Cargo.toml | 4 +- plugins/hello/dist/plugin.wasm | Bin 70241 -> 132571 bytes plugins/hello/plugin.toml | 39 +- plugins/hello/src/lib.rs | 156 +- plugins/official/ai-completion/.gitignore | 1 + plugins/official/ai-completion/Cargo.lock | 334 +++ plugins/official/ai-completion/Cargo.toml | 22 + .../official/ai-completion/dist/plugin.wasm | Bin 0 -> 165922 bytes plugins/official/ai-completion/plugin.toml | 44 + plugins/official/ai-completion/src/lib.rs | 214 ++ plugins/official/checkstyle/.gitignore | 1 + plugins/official/checkstyle/Cargo.lock | 333 +++ plugins/official/checkstyle/Cargo.toml | 21 + plugins/official/checkstyle/dist/plugin.wasm | Bin 0 -> 157899 bytes plugins/official/checkstyle/plugin.toml | 33 + plugins/official/checkstyle/src/lib.rs | 317 +++ plugins/official/csv-editor/Cargo.lock | 333 +++ plugins/official/csv-editor/Cargo.toml | 21 + plugins/official/csv-editor/dist/plugin.wasm | Bin 0 -> 130639 bytes plugins/official/csv-editor/plugin.toml | 23 + .../official/csv-editor/settings.schema.json | 17 + plugins/official/csv-editor/src/lib.rs | 319 +++ plugins/official/git-toolbox/.gitignore | 1 + plugins/official/git-toolbox/Cargo.lock | 333 +++ plugins/official/git-toolbox/Cargo.toml | 21 + plugins/official/git-toolbox/dist/plugin.wasm | Bin 0 -> 107752 bytes plugins/official/git-toolbox/plugin.toml | 26 + plugins/official/git-toolbox/src/lib.rs | 242 ++ plugins/official/key-promoter/.gitignore | 1 + plugins/official/key-promoter/Cargo.lock | 333 +++ plugins/official/key-promoter/Cargo.toml | 21 + .../official/key-promoter/dist/plugin.wasm | Bin 0 -> 99579 bytes plugins/official/key-promoter/plugin.toml | 31 + plugins/official/key-promoter/src/lib.rs | 161 ++ plugins/official/kubernetes/Cargo.lock | 334 +++ plugins/official/kubernetes/Cargo.toml | 22 + plugins/official/kubernetes/dist/plugin.wasm | Bin 0 -> 210999 bytes plugins/official/kubernetes/plugin.toml | 44 + .../official/kubernetes/settings.schema.json | 27 + plugins/official/kubernetes/src/lib.rs | 369 +++ plugins/official/material-icons/generate.py | 338 +++ .../official/material-icons/icons/archive.svg | 1 + .../official/material-icons/icons/astro.svg | 1 + .../official/material-icons/icons/audio.svg | 1 + .../official/material-icons/icons/batch.svg | 1 + plugins/official/material-icons/icons/c.svg | 1 + .../official/material-icons/icons/cargo.svg | 1 + .../official/material-icons/icons/class.svg | 1 + .../official/material-icons/icons/clojure.svg | 1 + plugins/official/material-icons/icons/cpp.svg | 1 + .../official/material-icons/icons/csharp.svg | 1 + plugins/official/material-icons/icons/css.svg | 1 + plugins/official/material-icons/icons/csv.svg | 1 + .../official/material-icons/icons/dart.svg | 1 + .../material-icons/icons/database.svg | 1 + .../official/material-icons/icons/diff.svg | 1 + .../official/material-icons/icons/docker.svg | 1 + .../material-icons/icons/editorconfig.svg | 1 + .../official/material-icons/icons/elixir.svg | 1 + plugins/official/material-icons/icons/env.svg | 1 + .../official/material-icons/icons/erlang.svg | 1 + .../official/material-icons/icons/file.svg | 1 + .../material-icons/icons/folder-api.svg | 1 + .../material-icons/icons/folder-assets.svg | 1 + .../icons/folder-components.svg | 1 + .../material-icons/icons/folder-config.svg | 1 + .../material-icons/icons/folder-db.svg | 1 + .../material-icons/icons/folder-deps.svg | 1 + .../material-icons/icons/folder-dist.svg | 1 + .../material-icons/icons/folder-docs.svg | 1 + .../material-icons/icons/folder-git.svg | 1 + .../material-icons/icons/folder-i18n.svg | 1 + .../material-icons/icons/folder-open.svg | 1 + .../material-icons/icons/folder-plugins.svg | 1 + .../material-icons/icons/folder-scripts.svg | 1 + .../material-icons/icons/folder-src.svg | 1 + .../material-icons/icons/folder-styles.svg | 1 + .../material-icons/icons/folder-test.svg | 1 + .../official/material-icons/icons/folder.svg | 1 + .../official/material-icons/icons/font.svg | 1 + .../official/material-icons/icons/fsharp.svg | 1 + plugins/official/material-icons/icons/git.svg | 1 + plugins/official/material-icons/icons/go.svg | 1 + .../official/material-icons/icons/gomod.svg | 1 + .../official/material-icons/icons/graphql.svg | 1 + .../official/material-icons/icons/groovy.svg | 1 + .../official/material-icons/icons/haskell.svg | 1 + .../official/material-icons/icons/header.svg | 1 + .../official/material-icons/icons/html.svg | 1 + .../official/material-icons/icons/http.svg | 1 + .../official/material-icons/icons/image.svg | 1 + plugins/official/material-icons/icons/ini.svg | 1 + .../official/material-icons/icons/java.svg | 1 + .../material-icons/icons/javascript.svg | 1 + .../official/material-icons/icons/json.svg | 1 + .../official/material-icons/icons/julia.svg | 1 + .../official/material-icons/icons/kotlin.svg | 1 + .../official/material-icons/icons/less.svg | 1 + .../official/material-icons/icons/license.svg | 1 + .../official/material-icons/icons/lock.svg | 1 + plugins/official/material-icons/icons/log.svg | 1 + plugins/official/material-icons/icons/lua.svg | 1 + .../material-icons/icons/makefile.svg | 1 + .../material-icons/icons/markdown.svg | 1 + plugins/official/material-icons/icons/nim.svg | 1 + plugins/official/material-icons/icons/nix.svg | 1 + .../material-icons/icons/notebook.svg | 1 + plugins/official/material-icons/icons/npm.svg | 1 + .../official/material-icons/icons/objc.svg | 1 + .../official/material-icons/icons/ocaml.svg | 1 + plugins/official/material-icons/icons/pdf.svg | 1 + .../official/material-icons/icons/perl.svg | 1 + plugins/official/material-icons/icons/php.svg | 1 + .../official/material-icons/icons/pnpm.svg | 1 + .../material-icons/icons/powershell.svg | 1 + .../official/material-icons/icons/proto.svg | 1 + .../official/material-icons/icons/python.svg | 1 + plugins/official/material-icons/icons/r.svg | 1 + .../material-icons/icons/react-ts.svg | 1 + .../official/material-icons/icons/react.svg | 1 + .../official/material-icons/icons/readme.svg | 1 + .../official/material-icons/icons/ruby.svg | 1 + .../official/material-icons/icons/rust.svg | 1 + .../official/material-icons/icons/sass.svg | 1 + .../official/material-icons/icons/scala.svg | 1 + .../material-icons/icons/settings.svg | 1 + .../official/material-icons/icons/shell.svg | 1 + .../material-icons/icons/solidity.svg | 1 + .../official/material-icons/icons/svelte.svg | 1 + plugins/official/material-icons/icons/svg.svg | 1 + .../official/material-icons/icons/swift.svg | 1 + .../material-icons/icons/terraform.svg | 1 + .../official/material-icons/icons/test.svg | 1 + plugins/official/material-icons/icons/tex.svg | 1 + .../official/material-icons/icons/text.svg | 1 + .../official/material-icons/icons/theme.json | 471 ++++ .../official/material-icons/icons/toml.svg | 1 + .../material-icons/icons/tsconfig.svg | 1 + .../material-icons/icons/typescript-def.svg | 1 + .../material-icons/icons/typescript.svg | 1 + .../official/material-icons/icons/video.svg | 1 + plugins/official/material-icons/icons/vim.svg | 1 + .../official/material-icons/icons/vite.svg | 1 + plugins/official/material-icons/icons/vue.svg | 1 + .../official/material-icons/icons/wasm.svg | 1 + plugins/official/material-icons/icons/xml.svg | 1 + .../official/material-icons/icons/yaml.svg | 1 + plugins/official/material-icons/icons/zig.svg | 1 + plugins/official/material-icons/plugin.toml | 15 + plugins/official/mybatis/.gitignore | 1 + plugins/official/mybatis/Cargo.lock | 333 +++ plugins/official/mybatis/Cargo.toml | 21 + plugins/official/mybatis/dist/plugin.wasm | Bin 0 -> 158332 bytes plugins/official/mybatis/plugin.toml | 17 + plugins/official/mybatis/src/lib.rs | 376 +++ plugins/official/solidity/.gitignore | 1 + plugins/official/solidity/Cargo.lock | 333 +++ plugins/official/solidity/Cargo.toml | 21 + plugins/official/solidity/dist/plugin.wasm | Bin 0 -> 105969 bytes .../official/solidity/languages/solidity.toml | 21 + plugins/official/solidity/plugin.toml | 41 + plugins/official/solidity/src/lib.rs | 689 +++++ plugins/official/string-tools/.gitignore | 1 + plugins/official/string-tools/Cargo.lock | 333 +++ plugins/official/string-tools/Cargo.toml | 21 + .../official/string-tools/dist/plugin.wasm | Bin 0 -> 165373 bytes plugins/official/string-tools/plugin.toml | 171 ++ plugins/official/string-tools/src/lib.rs | 460 ++++ sdk/rust/openidea-plugin/Cargo.lock | 326 +++ sdk/rust/openidea-plugin/Cargo.toml | 16 + sdk/rust/openidea-plugin/src/host.rs | 588 +++++ sdk/rust/openidea-plugin/src/lib.rs | 649 +++++ sdk/rust/openidea-plugin/src/mock.rs | 788 ++++++ sdk/rust/openidea-plugin/src/providers.rs | 123 + sdk/rust/openidea-plugin/src/syntax.rs | 224 ++ sdk/rust/openidea-plugin/src/text.rs | 82 + sdk/rust/openidea-plugin/src/types.rs | 265 ++ sdk/rust/openidea-plugin/src/views.rs | 225 ++ sdk/rust/openidea-plugin/wit/events.wit | 68 + sdk/rust/openidea-plugin/wit/exports.wit | 70 + sdk/rust/openidea-plugin/wit/host.wit | 51 + sdk/rust/openidea-plugin/wit/io.wit | 85 + sdk/rust/openidea-plugin/wit/plugin.wit | 68 + sdk/rust/openidea-plugin/wit/providers.wit | 83 + sdk/rust/openidea-plugin/wit/types.wit | 64 + sdk/rust/openidea-plugin/wit/views.wit | 146 ++ sdk/rust/openidea-plugin/wit/workspace.wit | 90 + src-tauri/Cargo.toml | 3 + src-tauri/src/commands/app.rs | 10 +- src-tauri/src/commands/mod.rs | 22 + src-tauri/src/commands/plugin.rs | 427 +++- src-tauri/src/menu.rs | 5 +- src-tauri/src/remote_dev.rs | 110 +- src-tauri/src/state.rs | 4 +- src/editor/api.ts | 13 + src/editor/core/cm/languages.ts | 11 + src/editor/core/model/attachment.test.ts | 29 + src/editor/core/model/attachment.ts | 23 +- src/features/app/menuBridge.ts | 20 +- src/features/plugins/PluginsPage.module.css | 73 +- src/features/plugins/PluginsPage.tsx | 449 +++- .../plugins/__tests__/plugins.test.tsx | 370 ++- src/features/plugins/__tests__/views.test.tsx | 194 ++ src/features/plugins/backend.ts | 304 ++- src/features/plugins/bridge.ts | 153 +- src/features/plugins/consent.tsx | 141 + src/features/plugins/iconThemes.ts | 170 ++ src/features/plugins/index.ts | 34 +- src/features/plugins/lineMarkerGutter.ts | 149 ++ src/features/plugins/points.tsx | 158 ++ src/features/plugins/projection.ts | 129 + src/features/plugins/runtimeUi.tsx | 273 ++ src/features/plugins/secrets.tsx | 114 + src/features/plugins/store.ts | 204 +- src/features/plugins/uiPoints.tsx | 318 +++ .../plugins/views/PluginView.module.css | 175 ++ src/features/plugins/views/PluginView.tsx | 480 ++++ src/features/plugins/views/customEditor.tsx | 234 ++ src/features/plugins/views/frame.ts | 40 + src/features/plugins/views/viewStore.ts | 39 + src/features/project-tree/ProjectView.tsx | 12 +- .../search/everywhere/SearchEverywhere.tsx | 8 +- src/platform/actions.ts | 20 +- src/platform/filePresentation.ts | 7 +- src/platform/keymap.ts | 64 +- src/platform/locales/text/zh.json | 68 +- src/ui/Menu.tsx | 4 +- src/ui/icons.tsx | 58 + src/workbench/contributions.ts | 20 + wit/events.wit | 68 + wit/exports.wit | 70 + wit/host.wit | 51 + wit/io.wit | 85 + wit/plugin.wit | 142 +- wit/providers.wit | 83 + wit/types.wit | 64 + wit/views.wit | 146 ++ wit/workspace.wit | 90 + 326 files changed, 35587 insertions(+), 2777 deletions(-) create mode 100644 crates/ide-extension/Cargo.toml create mode 100644 crates/ide-extension/src/capability.rs create mode 100644 crates/ide-extension/src/index.rs create mode 100644 crates/ide-extension/src/ledger.rs create mode 100644 crates/ide-extension/src/lib.rs create mode 100644 crates/ide-extension/src/manifest.rs create mode 100644 crates/ide-extension/src/package.rs create mode 100644 crates/ide-extension/src/point.rs create mode 100644 crates/ide-extension/src/registry.rs create mode 100644 crates/ide-extension/src/ui.rs create mode 100644 crates/ide-extension/src/when.rs create mode 100644 crates/ide-host/benches/plugins.rs create mode 100644 crates/ide-host/src/bin/openidea-plugin/main.rs create mode 100644 crates/ide-host/src/bin/openidea-plugin/templates.rs create mode 100644 crates/ide-host/src/plugins/api.rs create mode 100644 crates/ide-host/src/plugins/icons.rs create mode 100644 crates/ide-host/src/plugins/install.rs create mode 100644 crates/ide-host/src/plugins/market.rs create mode 100644 crates/ide-host/src/plugins/mod.rs create mode 100644 crates/ide-host/src/plugins/points.rs create mode 100644 crates/ide-host/src/plugins/providers.rs create mode 100644 crates/ide-host/src/plugins/remote.rs create mode 100644 crates/ide-host/src/plugins/remote_host.rs create mode 100644 crates/ide-host/src/plugins/run_detectors.rs rename crates/{ide-plugin/src => ide-host/src/plugins}/theme.rs (73%) create mode 100644 crates/ide-host/src/plugins/types.rs create mode 100644 crates/ide-host/src/plugins/ui_points.rs create mode 100644 crates/ide-host/src/plugins/views.rs create mode 100644 crates/ide-host/tests/distribution.rs create mode 100644 crates/ide-host/tests/plugins.rs create mode 100644 crates/ide-host/tests/remote_plugins.rs create mode 100644 crates/ide-language/src/plugin_point.rs create mode 100644 crates/ide-language/src/syntax/external.rs create mode 100644 crates/ide-plugin/src/actor.rs delete mode 100644 crates/ide-plugin/src/manifest.rs delete mode 100644 crates/ide-plugin/src/package.rs create mode 100644 crates/ide-plugin/src/wit_host.rs create mode 100644 crates/ide-plugin/tests/fixtures/p0-probe/.gitignore create mode 100644 crates/ide-plugin/tests/fixtures/p0-probe/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/p0-probe/Cargo.toml create mode 100755 crates/ide-plugin/tests/fixtures/p0-probe/build.sh create mode 100644 crates/ide-plugin/tests/fixtures/p0-probe/dist/full.wasm create mode 100644 crates/ide-plugin/tests/fixtures/p0-probe/dist/min.wasm create mode 100644 crates/ide-plugin/tests/fixtures/p0-probe/src/lib.rs create mode 100644 crates/ide-plugin/tests/fixtures/p0-probe/wit/deps/io/poll.wit create mode 100644 crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit delete mode 100644 crates/ide-plugin/tests/host.rs create mode 100644 crates/ide-plugin/tests/p0_probe.rs create mode 100644 crates/ide-run/src/external.rs create mode 100644 docs/plugin_p0.md create mode 100644 docs/plugin_plan.md create mode 100644 plugins/official/ai-completion/.gitignore create mode 100644 plugins/official/ai-completion/Cargo.lock create mode 100644 plugins/official/ai-completion/Cargo.toml create mode 100644 plugins/official/ai-completion/dist/plugin.wasm create mode 100644 plugins/official/ai-completion/plugin.toml create mode 100644 plugins/official/ai-completion/src/lib.rs create mode 100644 plugins/official/checkstyle/.gitignore create mode 100644 plugins/official/checkstyle/Cargo.lock create mode 100644 plugins/official/checkstyle/Cargo.toml create mode 100644 plugins/official/checkstyle/dist/plugin.wasm create mode 100644 plugins/official/checkstyle/plugin.toml create mode 100644 plugins/official/checkstyle/src/lib.rs create mode 100644 plugins/official/csv-editor/Cargo.lock create mode 100644 plugins/official/csv-editor/Cargo.toml create mode 100644 plugins/official/csv-editor/dist/plugin.wasm create mode 100644 plugins/official/csv-editor/plugin.toml create mode 100644 plugins/official/csv-editor/settings.schema.json create mode 100644 plugins/official/csv-editor/src/lib.rs create mode 100644 plugins/official/git-toolbox/.gitignore create mode 100644 plugins/official/git-toolbox/Cargo.lock create mode 100644 plugins/official/git-toolbox/Cargo.toml create mode 100644 plugins/official/git-toolbox/dist/plugin.wasm create mode 100644 plugins/official/git-toolbox/plugin.toml create mode 100644 plugins/official/git-toolbox/src/lib.rs create mode 100644 plugins/official/key-promoter/.gitignore create mode 100644 plugins/official/key-promoter/Cargo.lock create mode 100644 plugins/official/key-promoter/Cargo.toml create mode 100644 plugins/official/key-promoter/dist/plugin.wasm create mode 100644 plugins/official/key-promoter/plugin.toml create mode 100644 plugins/official/key-promoter/src/lib.rs create mode 100644 plugins/official/kubernetes/Cargo.lock create mode 100644 plugins/official/kubernetes/Cargo.toml create mode 100644 plugins/official/kubernetes/dist/plugin.wasm create mode 100644 plugins/official/kubernetes/plugin.toml create mode 100644 plugins/official/kubernetes/settings.schema.json create mode 100644 plugins/official/kubernetes/src/lib.rs create mode 100644 plugins/official/material-icons/generate.py create mode 100644 plugins/official/material-icons/icons/archive.svg create mode 100644 plugins/official/material-icons/icons/astro.svg create mode 100644 plugins/official/material-icons/icons/audio.svg create mode 100644 plugins/official/material-icons/icons/batch.svg create mode 100644 plugins/official/material-icons/icons/c.svg create mode 100644 plugins/official/material-icons/icons/cargo.svg create mode 100644 plugins/official/material-icons/icons/class.svg create mode 100644 plugins/official/material-icons/icons/clojure.svg create mode 100644 plugins/official/material-icons/icons/cpp.svg create mode 100644 plugins/official/material-icons/icons/csharp.svg create mode 100644 plugins/official/material-icons/icons/css.svg create mode 100644 plugins/official/material-icons/icons/csv.svg create mode 100644 plugins/official/material-icons/icons/dart.svg create mode 100644 plugins/official/material-icons/icons/database.svg create mode 100644 plugins/official/material-icons/icons/diff.svg create mode 100644 plugins/official/material-icons/icons/docker.svg create mode 100644 plugins/official/material-icons/icons/editorconfig.svg create mode 100644 plugins/official/material-icons/icons/elixir.svg create mode 100644 plugins/official/material-icons/icons/env.svg create mode 100644 plugins/official/material-icons/icons/erlang.svg create mode 100644 plugins/official/material-icons/icons/file.svg create mode 100644 plugins/official/material-icons/icons/folder-api.svg create mode 100644 plugins/official/material-icons/icons/folder-assets.svg create mode 100644 plugins/official/material-icons/icons/folder-components.svg create mode 100644 plugins/official/material-icons/icons/folder-config.svg create mode 100644 plugins/official/material-icons/icons/folder-db.svg create mode 100644 plugins/official/material-icons/icons/folder-deps.svg create mode 100644 plugins/official/material-icons/icons/folder-dist.svg create mode 100644 plugins/official/material-icons/icons/folder-docs.svg create mode 100644 plugins/official/material-icons/icons/folder-git.svg create mode 100644 plugins/official/material-icons/icons/folder-i18n.svg create mode 100644 plugins/official/material-icons/icons/folder-open.svg create mode 100644 plugins/official/material-icons/icons/folder-plugins.svg create mode 100644 plugins/official/material-icons/icons/folder-scripts.svg create mode 100644 plugins/official/material-icons/icons/folder-src.svg create mode 100644 plugins/official/material-icons/icons/folder-styles.svg create mode 100644 plugins/official/material-icons/icons/folder-test.svg create mode 100644 plugins/official/material-icons/icons/folder.svg create mode 100644 plugins/official/material-icons/icons/font.svg create mode 100644 plugins/official/material-icons/icons/fsharp.svg create mode 100644 plugins/official/material-icons/icons/git.svg create mode 100644 plugins/official/material-icons/icons/go.svg create mode 100644 plugins/official/material-icons/icons/gomod.svg create mode 100644 plugins/official/material-icons/icons/graphql.svg create mode 100644 plugins/official/material-icons/icons/groovy.svg create mode 100644 plugins/official/material-icons/icons/haskell.svg create mode 100644 plugins/official/material-icons/icons/header.svg create mode 100644 plugins/official/material-icons/icons/html.svg create mode 100644 plugins/official/material-icons/icons/http.svg create mode 100644 plugins/official/material-icons/icons/image.svg create mode 100644 plugins/official/material-icons/icons/ini.svg create mode 100644 plugins/official/material-icons/icons/java.svg create mode 100644 plugins/official/material-icons/icons/javascript.svg create mode 100644 plugins/official/material-icons/icons/json.svg create mode 100644 plugins/official/material-icons/icons/julia.svg create mode 100644 plugins/official/material-icons/icons/kotlin.svg create mode 100644 plugins/official/material-icons/icons/less.svg create mode 100644 plugins/official/material-icons/icons/license.svg create mode 100644 plugins/official/material-icons/icons/lock.svg create mode 100644 plugins/official/material-icons/icons/log.svg create mode 100644 plugins/official/material-icons/icons/lua.svg create mode 100644 plugins/official/material-icons/icons/makefile.svg create mode 100644 plugins/official/material-icons/icons/markdown.svg create mode 100644 plugins/official/material-icons/icons/nim.svg create mode 100644 plugins/official/material-icons/icons/nix.svg create mode 100644 plugins/official/material-icons/icons/notebook.svg create mode 100644 plugins/official/material-icons/icons/npm.svg create mode 100644 plugins/official/material-icons/icons/objc.svg create mode 100644 plugins/official/material-icons/icons/ocaml.svg create mode 100644 plugins/official/material-icons/icons/pdf.svg create mode 100644 plugins/official/material-icons/icons/perl.svg create mode 100644 plugins/official/material-icons/icons/php.svg create mode 100644 plugins/official/material-icons/icons/pnpm.svg create mode 100644 plugins/official/material-icons/icons/powershell.svg create mode 100644 plugins/official/material-icons/icons/proto.svg create mode 100644 plugins/official/material-icons/icons/python.svg create mode 100644 plugins/official/material-icons/icons/r.svg create mode 100644 plugins/official/material-icons/icons/react-ts.svg create mode 100644 plugins/official/material-icons/icons/react.svg create mode 100644 plugins/official/material-icons/icons/readme.svg create mode 100644 plugins/official/material-icons/icons/ruby.svg create mode 100644 plugins/official/material-icons/icons/rust.svg create mode 100644 plugins/official/material-icons/icons/sass.svg create mode 100644 plugins/official/material-icons/icons/scala.svg create mode 100644 plugins/official/material-icons/icons/settings.svg create mode 100644 plugins/official/material-icons/icons/shell.svg create mode 100644 plugins/official/material-icons/icons/solidity.svg create mode 100644 plugins/official/material-icons/icons/svelte.svg create mode 100644 plugins/official/material-icons/icons/svg.svg create mode 100644 plugins/official/material-icons/icons/swift.svg create mode 100644 plugins/official/material-icons/icons/terraform.svg create mode 100644 plugins/official/material-icons/icons/test.svg create mode 100644 plugins/official/material-icons/icons/tex.svg create mode 100644 plugins/official/material-icons/icons/text.svg create mode 100644 plugins/official/material-icons/icons/theme.json create mode 100644 plugins/official/material-icons/icons/toml.svg create mode 100644 plugins/official/material-icons/icons/tsconfig.svg create mode 100644 plugins/official/material-icons/icons/typescript-def.svg create mode 100644 plugins/official/material-icons/icons/typescript.svg create mode 100644 plugins/official/material-icons/icons/video.svg create mode 100644 plugins/official/material-icons/icons/vim.svg create mode 100644 plugins/official/material-icons/icons/vite.svg create mode 100644 plugins/official/material-icons/icons/vue.svg create mode 100644 plugins/official/material-icons/icons/wasm.svg create mode 100644 plugins/official/material-icons/icons/xml.svg create mode 100644 plugins/official/material-icons/icons/yaml.svg create mode 100644 plugins/official/material-icons/icons/zig.svg create mode 100644 plugins/official/material-icons/plugin.toml create mode 100644 plugins/official/mybatis/.gitignore create mode 100644 plugins/official/mybatis/Cargo.lock create mode 100644 plugins/official/mybatis/Cargo.toml create mode 100644 plugins/official/mybatis/dist/plugin.wasm create mode 100644 plugins/official/mybatis/plugin.toml create mode 100644 plugins/official/mybatis/src/lib.rs create mode 100644 plugins/official/solidity/.gitignore create mode 100644 plugins/official/solidity/Cargo.lock create mode 100644 plugins/official/solidity/Cargo.toml create mode 100644 plugins/official/solidity/dist/plugin.wasm create mode 100644 plugins/official/solidity/languages/solidity.toml create mode 100644 plugins/official/solidity/plugin.toml create mode 100644 plugins/official/solidity/src/lib.rs create mode 100644 plugins/official/string-tools/.gitignore create mode 100644 plugins/official/string-tools/Cargo.lock create mode 100644 plugins/official/string-tools/Cargo.toml create mode 100644 plugins/official/string-tools/dist/plugin.wasm create mode 100644 plugins/official/string-tools/plugin.toml create mode 100644 plugins/official/string-tools/src/lib.rs create mode 100644 sdk/rust/openidea-plugin/Cargo.lock create mode 100644 sdk/rust/openidea-plugin/Cargo.toml create mode 100644 sdk/rust/openidea-plugin/src/host.rs create mode 100644 sdk/rust/openidea-plugin/src/lib.rs create mode 100644 sdk/rust/openidea-plugin/src/mock.rs create mode 100644 sdk/rust/openidea-plugin/src/providers.rs create mode 100644 sdk/rust/openidea-plugin/src/syntax.rs create mode 100644 sdk/rust/openidea-plugin/src/text.rs create mode 100644 sdk/rust/openidea-plugin/src/types.rs create mode 100644 sdk/rust/openidea-plugin/src/views.rs create mode 100644 sdk/rust/openidea-plugin/wit/events.wit create mode 100644 sdk/rust/openidea-plugin/wit/exports.wit create mode 100644 sdk/rust/openidea-plugin/wit/host.wit create mode 100644 sdk/rust/openidea-plugin/wit/io.wit create mode 100644 sdk/rust/openidea-plugin/wit/plugin.wit create mode 100644 sdk/rust/openidea-plugin/wit/providers.wit create mode 100644 sdk/rust/openidea-plugin/wit/types.wit create mode 100644 sdk/rust/openidea-plugin/wit/views.wit create mode 100644 sdk/rust/openidea-plugin/wit/workspace.wit create mode 100644 src/features/plugins/__tests__/views.test.tsx create mode 100644 src/features/plugins/consent.tsx create mode 100644 src/features/plugins/iconThemes.ts create mode 100644 src/features/plugins/lineMarkerGutter.ts create mode 100644 src/features/plugins/points.tsx create mode 100644 src/features/plugins/projection.ts create mode 100644 src/features/plugins/runtimeUi.tsx create mode 100644 src/features/plugins/secrets.tsx create mode 100644 src/features/plugins/uiPoints.tsx create mode 100644 src/features/plugins/views/PluginView.module.css create mode 100644 src/features/plugins/views/PluginView.tsx create mode 100644 src/features/plugins/views/customEditor.tsx create mode 100644 src/features/plugins/views/frame.ts create mode 100644 src/features/plugins/views/viewStore.ts create mode 100644 wit/events.wit create mode 100644 wit/exports.wit create mode 100644 wit/host.wit create mode 100644 wit/io.wit create mode 100644 wit/providers.wit create mode 100644 wit/types.wit create mode 100644 wit/views.wit create mode 100644 wit/workspace.wit diff --git a/Cargo.lock b/Cargo.lock index a39ae6994..ac0c58f70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4181,6 +4181,26 @@ dependencies = [ "which", ] +[[package]] +name = "ide-extension" +version = "0.1.34" +dependencies = [ + "base64 0.22.1", + "blake3", + "ed25519-dalek", + "ide-core", + "parking_lot", + "semver", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tempfile", + "toml 0.9.12+spec-1.1.0", + "tracing", + "zip", +] + [[package]] name = "ide-fonts" version = "0.1.35" @@ -4210,9 +4230,13 @@ dependencies = [ name = "ide-host" version = "0.1.35" dependencies = [ + "base64 0.22.1", + "criterion", "dirs 6.0.0", "dunce", "encoding_rs", + "getrandom 0.3.4", + "globset", "ide-agent", "ide-analysis", "ide-build", @@ -4220,12 +4244,14 @@ dependencies = [ "ide-coverage", "ide-dap", "ide-db", + "ide-extension", "ide-http", "ide-index", "ide-jsonrpc", "ide-language", "ide-lsp", "ide-notebook", + "ide-plugin", "ide-remote", "ide-run", "ide-sdk", @@ -4239,7 +4265,9 @@ dependencies = [ "ide-vcs", "ide-vfs", "ide-workspace", + "ignore", "parking_lot", + "reqwest", "rustix", "serde", "serde_json", @@ -4247,6 +4275,7 @@ dependencies = [ "specta-typescript", "tempfile", "tokio", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "uuid", @@ -4316,6 +4345,7 @@ dependencies = [ "blake3", "criterion", "ide-core", + "ide-extension", "ide-text", "parking_lot", "regex-lite", @@ -4417,19 +4447,20 @@ dependencies = [ name = "ide-plugin" version = "0.1.35" dependencies = [ + "blake3", "ide-core", - "ide-language", + "ide-extension", "parking_lot", "serde", "serde_json", "specta", "tempfile", "thiserror 2.0.21", + "tokio", "toml 0.9.12+spec-1.1.0", "tracing", "wasmtime", "wasmtime-wasi", - "zip", ] [[package]] @@ -6164,6 +6195,7 @@ dependencies = [ "ide-dap", "ide-db", "ide-docker", + "ide-extension", "ide-fonts", "ide-github", "ide-host", @@ -6195,6 +6227,7 @@ dependencies = [ "objc2-foundation 0.3.2", "objc2-web-kit 0.3.2", "parking_lot", + "security-framework", "serde", "serde_json", "specta", diff --git a/Cargo.toml b/Cargo.toml index f306034e9..a1c42bf2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] resolver = "3" members = ["crates/*", "src-tauri"] +# Plugins and the guest SDK build for wasm32-wasip2 in their own workspaces. +exclude = ["sdk", "plugins"] [workspace.package] version = "0.1.35" @@ -41,6 +43,8 @@ ide-ssh = { path = "crates/ide-ssh" } ide-remote = { path = "crates/ide-remote" } ide-host = { path = "crates/ide-host" } ide-notebook = { path = "crates/ide-notebook" } +ide-extension = { path = "crates/ide-extension" } +ide-plugin = { path = "crates/ide-plugin" } # serialization & IPC typing serde = { version = "1", features = ["derive"] } @@ -69,6 +73,7 @@ ropey = "1.6" encoding_rs = "0.8" chardetng = "0.1" ignore = "0.4" +base64 = "0.22" globset = "0.4" notify = "8" notify-debouncer-full = "0.6" diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 95ba3f5fd..f0e942945 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -61,6 +61,9 @@ pub struct Settings { pub cmake: CMakeSettings, /// Tools › Agents › Chat. pub agents: AgentSettings, + /// Plugin settings (`[contributes.settings]`): plugin id → its values by key. + #[specta(type = BTreeMap>)] + pub plugins: BTreeMap>, } /// What the meters in an agent task's header show. @@ -215,6 +218,7 @@ impl Default for Settings { run_targets: Vec::new(), cmake: CMakeSettings::default(), agents: AgentSettings::default(), + plugins: BTreeMap::new(), } } } diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 341e0663b..2f4f88fc1 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -8,7 +8,7 @@ use ide_core::process::executable_names; use ide_core::{IdeError, IdeResult}; -use ide_language::{DebuggerConfig, LanguageRegistry}; +use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin}; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -44,6 +44,8 @@ pub struct AdapterSpec { /// `host:port` of an adapter inside another program (the Godot editor): connected to /// when it accepts, else `command` is started. pub connect: Option, + /// A plugin language's adapter: start exactly this approved program. + pub pinned: Option, } /// Built-in adapters used when no language definition names `adapter`. @@ -55,6 +57,7 @@ fn builtin(adapter: &str) -> Option { transport: transport.to_owned(), connect: None, install_hint: Some(hint.to_owned()), + pinned: None, }; Some(match adapter { "codelldb" => cfg( @@ -133,6 +136,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec { env: Vec::new(), server_host: None, connect: config.connect.clone(), + pinned: config.pinned.clone(), } } @@ -426,12 +430,20 @@ impl AdapterLauncher for ProcessLauncher { if is_plugin_bundle(&spec.command) { return Err(not_standalone(&spec)); } - let (command, cwd) = (spec.command.clone(), spec.cwd.clone()); - let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd)) - .await - .ok() - .flatten() - .ok_or_else(|| missing(&spec))?; + let binary = match spec.pinned.clone() { + Some(pin) => tokio::task::spawn_blocking(move || pin.verify().map(Path::to_path_buf)) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + .map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?, + None => { + let (command, cwd) = (spec.command.clone(), spec.cwd.clone()); + tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd)) + .await + .ok() + .flatten() + .ok_or_else(|| missing(&spec))? + } + }; let port = match spec.transport { // An embedded adapter started without `${port}` listens on its usual address. Transport::Tcp => match &embedded { diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index e1fb0ecae..181386a5f 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -915,6 +915,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc Risk { + match self { + Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low, + Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents => Risk::Medium, + Self::Process(_) | Self::FsRead(_) => Risk::High, + } + } + + /// Low-risk capabilities that the one-time migration of the 0.1 "declared + /// means granted" behaviour may grant automatically (plan 4.6). + pub fn auto_migrates(&self) -> bool { + self.risk() == Risk::Low + } + + /// Short user-facing description for install and grant pages. + pub fn describe(&self) -> String { + match self { + Self::ProjectRead => "Read files in the open project".into(), + Self::EditorRead => "Read the focused editor's file and selection".into(), + Self::EditorWrite => "Change text in the editor".into(), + Self::VcsRead => "Read version control state".into(), + Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(), + Self::Storage => "Store its own data".into(), + Self::Secrets => "Store secrets in the system keychain".into(), + Self::Events => "Observe workbench events (files opened, saved, focus)".into(), + Self::InputEvents => "Observe keystrokes and editor input".into(), + Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"), + Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"), + Self::FsRead(path) => format!("Read files under {path}, outside the project"), + } + } + + /// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`). + pub fn covers(&self, wanted: &Capability) -> bool { + match (self, wanted) { + (Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host), + (Self::FsRead(base), Self::FsRead(path)) => { + std::path::Path::new(path).starts_with(std::path::Path::new(base)) + } + _ => self == wanted, + } + } +} + +/// `example.com` matches exactly; `*.example.com` matches any subdomain (not the apex). +pub fn host_matches(pattern: &str, host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + let pattern = pattern.to_ascii_lowercase(); + match pattern.strip_prefix("*.") { + Some(suffix) => host.len() > suffix.len() + 1 && host.ends_with(&format!(".{suffix}")), + None => host == pattern, + } +} + +impl fmt::Display for Capability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ProjectRead => f.write_str("project.read"), + Self::EditorRead => f.write_str("editor.read"), + Self::EditorWrite => f.write_str("editor.write"), + Self::VcsRead => f.write_str("vcs.read"), + Self::VcsWrite => f.write_str("vcs.write"), + Self::Storage => f.write_str("storage"), + Self::Secrets => f.write_str("secrets"), + Self::Events => f.write_str("events"), + Self::InputEvents => f.write_str("events.input"), + Self::Net(h) => write!(f, "net:{h}"), + Self::Process(b) => write!(f, "process:{b}"), + Self::FsRead(p) => write!(f, "fs.read:{p}"), + } + } +} + +impl FromStr for Capability { + type Err = String; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + if let Some((kind, arg)) = s.split_once(':') { + let arg = arg.trim(); + if arg.is_empty() { + return Err(format!("capability {s:?} needs a value after ':'")); + } + return match kind { + "net" => { + let host = arg.strip_prefix("*.").unwrap_or(arg); + let valid = !host.is_empty() + && host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + }); + if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) } + } + "process" => { + if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() { + return Err(format!("{s:?}: use a program name or an absolute path")); + } + Ok(Self::Process(arg.to_owned())) + } + "fs.read" => { + if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') { + return Err(format!("{s:?}: use an absolute path (or ~/…)")); + } + Ok(Self::FsRead(arg.to_owned())) + } + _ => Err(format!("unknown capability {s:?}")), + }; + } + Ok(match s { + "project.read" | "project-read" => Self::ProjectRead, + "editor.read" | "editor-read" => Self::EditorRead, + "editor.write" | "editor-write" => Self::EditorWrite, + "vcs.read" => Self::VcsRead, + "vcs.write" => Self::VcsWrite, + "storage" => Self::Storage, + "secrets" => Self::Secrets, + "events" => Self::Events, + "events.input" => Self::InputEvents, + _ => return Err(format!("unknown capability {s:?}")), + }) + } +} + +impl Serialize for Capability { + fn serialize(&self, s: S) -> Result { + s.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Capability { + fn deserialize>(d: D) -> Result { + String::deserialize(d)?.parse().map_err(serde::de::Error::custom) + } +} + +/// A capability as the UI shows it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityInfo { + /// `project.read`, `net:crates.io`, … + pub id: String, + pub risk: Risk, + pub description: String, + /// Declared under `optional`: the plugin runs without it. + pub optional: bool, + /// Currently granted. + pub granted: bool, +} + +impl CapabilityInfo { + pub fn new(cap: &Capability, optional: bool, granted: bool) -> Self { + Self { id: cap.to_string(), risk: cap.risk(), description: cap.describe(), optional, granted } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_prints() { + for s in [ + "project.read", + "editor.write", + "vcs.write", + "storage", + "secrets", + "events", + "events.input", + "net:crates.io", + "net:*.github.com", + "process:cargo", + "process:/usr/bin/git", + "fs.read:/etc", + ] { + assert_eq!(s.parse::().unwrap().to_string(), s); + } + assert_eq!("project-read".parse::().unwrap(), Capability::ProjectRead); + assert!("net:".parse::().is_err()); + assert!("net:a b".parse::().is_err()); + assert!("process:./evil".parse::().is_err()); + assert!("fs.read:rel".parse::().is_err()); + assert!("network".parse::().is_err()); + } + + #[test] + fn risk_levels() { + assert_eq!(Capability::ProjectRead.risk(), Risk::Low); + assert_eq!(Capability::Net("x".into()).risk(), Risk::Medium); + assert_eq!(Capability::InputEvents.risk(), Risk::Medium); + assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High); + assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates()); + } + + #[test] + fn host_patterns() { + assert!(host_matches("crates.io", "crates.io")); + assert!(host_matches("crates.io", "CRATES.IO.")); + assert!(!host_matches("crates.io", "static.crates.io")); + assert!(host_matches("*.github.com", "api.github.com")); + assert!(!host_matches("*.github.com", "github.com")); + assert!(!host_matches("*.github.com", "evilgithub.com")); + let any_gh: Capability = "net:*.github.com".parse().unwrap(); + assert!(any_gh.covers(&"net:api.github.com".parse().unwrap())); + assert!(Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etc/hosts".into()))); + assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into()))); + } +} diff --git a/crates/ide-extension/src/index.rs b/crates/ide-extension/src/index.rs new file mode 100644 index 000000000..2bb05d450 --- /dev/null +++ b/crates/ide-extension/src/index.rs @@ -0,0 +1,104 @@ +//! The contribution index (docs/plugin_plan.md 4.5): validated UI +//! declarations of installed packages, kept on disk so start-up does not +//! validate them again (sanitising thousands of icon SVGs, say). +//! +//! Only packages with a [`Package::digest`] are indexed: installed copies, +//! which the host never modifies after install. An entry is keyed by that +//! digest, the extension point, the declaration and a salt naming the +//! validation logic (the IDE version), so a new package version, a changed +//! declaration or an IDE upgrade simply misses. A miss or an unreadable entry +//! only costs the validation. + +use crate::point::Package; +use crate::ui::UiDecl; +use std::collections::BTreeSet; +use std::path::PathBuf; + +/// Bumped when [`UiDecl`]'s serialized shape changes. +const FORMAT: u32 = 1; + +pub struct ContributionIndex { + dir: PathBuf, + salt: String, +} + +impl ContributionIndex { + /// `salt` names the validators' version (the IDE version). + pub fn new(dir: PathBuf, salt: &str) -> Self { + Self { dir, salt: format!("{FORMAT}:{salt}") } + } + + /// Entries start with a prefix of the package digest, for pruning. + fn file(&self, digest: &str, point: &str, decl: &toml::Value) -> PathBuf { + let mut h = blake3::Hasher::new(); + for part in [self.salt.as_str(), digest, point, &decl.to_string()] { + h.update(part.as_bytes()); + h.update(&[0]); + } + let key = h.finalize().to_hex(); + self.dir.join(format!("{}-{}.json", prefix(digest), &key[..32])) + } + + pub fn get(&self, pkg: &Package, point: &str, decl: &toml::Value) -> Option { + let digest = pkg.digest.as_deref()?; + let bytes = std::fs::read(self.file(digest, point, decl)).ok()?; + serde_json::from_slice(&bytes).ok() + } + + pub fn put(&self, pkg: &Package, point: &str, decl: &toml::Value, ui: &UiDecl) { + let Some(digest) = pkg.digest.as_deref() else { return }; + let Ok(bytes) = serde_json::to_vec(ui) else { return }; + let file = self.file(digest, point, decl); + let _ = std::fs::create_dir_all(&self.dir); + let tmp = file.with_extension(format!("tmp{}", std::process::id())); + if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).is_err() { + let _ = std::fs::remove_file(&tmp); + } + } + + /// Removes entries of packages other than `live` (digests). + pub fn prune<'a>(&self, live: impl IntoIterator) { + let keep: BTreeSet = live.into_iter().map(|d| prefix(d).to_owned()).collect(); + let Ok(entries) = std::fs::read_dir(&self.dir) else { return }; + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if !name.split_once('-').is_some_and(|(p, _)| keep.contains(p)) { + let _ = std::fs::remove_file(e.path()); + } + } + } +} + +fn prefix(digest: &str) -> &str { + let d = digest.strip_prefix("blake3:").unwrap_or(digest); + let end = d.char_indices().nth(16).map_or(d.len(), |(i, _)| i); + &d[..end] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::Manifest; + + #[test] + fn hits_only_the_same_package_point_declaration_and_salt() { + let tmp = tempfile::tempdir().unwrap(); + let index = ContributionIndex::new(tmp.path().join("idx"), "1.0"); + let manifest = + Manifest::parse("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n").unwrap(); + let pkg = Package::new(manifest, tmp.path().to_path_buf()).with_digest("blake3:00aa11bb22cc33dd44ee"); + let decl: toml::Value = toml::from_str("id = \"x\"").unwrap(); + let ui = UiDecl::new("x", "X", serde_json::json!({ "id": "x" })); + assert!(index.get(&pkg, "pt", &decl).is_none()); + index.put(&pkg, "pt", &decl, &ui); + assert_eq!(index.get(&pkg, "pt", &decl).unwrap().label, "X"); + assert!(index.get(&pkg, "other", &decl).is_none()); + assert!(index.get(&pkg, "pt", &toml::from_str("id = \"y\"").unwrap()).is_none()); + assert!(ContributionIndex::new(tmp.path().join("idx"), "1.1").get(&pkg, "pt", &decl).is_none()); + let unhashed = Package { digest: None, ..pkg.clone() }; + assert!(index.get(&unhashed, "pt", &decl).is_none(), "not an installed copy"); + + index.prune(["blake3:ffff"]); + assert!(index.get(&pkg, "pt", &decl).is_none()); + } +} diff --git a/crates/ide-extension/src/ledger.rs b/crates/ide-extension/src/ledger.rs new file mode 100644 index 000000000..9bed117b3 --- /dev/null +++ b/crates/ide-extension/src/ledger.rs @@ -0,0 +1,483 @@ +//! The grant ledger (plan 4.6): which capabilities the user granted to which +//! plugin, saved as `grants.json` next to the installed plugins. +//! +//! - Installed plugins are keyed by id and remember the version and package +//! hash they were confirmed for. An update that needs no new capability is +//! accepted silently; one that does must be confirmed again. +//! - Linked development plugins and project plugins (`.openidea/plugins/*`) +//! are keyed by their directory, not by hash, so rebuilding does not ask +//! again; new capabilities still do. +//! - `process:` grants record the program's absolute path and file +//! hash at grant time. Calls use that path, never a `PATH` lookup, and fail +//! when the file changed (re-confirmation needed). + +use crate::capability::{Capability, Risk}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +pub const LEDGER_FILE: &str = "grants.json"; +const FORMAT: u32 = 1; + +/// Whose grant an entry is. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "camelCase")] +pub enum Subject { + /// Installed under the plugins directory, by plugin id. + Installed(String), + /// A linked development plugin or a project plugin, by package directory. + Path(PathBuf), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BinaryGrant { + pub path: PathBuf, + /// blake3 of the file when granted. + pub hash: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Grant { + pub plugin_id: String, + /// The version and package hash the user confirmed (installed plugins). + #[serde(default)] + pub version: String, + #[serde(default)] + pub package_hash: String, + /// The user confirmed the plugin (the install page or the project-plugin prompt). + #[serde(default)] + pub confirmed: bool, + /// Granted capabilities (`project.read`, `net:x`, …). + #[serde(default)] + pub granted: BTreeSet, + /// Optional capabilities the user declined (not asked again until re-confirmed). + #[serde(default)] + pub declined: BTreeSet, + /// `process:` → the program confirmed for it. + #[serde(default)] + pub binaries: BTreeMap, + /// Migrated from 0.1: capabilities not granted yet are asked for on first + /// use instead of blocking the plugin from loading. + #[serde(default)] + pub ask_on_use: bool, +} + +impl Grant { + pub fn has(&self, cap: &Capability) -> bool { + self.granted.iter().filter_map(|s| s.parse::().ok()).any(|g| g.covers(cap)) + } +} + +/// What a plugin (in its current form) needs, to compare with its grant. +#[derive(Clone, Debug)] +pub struct Request<'a> { + pub subject: Subject, + pub plugin_id: &'a str, + pub version: &'a str, + pub package_hash: &'a str, + pub required: Vec, + pub optional: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Decision { + /// Load with these capabilities. `pending` required ones are asked for when + /// the plugin is first used (plugins migrated from 0.1 only). + Allowed { granted: Vec, pending: Vec }, + /// Ask the user first. `new` lists what was never granted (all of it on first use). + NeedsConfirmation { new: Vec, first_time: bool }, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct File { + #[serde(default)] + format: u32, + /// The one-time migration from 0.1 ("declared means granted") ran. + #[serde(default)] + migrated: bool, + #[serde(default)] + grants: Vec<(Subject, Grant)>, +} + +/// The ledger, loaded into memory; [`Ledger::save`] writes it atomically. +#[derive(Debug, Default)] +pub struct Ledger { + path: Option, + migrated: bool, + grants: BTreeMap, +} + +impl Ledger { + /// Loads `/grants.json`; a missing or unreadable file gives an empty + /// ledger with `needs_migration()` true. + pub fn load(dir: &Path) -> Self { + let path = dir.join(LEDGER_FILE); + let file: Option = std::fs::read_to_string(&path).ok().and_then(|s| match serde_json::from_str(&s) { + Ok(f) => Some(f), + Err(e) => { + tracing::warn!("{}: {e}; starting with an empty grant ledger", path.display()); + None + } + }); + let existed = file.is_some(); + let file = file.unwrap_or_default(); + Self { path: Some(path), migrated: file.migrated || existed, grants: file.grants.into_iter().collect() } + } + + pub fn in_memory() -> Self { + Self { path: None, migrated: true, grants: BTreeMap::new() } + } + + pub fn save(&self) -> std::io::Result<()> { + let Some(path) = &self.path else { return Ok(()) }; + let file = File { + format: FORMAT, + migrated: self.migrated, + grants: self.grants.iter().map(|(s, g)| (s.clone(), g.clone())).collect(), + }; + let json = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, path) + } + + pub fn get(&self, subject: &Subject) -> Option<&Grant> { + self.grants.get(subject) + } + + pub fn remove(&mut self, subject: &Subject) -> Option { + self.grants.remove(subject) + } + + /// Puts back an earlier state of `subject`'s grant (`None`: none), e.g. + /// after rolling back an update. + pub fn restore(&mut self, subject: &Subject, grant: Option) { + match grant { + Some(g) => { + self.grants.insert(subject.clone(), g); + } + None => { + self.grants.remove(subject); + } + } + } + + /// The one-time migration of 0.1's "declared means granted" has not run yet. + pub fn needs_migration(&self) -> bool { + !self.migrated + } + + /// Migrates plugins installed before the ledger existed: low-risk + /// capabilities are granted, the rest are asked on first use. + pub fn migrate(&mut self, installed: &[Request<'_>]) { + for req in installed { + let granted: BTreeSet = + req.required.iter().chain(&req.optional).filter(|c| c.auto_migrates()).map(|c| c.to_string()).collect(); + self.grants.entry(req.subject.clone()).or_insert_with(|| Grant { + plugin_id: req.plugin_id.to_owned(), + version: req.version.to_owned(), + package_hash: req.package_hash.to_owned(), + confirmed: true, + granted, + ask_on_use: true, + ..Grant::default() + }); + } + self.migrated = true; + } + + /// Compares what the plugin needs with what was granted. + pub fn decide(&mut self, req: &Request<'_>) -> Decision { + let Some(grant) = self.grants.get_mut(&req.subject) else { + return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true }; + }; + if !grant.confirmed { + return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true }; + } + let new: Vec = req.required.iter().filter(|c| !grant.has(c)).cloned().collect(); + if !new.is_empty() && !grant.ask_on_use { + return Decision::NeedsConfirmation { new, first_time: false }; + } + // Same or fewer needs: accept an update silently (plan 4.6). + if grant.version != req.version || grant.package_hash != req.package_hash { + grant.version = req.version.to_owned(); + grant.package_hash = req.package_hash.to_owned(); + } + let granted = req.required.iter().chain(&req.optional).filter(|c| grant.has(c)).cloned().collect(); + Decision::Allowed { granted, pending: new } + } + + /// Records the user's confirmation: `required` plus the accepted optional capabilities. + pub fn confirm( + &mut self, + req: &Request<'_>, + accepted_optional: &[Capability], + binaries: BTreeMap, + ) { + let grant = self.grants.entry(req.subject.clone()).or_default(); + grant.plugin_id = req.plugin_id.to_owned(); + grant.version = req.version.to_owned(); + grant.package_hash = req.package_hash.to_owned(); + grant.confirmed = true; + grant.ask_on_use = false; + for cap in req.required.iter().chain(accepted_optional) { + grant.granted.insert(cap.to_string()); + } + for cap in &req.optional { + let s = cap.to_string(); + if accepted_optional.contains(cap) { + grant.declined.remove(&s); + } else if !grant.granted.contains(&s) { + grant.declined.insert(s); + } + } + grant.binaries.extend(binaries); + } + + /// Grants or revokes one capability (Settings › Plugins › permissions). + pub fn set(&mut self, subject: &Subject, cap: &Capability, granted: bool) -> bool { + let Some(grant) = self.grants.get_mut(subject) else { return false }; + let s = cap.to_string(); + if granted { + grant.declined.remove(&s); + grant.granted.insert(s) + } else { + if let Capability::Process(_) = cap { + grant.binaries.remove(&s); + } + grant.granted.remove(&s) + } + } + + /// Records the program approved for a `process:` grant. + pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) { + if let Some(grant) = self.grants.get_mut(subject) { + grant.binaries.insert(cap.to_string(), binary); + } + } + + /// Checks a `process:` grant at call time: the program confirmed for it, + /// unchanged since. Errors name what to do. + pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result { + let Capability::Process(name) = cap else { return Err(format!("{cap} is not a process capability")) }; + let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?; + let key = cap.to_string(); + if !grant.granted.contains(&key) { + return Err(format!("{cap} was not granted")); + } + let bin = grant.binaries.get(&key).ok_or_else(|| format!("{cap}: no program was confirmed for {name}"))?; + match file_hash(&bin.path) { + Ok(hash) if hash == bin.hash => Ok(bin.path.clone()), + Ok(_) => Err(format!( + "{} changed since it was approved for {cap}; confirm the plugin's permissions again", + bin.path.display() + )), + Err(e) => Err(format!("{}: {e}; confirm the plugin's permissions again", bin.path.display())), + } + } + + /// The program approved for a granted `process:` capability, without + /// checking the file: whoever starts it verifies the hash then + /// ([`check_binary`](Self::check_binary) or the pin's own check). + pub fn pinned_binary(&self, subject: &Subject, cap: &Capability) -> Option { + let grant = self.grants.get(subject)?; + let key = cap.to_string(); + if !grant.granted.contains(&key) { + return None; + } + grant.binaries.get(&key).cloned() + } + + pub fn subjects(&self) -> impl Iterator { + self.grants.iter() + } +} + +/// What a confirmation page shows: every capability with its risk, high risk first. +pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec)> { + let mut groups: BTreeMap, Vec> = BTreeMap::new(); + for c in caps { + groups.entry(std::cmp::Reverse(c.risk())).or_default().push(c.clone()); + } + groups.into_iter().map(|(r, v)| (r.0, v)).collect() +} + +/// Resolves a `process:` program to an absolute path once, at grant time: +/// absolute names are taken as is, others are looked up in `path_var`. +pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result { + let candidate = Path::new(name); + if candidate.is_absolute() { + return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) }; + } + let exts: Vec = if cfg!(windows) { + std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect() + } else { + vec![String::new()] + }; + for dir in std::env::split_paths(path_var.unwrap_or_default()) { + if !dir.is_absolute() { + continue; // `.` or relative entries could point into the project + } + for ext in &exts { + let p = dir.join(format!("{name}{ext}")); + if is_executable(&p) { + return Ok(dunce_canonical(&p)); + } + } + } + Err(format!("{name} was not found on PATH")) +} + +fn dunce_canonical(p: &Path) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) +} + +fn is_executable(p: &Path) -> bool { + let Ok(meta) = std::fs::metadata(p) else { return false }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + true +} + +/// blake3 of a file, hex. +pub fn file_hash(path: &Path) -> std::io::Result { + let mut hasher = blake3::Hasher::new(); + hasher.update_reader(std::fs::File::open(path)?)?; + Ok(hasher.finalize().to_hex().to_string()) +} + +/// blake3 over a package's files (sorted relative paths and contents). +pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result { + let mut sorted: Vec<&PathBuf> = files.iter().collect(); + sorted.sort(); + sorted.dedup(); + let mut hasher = blake3::Hasher::new(); + for rel in sorted { + hasher.update(rel.to_string_lossy().as_bytes()); + hasher.update(&[0]); + hasher.update_reader(std::fs::File::open(dir.join(rel))?)?; + hasher.update(&[0]); + } + Ok(hasher.finalize().to_hex().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn caps(list: &[&str]) -> Vec { + list.iter().map(|s| s.parse().unwrap()).collect() + } + + fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> { + Request { + subject: Subject::Installed("p".into()), + plugin_id: "p", + version, + package_hash: hash, + required: caps(required), + optional: caps(optional), + } + } + + #[test] + fn confirmation_then_silent_update_then_reconfirm_on_new_capability() { + let mut l = Ledger::in_memory(); + let r1 = req("1.0.0", "h1", &["project.read"], &["net:crates.io"]); + assert_eq!(l.decide(&r1), Decision::NeedsConfirmation { new: caps(&["project.read"]), first_time: true }); + l.confirm(&r1, &[], BTreeMap::new()); + assert_eq!( + l.decide(&r1), + Decision::Allowed { granted: caps(&["project.read"]), pending: vec![] }, + "declined optional" + ); + assert!(l.get(&r1.subject).unwrap().declined.contains("net:crates.io")); + + // Same needs, new version: silent. + let r2 = req("1.1.0", "h2", &["project.read"], &["net:crates.io"]); + assert!(matches!(l.decide(&r2), Decision::Allowed { .. })); + assert_eq!(l.get(&r2.subject).unwrap().version, "1.1.0"); + + // A new required capability: ask again, only for it. + let r3 = req("1.2.0", "h3", &["project.read", "editor.write"], &[]); + assert_eq!(l.decide(&r3), Decision::NeedsConfirmation { new: caps(&["editor.write"]), first_time: false }); + l.confirm(&r3, &[], BTreeMap::new()); + assert!(matches!(l.decide(&r3), Decision::Allowed { .. })); + + // Revoking a required capability blocks loading again. + assert!(l.set(&r3.subject, &Capability::EditorWrite, false)); + assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. })); + } + + #[test] + fn migration_grants_low_risk_only() { + let tmp = tempfile::tempdir().unwrap(); + let mut l = Ledger::load(tmp.path()); + assert!(l.needs_migration()); + let r = req("0.1.0", "h", &["project.read", "editor.write"], &[]); + l.migrate(std::slice::from_ref(&r)); + // Loads with the low-risk part; the rest is asked for on first use. + assert_eq!( + l.decide(&r), + Decision::Allowed { granted: caps(&["project.read"]), pending: caps(&["editor.write"]) } + ); + l.confirm(&r, &[], BTreeMap::new()); + assert_eq!( + l.decide(&r), + Decision::Allowed { granted: caps(&["project.read", "editor.write"]), pending: vec![] } + ); + l.save().unwrap(); + let again = Ledger::load(tmp.path()); + assert!(!again.needs_migration()); + assert!(again.get(&r.subject).unwrap().has(&Capability::ProjectRead)); + } + + #[cfg(unix)] + #[test] + fn binaries_are_pinned_by_path_and_hash() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let bin_dir = tmp.path().join("bin"); + std::fs::create_dir(&bin_dir).unwrap(); + let tool = bin_dir.join("tool"); + std::fs::write(&tool, "#!/bin/sh\necho v1\n").unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path_var = std::env::join_paths([PathBuf::from("relative"), bin_dir.clone()]).unwrap(); + let resolved = resolve_program("tool", Some(&path_var)).unwrap(); + assert!(resolved.is_absolute()); + assert!(resolve_program("missing", Some(&path_var)).is_err()); + + let mut l = Ledger::in_memory(); + let r = req("1.0.0", "h", &["process:tool"], &[]); + let cap: Capability = "process:tool".parse().unwrap(); + let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() }; + l.confirm(&r, &[], [(cap.to_string(), bin)].into()); + assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved); + std::fs::write(&tool, "#!/bin/sh\necho evil\n").unwrap(); + assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed")); + } + + #[test] + fn hashes_and_grouping() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("a"), "1").unwrap(); + std::fs::write(tmp.path().join("b"), "2").unwrap(); + let h1 = package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap(); + assert_eq!(h1, package_hash(tmp.path(), &["b".into(), "a".into()]).unwrap()); + std::fs::write(tmp.path().join("b"), "3").unwrap(); + assert_ne!(h1, package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap()); + let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"])); + assert_eq!(groups.iter().map(|g| g.0).collect::>(), [Risk::High, Risk::Medium, Risk::Low]); + } +} diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs new file mode 100644 index 000000000..6595166a0 --- /dev/null +++ b/crates/ide-extension/src/lib.rs @@ -0,0 +1,37 @@ +//! The plugin system's kernel, independent of the Wasm runtime +//! (`docs/plugin_plan.md` 3–4): +//! +//! - [`Manifest`]: `plugin.toml` (0.2 format; 0.1 still parses); +//! - [`Capability`]: what a plugin may do, with its [`Risk`] level; +//! - [`ExtensionPoint`]: one kind of contribution, implemented by the crate that +//! owns the feature; [`ExtensionRegistry`] validates a package's declarations +//! ([`ExtensionRegistry::prepare`], no side effects), makes them take effect +//! all or nothing ([`ExtensionRegistry::commit`]) and withdraws them per plugin; +//! - [`UiStore`] / [`UiPoint`]: UI contributions, validated here and projected +//! by the client without the possibility to fail; +//! - [`ledger`]: which capabilities the user granted to which plugin; +//! - [`package`]: the package file, its signature and the marketplace index. +//! +//! Dependency direction: `ide-core` ← `ide-extension` ← feature crates (their +//! extension points) and `ide-plugin` (Wasm) ← `ide-host` (assembly). + +mod capability; +mod index; +pub mod ledger; +mod manifest; +pub mod package; +mod point; +mod registry; +mod ui; +mod when; + +pub use capability::{Capability, CapabilityInfo, Risk, host_matches}; +pub use index::ContributionIndex; +pub use manifest::{ + ActivationEvent, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, check_relative, + engine_compatible, validate_item_id, validate_plugin_id, validate_point_id, +}; +pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode}; +pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet, Scope}; +pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json}; +pub use when::validate_when; diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs new file mode 100644 index 000000000..99820d5f5 --- /dev/null +++ b/crates/ide-extension/src/manifest.rs @@ -0,0 +1,501 @@ +//! `plugin.toml`, the package manifest (plan 4.4). +//! +//! ```toml +//! id = "acme.rust-tools" # optional publisher namespace before the first '.' +//! name = "Rust Tools" +//! version = "1.2.0" +//! engines.openidea = "^1.0" +//! wasm = "plugin.wasm" +//! activation = ["on-language:rust", "on-command:check"] +//! +//! [capabilities] +//! required = ["project.read", "editor.write"] +//! optional = ["net:crates.io", "process:cargo"] +//! +//! [[contributes.commands]] +//! id = "check" +//! title = "Check" +//! ``` +//! +//! `contributes` is kept as raw TOML: each `contributes.` is parsed and +//! validated by the extension point registered under that id +//! ([`crate::ExtensionRegistry`]), so new contribution types need no change here. +//! +//! Manifests in the 0.1 format (`openidea-api = "0.1"`, `permissions = [...]`) +//! still parse: permissions become required capabilities and the plugin is +//! activated at start-up, as before. + +use crate::capability::Capability; +use serde::Deserialize; +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; + +pub const MANIFEST_FILE: &str = "plugin.toml"; +/// The plugin API (engine) version this host implements. +pub const ENGINE_VERSION: &str = "1.0.0"; +/// The 0.1 API, still accepted for contribution-only packages and 0.1 components. +pub const LEGACY_API: &str = "0.1"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Manifest { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub authors: Vec, + pub homepage: Option, + pub license: Option, + /// Release notes of this version (Settings › Plugins › What's New). + pub change_notes: Option, + /// `engines.openidea` (a semver requirement), or the legacy `openidea-api`. + pub engine: String, + /// Written in the 0.1 format. + pub legacy: bool, + /// Component file relative to the package directory. + pub wasm: Option, + pub activation: Vec, + pub capabilities: Capabilities, + /// `contributes.` → its declarations, validated by the extension points. + pub contributes: toml::Table, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Capabilities { + pub required: Vec, + pub optional: Vec, +} + +impl Capabilities { + pub fn all(&self) -> impl Iterator { + self.required.iter().chain(&self.optional) + } + + pub fn is_optional(&self, cap: &Capability) -> bool { + self.optional.contains(cap) && !self.required.contains(cap) + } +} + +/// When a plugin's code is instantiated (plan 4.4). Contributions are live +/// without it; code only runs once one of these fires. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ActivationEvent { + /// One of the plugin's commands is run (`on-command:`; `*` = any). + Command(String), + /// A file of this language is opened. + Language(String), + /// A file matching this glob (project-relative) is opened. + File(String), + /// One of the plugin's views is shown. + View(String), + /// Periodically: `on-schedule:`. Does not keep the plugin alive. + Schedule(u32), + /// A workbench event of this kind (`on-event:file-saved`): the plugin is + /// subscribed from the start, and activated to receive it. + Event(String), + /// Shortly after the workbench is ready. + StartupFinished, +} + +impl ActivationEvent { + pub fn parse(s: &str) -> Result { + let s = s.trim(); + if s == "on-startup-finished" { + return Ok(Self::StartupFinished); + } + let (kind, arg) = s.split_once(':').ok_or_else(|| format!("unknown activation event {s:?}"))?; + let arg = arg.trim(); + if arg.is_empty() { + return Err(format!("activation event {s:?} needs a value")); + } + Ok(match kind { + "on-command" => Self::Command(arg.into()), + "on-language" => Self::Language(arg.into()), + "on-file" => Self::File(arg.into()), + "on-view" => Self::View(arg.into()), + "on-schedule" => { + let secs: u32 = + arg.trim_end_matches('s').parse().map_err(|_| format!("{s:?}: expected seconds, e.g. 300"))?; + if secs < 60 { + return Err(format!("{s:?}: the shortest schedule is 60 seconds")); + } + Self::Schedule(secs) + } + "on-event" => { + if !EVENT_KINDS.contains(&arg) { + return Err(format!("{s:?}: unknown event kind (one of {})", EVENT_KINDS.join(", "))); + } + Self::Event(arg.into()) + } + _ => return Err(format!("unknown activation event {s:?}")), + }) + } +} + +/// Workbench event kinds (`on-event:`, WIT `events.event-kind`). +/// `scheduled` comes from `on-schedule` instead. +pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"]; + +impl std::fmt::Display for ActivationEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Command(c) => write!(f, "on-command:{c}"), + Self::Language(l) => write!(f, "on-language:{l}"), + Self::File(g) => write!(f, "on-file:{g}"), + Self::View(v) => write!(f, "on-view:{v}"), + Self::Schedule(s) => write!(f, "on-schedule:{s}"), + Self::Event(k) => write!(f, "on-event:{k}"), + Self::StartupFinished => f.write_str("on-startup-finished"), + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct Raw { + id: String, + name: String, + version: String, + #[serde(default)] + description: String, + #[serde(default)] + authors: Vec, + #[serde(default)] + homepage: Option, + #[serde(default)] + license: Option, + #[serde(default)] + change_notes: Option, + #[serde(default)] + engines: Option, + /// 0.1 format. + #[serde(default)] + openidea_api: Option, + #[serde(default)] + wasm: Option, + #[serde(default)] + activation: Option>, + #[serde(default)] + capabilities: Option, + /// 0.1 format. + #[serde(default)] + permissions: Option>, + #[serde(default)] + contributes: toml::Table, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Engines { + openidea: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCapabilities { + #[serde(default)] + required: Vec, + #[serde(default)] + optional: Vec, +} + +impl Manifest { + pub fn parse(src: &str) -> Result { + let raw: Raw = toml::from_str(src).map_err(|e| e.to_string())?; + let (engine, legacy) = match (raw.engines, raw.openidea_api) { + (Some(e), None) => (e.openidea, false), + (None, Some(api)) => (api, true), + (Some(_), Some(_)) => return Err("use `engines.openidea`, not `openidea-api`".into()), + (None, None) => return Err("missing `engines.openidea` (e.g. \"^1.0\")".into()), + }; + if legacy && raw.capabilities.is_some() { + return Err("`capabilities` requires `engines.openidea`; 0.1 manifests use `permissions`".into()); + } + if !legacy && raw.permissions.is_some() { + return Err("`permissions` is the 0.1 format; use `[capabilities] required = [...]`".into()); + } + let parse_caps = |list: Vec| -> Result, String> { + let mut out: Vec = Vec::new(); + for s in list { + let cap: Capability = s.parse()?; + if !out.contains(&cap) { + out.push(cap); + } + } + Ok(out) + }; + let capabilities = match (raw.capabilities, raw.permissions) { + (Some(c), _) => Capabilities { required: parse_caps(c.required)?, optional: parse_caps(c.optional)? }, + (None, Some(p)) => Capabilities { required: parse_caps(p)?, optional: Vec::new() }, + (None, None) => Capabilities::default(), + }; + let activation = match raw.activation { + Some(list) => list.iter().map(|s| ActivationEvent::parse(s)).collect::, _>>()?, + // 0.1 plugins were activated at start-up; keep that until they move to 1.0. + None if legacy && raw.wasm.is_some() => vec![ActivationEvent::StartupFinished], + None => Vec::new(), + }; + let m = Manifest { + id: raw.id, + name: raw.name, + version: raw.version, + description: raw.description, + authors: raw.authors, + homepage: raw.homepage, + license: raw.license, + change_notes: raw.change_notes, + engine, + legacy, + wasm: raw.wasm, + activation, + capabilities, + contributes: raw.contributes, + }; + m.validate()?; + Ok(m) + } + + pub fn load(dir: &Path) -> Result { + let path = dir.join(MANIFEST_FILE); + let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + Self::parse(&src).map_err(|e| format!("{}: {e}", path.display())) + } + + fn validate(&self) -> Result<(), String> { + validate_plugin_id(&self.id)?; + if self.name.trim().is_empty() { + return Err("`name` must not be empty".into()); + } + if semver::Version::parse(&self.version).is_err() { + return Err(format!("`version` must be a semantic version like 1.2.3, got {:?}", self.version)); + } + if !engine_compatible(&self.engine, self.legacy) { + return Err(if self.legacy { + format!( + "unsupported openidea-api {:?} (this IDE implements {LEGACY_API} and {ENGINE_VERSION})", + self.engine + ) + } else { + format!("engines.openidea {:?} does not match this IDE's plugin API {ENGINE_VERSION}", self.engine) + }); + } + if let Some(wasm) = &self.wasm { + check_relative(wasm, "wasm")?; + } + if self.wasm.is_none() && !self.activation.is_empty() { + return Err("`activation` needs `wasm`: there is no code to activate".into()); + } + let mut seen = BTreeSet::new(); + for event in &self.activation { + if !seen.insert(event) { + return Err(format!("duplicate activation event {event}")); + } + } + for (point, decls) in &self.contributes { + validate_point_id(point)?; + if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) { + return Err(format!("`contributes.{point}` must be a table or an array of tables")); + } + } + Ok(()) + } + + /// The declarations of one extension point: `[[contributes.x]]` gives + /// several, `[contributes.x]` one. + pub fn declarations(&self, point: &str) -> Vec { + match self.contributes.get(point) { + Some(toml::Value::Array(items)) => items.clone(), + Some(single @ toml::Value::Table(_)) => vec![single.clone()], + _ => Vec::new(), + } + } + + /// Number of declarations per extension point (for listings before loading). + pub fn declaration_counts(&self) -> Vec<(String, u32)> { + self.contributes.keys().map(|p| (p.clone(), self.declarations(p).len() as u32)).collect() + } + + /// Publisher namespace: the id's part before the first `.` (`acme` in `acme.tools`). + pub fn publisher(&self) -> Option<&str> { + self.id.split_once('.').map(|(p, _)| p) + } + + /// Whether `cap` is declared (required or optional). + pub fn declares(&self, cap: &Capability) -> bool { + self.capabilities.all().any(|c| c.covers(cap)) + } +} + +/// Whether this host can load a plugin written for `engine`. +pub fn engine_compatible(engine: &str, legacy: bool) -> bool { + if legacy { + return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.')); + } + let Ok(req) = semver::VersionReq::parse(engine) else { return false }; + let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version"); + req.matches(&host) +} + +/// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher +/// namespace first), at most 64 chars. +pub fn validate_plugin_id(id: &str) -> Result<(), String> { + let ok = id.len() <= 64 && !id.is_empty() && id.split('.').all(segment_ok); + if ok { + Ok(()) + } else { + Err(format!( + "invalid plugin id {id:?}: use a-z, 0-9, '-', '_' in '.'-separated parts (e.g. acme.tools), max 64" + )) + } +} + +/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars (commands, themes, views…). +pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> { + let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()); + let ok_rest = id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.')); + if ok_first && ok_rest && id.len() <= 64 { + Ok(()) + } else { + Err(format!("invalid {what} {id:?}: use a-z, 0-9, '.', '-', '_', starting with a letter or digit (max 64)")) + } +} + +/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`). +pub fn validate_point_id(id: &str) -> Result<(), String> { + let ok = !id.is_empty() + && id.len() <= 64 + && id.starts_with(|c: char| c.is_ascii_lowercase()) + && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.')); + if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) } +} + +fn segment_ok(s: &str) -> bool { + s.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + +/// Package paths must stay inside the package directory. +pub fn check_relative(path: &Path, what: &str) -> Result<(), String> { + let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_))); + if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FULL: &str = r#" + id = "acme.rust-tools" + name = "Rust Tools" + version = "1.2.0" + engines.openidea = "^1.0" + wasm = "plugin.wasm" + activation = ["on-language:rust", "on-command:check", "on-schedule:300"] + + [capabilities] + required = ["project.read", "editor.write"] + optional = ["net:crates.io", "process:cargo"] + + [[contributes.commands]] + id = "check" + title = "%check.title%" + + [[contributes.menus]] + menu = "editor.context" + command = "check" + when = "editorLang == rust" + + [contributes.settings] + schema = "settings.schema.json" + "#; + + #[test] + fn event_activation() { + assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into()))); + assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed"); + let err = ActivationEvent::parse("on-event:keystroke").unwrap_err(); + assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}"); + assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule"); + } + + #[test] + fn parses_the_02_format() { + let m = Manifest::parse(FULL).unwrap(); + assert_eq!(m.publisher(), Some("acme")); + assert!(!m.legacy); + assert_eq!(m.activation.len(), 3); + assert_eq!(m.activation[2], ActivationEvent::Schedule(300)); + assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]); + assert!(m.capabilities.is_optional(&Capability::Process("cargo".into()))); + assert_eq!(m.declarations("commands").len(), 1); + assert_eq!(m.declarations("settings").len(), 1, "a single table is one declaration"); + assert!(m.declarations("nope").is_empty()); + let counts: Vec<_> = m.declaration_counts(); + assert!(counts.contains(&("menus".into(), 1))); + } + + #[test] + fn parses_the_01_format() { + let m = Manifest::parse( + "id = \"hello\"\nname = \"Hello\"\nversion = \"0.1.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n\ + permissions = [\"project-read\", \"editor-write\"]\n[[contributes.commands]]\nid = \"x\"\ntitle = \"X\"", + ) + .unwrap(); + assert!(m.legacy); + assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]); + assert_eq!(m.activation, vec![ActivationEvent::StartupFinished]); + assert_eq!(m.publisher(), None); + } + + fn err(src: &str) -> String { + Manifest::parse(src).unwrap_err() + } + + #[test] + fn rejects_invalid_manifests() { + let base = |extra: &str| format!("id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\n{extra}"); + assert!(err(&base("")).contains("engines.openidea")); + assert!(err(&base("engines.openidea = \"^0.3\"")).contains("does not match")); + assert!(err(&base("engines.openidea = \"^2\"")).contains("does not match")); + assert!(err(&base("engines.openidea = \"^0.2\"")).contains("does not match")); + assert!(err(&base("openidea-api = \"0.2\"")).contains("unsupported")); + assert!(err(&base("engines.openidea = \"^1.0\"\npermissions = [\"project-read\"]")).contains("0.1 format")); + assert!( + err(&base("engines.openidea = \"^1.0\"\n[capabilities]\nrequired = [\"network\"]")).contains("unknown") + ); + assert!(err(&base("engines.openidea = \"^1.0\"\nwasm = \"../x.wasm\"")).contains("relative path")); + assert!(err(&base("engines.openidea = \"^1.0\"\nactivation = [\"on-command:x\"]")).contains("needs `wasm`")); + assert!( + err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-boot\"]")) + .contains("unknown") + ); + assert!( + err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-schedule:5\"]")) + .contains("60 seconds") + ); + assert!(err(&base("engines.openidea = \"^1.0\"\nbogus = 1")).contains("unknown field")); + assert!(err(&base("engines.openidea = \"^1.0\"\ncontributes.commands = 3")).contains("table")); + assert!( + err("id = \"Bad\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id") + ); + assert!( + err("id = \"a..b\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id") + ); + assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version")); + } + + #[test] + fn ids() { + assert!(validate_plugin_id("openidea.material-icons").is_ok()); + assert!(validate_plugin_id("hello_2").is_ok()); + assert!(validate_plugin_id(".x").is_err()); + assert!(validate_item_id("word-count.v2_x", "command id").is_ok()); + assert!(validate_item_id("-x", "command id").is_err()); + assert!(validate_point_id("iconThemes").is_ok()); + assert!(validate_point_id("Icon").is_err()); + assert!(engine_compatible("1.0", false)); + assert!(engine_compatible(">=1.0, <2", false)); + assert!(!engine_compatible("^0.2", false), "0.x components don't run on 1.0"); + assert!(engine_compatible("0.1.3", true)); + assert!(!engine_compatible("0.10", true)); + } +} diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs new file mode 100644 index 000000000..ee8c8642e --- /dev/null +++ b/crates/ide-extension/src/package.rs @@ -0,0 +1,378 @@ +//! Plugin distribution (docs/plugin_plan.md 4.7): the package file, its +//! signature, and the static marketplace index. +//! +//! - **Package** (`-.oiplugin`): a zip of the manifest, the files +//! its declarations reference, the component and licence files, plus +//! [`SIGNATURE_FILE`] when signed. +//! - **Signature**: Ed25519 over `openidea-plugin/1\n\n\n`, +//! `digest` being [`package_digest`] of the unpacked files. A signature +//! proves who published the bytes; whether to trust that publisher is the +//! index's call. +//! - **Index** (`index.json`): the publishers and their keys (the trust root), +//! the published plugins, and revocations. A publisher owns the id prefix +//! `.`; a revoked key invalidates everything it signed, a +//! revoked plugin version is disabled where it is installed. + +use crate::MANIFEST_FILE; +use base64::Engine as _; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +pub const SIGNATURE_FILE: &str = "SIGNATURE.json"; +pub const PACKAGE_EXTENSION: &str = "oiplugin"; +pub const INDEX_FORMAT: u32 = 1; +/// Limits when unpacking an untrusted package. +const MAX_ENTRIES: usize = 10_000; +const MAX_TOTAL: u64 = 512 << 20; + +const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +/// Content digest of a package: the manifest plus `files` (relative paths). +pub fn package_digest(dir: &Path, files: &[PathBuf]) -> std::io::Result { + let mut all = files.to_vec(); + all.push(PathBuf::from(MANIFEST_FILE)); + crate::ledger::package_hash(dir, &all) +} + +fn signed_message(id: &str, version: &str, digest: &str) -> Vec { + format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes() +} + +/// `SIGNATURE.json` of a signed package. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageSignature { + pub publisher: String, + /// Ed25519 public key, base64. + pub key: String, + /// Ed25519 signature, base64. + pub signature: String, +} + +/// A publisher's key pair (the 32-byte seed, base64, in a file only the publisher has). +pub struct PublisherKey(SigningKey); + +impl PublisherKey { + pub fn from_seed(seed: [u8; 32]) -> Self { + Self(SigningKey::from_bytes(&seed)) + } + + /// The seed as written by `openidea-plugin keygen`. + pub fn from_base64(text: &str) -> Result { + let bytes = B64.decode(text.trim()).map_err(|e| format!("key file: {e}"))?; + let seed: [u8; 32] = bytes.try_into().map_err(|_| "key file: expected a 32-byte key".to_owned())?; + Ok(Self::from_seed(seed)) + } + + pub fn seed_base64(&self) -> String { + B64.encode(self.0.to_bytes()) + } + + pub fn public_base64(&self) -> String { + B64.encode(self.0.verifying_key().to_bytes()) + } + + pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature { + let sig = self.0.sign(&signed_message(id, version, digest)); + PackageSignature { + publisher: publisher.into(), + key: self.public_base64(), + signature: B64.encode(sig.to_bytes()), + } + } +} + +impl PackageSignature { + /// Whether the signature is valid for `id`, `version` and `digest` (the + /// key's validity is checked against an [`Index`]). + pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> { + let key: [u8; 32] = B64 + .decode(&self.key) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| "signature: malformed public key".to_owned())?; + let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?; + let sig: [u8; 64] = B64 + .decode(&self.signature) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| "signature: malformed".to_owned())?; + key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig)) + .map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned()) + } +} + +/// Writes the package `out` from `dir`: the manifest, `files`, and the signature if any. +pub fn pack(dir: &Path, files: &[PathBuf], out: &Path, signature: Option<&PackageSignature>) -> Result<(), String> { + let io = |e: std::io::Error| e.to_string(); + let file = std::fs::File::create(out).map_err(io)?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let mut names: Vec = std::iter::once(PathBuf::from(MANIFEST_FILE)).chain(files.iter().cloned()).collect(); + names.sort(); + names.dedup(); + for rel in names { + let name = rel.to_str().ok_or("non UTF-8 file name")?.replace('\\', "/"); + zip.start_file(name, options).map_err(|e| e.to_string())?; + let bytes = std::fs::read(dir.join(&rel)).map_err(|e| format!("{}: {e}", rel.display()))?; + zip.write_all(&bytes).map_err(io)?; + } + if let Some(sig) = signature { + zip.start_file(SIGNATURE_FILE, options).map_err(|e| e.to_string())?; + zip.write_all(serde_json::to_string_pretty(sig).map_err(|e| e.to_string())?.as_bytes()).map_err(io)?; + } + zip.finish().map_err(|e| e.to_string())?; + Ok(()) +} + +/// What [`unpack`] wrote. +#[derive(Debug)] +pub struct Unpacked { + /// Every file but the manifest and the signature, relative. + pub files: Vec, + pub signature: Option, +} + +/// `name` as a relative path that stays inside the package. +fn safe_path(name: &str) -> Option { + let p = Path::new(name); + (!name.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))).then(|| p.to_path_buf()) +} + +/// Extracts the package `archive` into the empty directory `dest`, refusing +/// paths leaving it, links, and oversized contents. +pub fn unpack(archive: &Path, dest: &Path) -> Result { + let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?; + let mut zip = + zip::ZipArchive::new(file).map_err(|e| format!("{}: not a plugin package ({e})", archive.display()))?; + if zip.len() > MAX_ENTRIES { + return Err(format!("more than {MAX_ENTRIES} files")); + } + std::fs::create_dir_all(dest).map_err(|e| e.to_string())?; + let (mut files, mut signature, mut total, mut manifest) = (Vec::new(), None, 0u64, false); + for i in 0..zip.len() { + let mut entry = zip.by_index(i).map_err(|e| e.to_string())?; + if entry.is_dir() { + continue; + } + let name = entry.name().map_err(|e| e.to_string())?.into_owned(); + if entry.is_symlink() { + return Err(format!("{name}: links are not allowed in packages")); + } + let rel = safe_path(&name).ok_or_else(|| format!("invalid path {name:?} in package"))?; + let mut bytes = Vec::new(); + entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + total += bytes.len() as u64; + if total > MAX_TOTAL { + return Err(format!("the package unpacks to more than {} MiB", MAX_TOTAL >> 20)); + } + if rel == Path::new(SIGNATURE_FILE) { + signature = Some(serde_json::from_slice(&bytes).map_err(|e| format!("{SIGNATURE_FILE}: {e}"))?); + continue; + } + let to = dest.join(&rel); + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&to, bytes).map_err(|e| e.to_string())?; + if rel == Path::new(MANIFEST_FILE) { + manifest = true; + } else { + files.push(rel); + } + } + if !manifest { + return Err(format!("the package has no {MANIFEST_FILE}")); + } + files.sort(); + Ok(Unpacked { files, signature }) +} + +// ---- index ------------------------------------------------------------------- + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Index { + pub format: u32, + #[serde(default)] + pub publishers: Vec, + #[serde(default)] + pub plugins: Vec, + #[serde(default)] + pub revoked: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Publisher { + /// Owns plugin ids `.…`. + pub id: String, + pub name: String, + pub keys: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct KeyEntry { + /// Ed25519 public key, base64. + pub key: String, + /// Packages signed with it are no longer trusted. + #[serde(default)] + pub revoked: bool, +} + +/// A published plugin version. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct IndexEntry { + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub authors: Vec, + pub publisher: String, + /// The package, relative to the index (or an absolute URL). + pub download: String, + /// [`package_digest`] of its contents. + pub digest: String, + #[serde(default)] + pub capabilities: Vec, +} + +/// A plugin (all versions when `versions` is empty) that must not run. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Revocation { + pub id: String, + #[serde(default)] + pub versions: Vec, + pub reason: String, +} + +impl Index { + pub fn parse(text: &str) -> Result { + let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?; + if index.format != INDEX_FORMAT { + return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format)); + } + Ok(index) + } + + /// The revocation that applies to `id` at `version`. + pub fn revocation(&self, id: &str, version: &str) -> Option<&Revocation> { + self.revoked.iter().find(|r| r.id == id && (r.versions.is_empty() || r.versions.iter().any(|v| v == version))) + } + + /// Whether `key` (base64) belongs to a publisher and was revoked. + pub fn key_revoked(&self, key: &str) -> bool { + self.publishers.iter().flat_map(|p| &p.keys).any(|k| k.key == key && k.revoked) + } + + /// The trusted publisher of a signed package: the signature is valid, its + /// key is one of the publisher's and not revoked, and the publisher owns + /// the plugin's id prefix. + pub fn verify(&self, sig: &PackageSignature, id: &str, version: &str, digest: &str) -> Result { + let publisher = self + .publishers + .iter() + .find(|p| p.id == sig.publisher) + .ok_or_else(|| format!("unknown publisher {:?}", sig.publisher))?; + let key = publisher + .keys + .iter() + .find(|k| k.key == sig.key) + .ok_or_else(|| format!("the key is not one of {}'s", publisher.name))?; + if key.revoked { + return Err(format!("{}'s signing key was revoked", publisher.name)); + } + if !id.starts_with(&format!("{}.", publisher.id)) { + return Err(format!("{} may only publish plugins named {}.*", publisher.name, publisher.id)); + } + sig.check(id, version, digest)?; + Ok(publisher.id.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package(dir: &Path) -> Vec { + std::fs::create_dir_all(dir.join("dist")).unwrap(); + std::fs::write(dir.join(MANIFEST_FILE), "id = \"acme.tools\"\nversion = \"1.0.0\"").unwrap(); + std::fs::write(dir.join("dist/plugin.wasm"), b"\0asm").unwrap(); + vec![PathBuf::from("dist/plugin.wasm")] + } + + fn index(key: &PublisherKey) -> Index { + Index { + format: INDEX_FORMAT, + publishers: vec![Publisher { + id: "acme".into(), + name: "ACME".into(), + keys: vec![KeyEntry { key: key.public_base64(), revoked: false }], + }], + plugins: Vec::new(), + revoked: vec![Revocation { + id: "acme.bad".into(), + versions: vec!["1.0.0".into()], + reason: "malware".into(), + }], + } + } + + #[test] + fn signed_packages_round_trip_and_tampering_is_caught() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + let files = package(&src); + let digest = package_digest(&src, &files).unwrap(); + let key = PublisherKey::from_seed([7; 32]); + let sig = key.sign("acme", "acme.tools", "1.0.0", &digest); + let out = tmp.path().join("acme.tools-1.0.0.oiplugin"); + pack(&src, &files, &out, Some(&sig)).unwrap(); + + let dest = tmp.path().join("dest"); + let unpacked = unpack(&out, &dest).unwrap(); + assert_eq!(unpacked.files, files); + assert_eq!(package_digest(&dest, &unpacked.files).unwrap(), digest); + let index = index(&key); + assert_eq!(index.verify(unpacked.signature.as_ref().unwrap(), "acme.tools", "1.0.0", &digest).unwrap(), "acme"); + + // Changed bytes, another id, someone else's prefix, a revoked key. + assert!(index.verify(&sig, "acme.tools", "1.0.0", "0000").is_err()); + assert!(index.verify(&sig, "acme.tools", "1.0.1", &digest).is_err()); + let other = PublisherKey::from_seed([9; 32]).sign("acme", "acme.tools", "1.0.0", &digest); + assert!(index.verify(&other, "acme.tools", "1.0.0", &digest).unwrap_err().contains("not one of")); + let foreign = key.sign("acme", "evil.tools", "1.0.0", &digest); + assert!(index.verify(&foreign, "evil.tools", "1.0.0", &digest).unwrap_err().contains("acme.*")); + let mut revoked = index.clone(); + revoked.publishers[0].keys[0].revoked = true; + assert!(revoked.verify(&sig, "acme.tools", "1.0.0", &digest).unwrap_err().contains("revoked")); + assert!(revoked.key_revoked(&sig.key)); + assert_eq!(index.revocation("acme.bad", "1.0.0").unwrap().reason, "malware"); + assert!(index.revocation("acme.bad", "1.0.1").is_none()); + assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64()); + } + + #[test] + fn unpacking_refuses_paths_leaving_the_package() { + let tmp = tempfile::tempdir().unwrap(); + let out = tmp.path().join("evil.oiplugin"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap()); + let o = zip::write::SimpleFileOptions::default(); + zip.start_file(MANIFEST_FILE, o).unwrap(); + zip.write_all(b"id = \"x\"").unwrap(); + zip.start_file("../outside.txt", o).unwrap(); + zip.write_all(b"x").unwrap(); + zip.finish().unwrap(); + let err = unpack(&out, &tmp.path().join("dest")).unwrap_err(); + assert!(err.contains("invalid path"), "{err}"); + assert!(!tmp.path().join("outside.txt").exists()); + assert!(Index::parse(r#"{"format": 2}"#).is_err()); + } +} diff --git a/crates/ide-extension/src/point.rs b/crates/ide-extension/src/point.rs new file mode 100644 index 000000000..7a74ac0d1 --- /dev/null +++ b/crates/ide-extension/src/point.rs @@ -0,0 +1,175 @@ +//! The extension point contract (plan 4.1). + +use crate::capability::Capability; +use crate::manifest::{Manifest, check_relative}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Where a contribution takes effect (plan 3, placement rules). +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum Placement { + /// Projected to the client (menus, keybindings, themes, icons). + Ui, + /// Takes effect on the machine the project is on (languages, LSP, run configurations). + Workspace, +} + +/// The package a declaration comes from, as extension points see it. +#[derive(Clone, Debug)] +pub struct Package { + pub manifest: Arc, + /// Package directory. + pub dir: PathBuf, + /// Hash of the package's files when they never change (an installed + /// copy): validated declarations are then reused from the + /// [`ContributionIndex`](crate::ContributionIndex). + pub digest: Option>, +} + +/// Largest single package file an extension point reads. +pub const MAX_PACKAGE_FILE: u64 = 8 << 20; + +impl Package { + pub fn new(manifest: Manifest, dir: PathBuf) -> Self { + Self { manifest: Arc::new(manifest), dir, digest: None } + } + + pub fn with_digest(mut self, digest: &str) -> Self { + self.digest = Some(digest.into()); + self + } + + pub fn id(&self) -> &str { + &self.manifest.id + } + + /// Resolves a package-relative path, refusing anything that leaves the + /// package (`..`, absolute paths, symlinks pointing outside). + pub fn resolve(&self, rel: &Path) -> Result { + check_relative(rel, "path")?; + let joined = self.dir.join(rel); + let real = joined.canonicalize().map_err(|_| format!("{}: no such file in the plugin", rel.display()))?; + let root = self.dir.canonicalize().map_err(|e| format!("plugin directory: {e}"))?; + if !real.starts_with(&root) { + return Err(format!("{}: points outside the plugin", rel.display())); + } + Ok(real) + } + + pub fn read_bytes(&self, rel: &Path) -> Result, String> { + let real = self.resolve(rel)?; + let meta = std::fs::metadata(&real).map_err(|e| format!("{}: {e}", rel.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", rel.display())); + } + if meta.len() > MAX_PACKAGE_FILE { + return Err(format!("{}: larger than {} MiB", rel.display(), MAX_PACKAGE_FILE >> 20)); + } + std::fs::read(&real).map_err(|e| format!("{}: {e}", rel.display())) + } + + pub fn read_text(&self, rel: &Path) -> Result { + String::from_utf8(self.read_bytes(rel)?).map_err(|_| format!("{}: not UTF-8 text", rel.display())) + } +} + +/// Undoes one committed contribution. Dropping without [`dispose`](Self::dispose) also disposes. +#[must_use = "dropping a Disposable undoes the contribution"] +pub struct Disposable(Option>); + +impl Disposable { + pub fn new(f: impl FnOnce() + Send + 'static) -> Self { + Self(Some(Box::new(f))) + } + + /// A contribution with nothing to undo. + pub fn noop() -> Self { + Self(None) + } + + pub fn dispose(mut self) { + if let Some(f) = self.0.take() { + f(); + } + } +} + +impl Drop for Disposable { + fn drop(&mut self) { + if let Some(f) = self.0.take() { + f(); + } + } +} + +impl std::fmt::Debug for Disposable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Disposable") + } +} + +pub type Commit = Box Result + Send>; + +/// A validated declaration, ready to take effect. +pub struct Prepared { + /// Capabilities this declaration needs (a language with an LSP → `process:`). + pub capabilities: Vec, + /// Package files the declaration references (copied on install). + pub files: Vec, + /// Identity within the point: a later declaration with the same key replaces + /// the earlier one of the same plugin (re-registering a command). + pub key: Option, + /// User-facing one-liner for the plugin details page ("Insert Date"). + pub label: String, + /// Makes it take effect. Must not fail for reasons `prepare` could have checked. + pub commit: Commit, +} + +impl Prepared { + pub fn new(label: impl Into, commit: impl FnOnce() -> Result + Send + 'static) -> Self { + Self { capabilities: Vec::new(), files: Vec::new(), key: None, label: label.into(), commit: Box::new(commit) } + } + + pub fn key(mut self, key: impl Into) -> Self { + self.key = Some(key.into()); + self + } + + pub fn file(mut self, path: impl Into) -> Self { + self.files.push(path.into()); + self + } + + pub fn capability(mut self, cap: Capability) -> Self { + if !self.capabilities.contains(&cap) { + self.capabilities.push(cap); + } + self + } +} + +impl std::fmt::Debug for Prepared { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Prepared").field("key", &self.key).field("label", &self.label).finish_non_exhaustive() + } +} + +/// A kind of contribution (`contributes.` in the manifest). Implemented by +/// the crate that owns the feature; registered once in an [`crate::ExtensionRegistry`]. +pub trait ExtensionPoint: Send + Sync { + /// `"languages"`, `"iconThemes"`, … + fn id(&self) -> &'static str; + + fn placement(&self) -> Placement; + + /// Parses and validates one declaration without side effects, and works + /// out the capabilities it needs. `Ui` points validate completely here: the + /// client's projection is not allowed to fail. + fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result; +} + +/// Deserializes a declaration with serde, prefixing errors with the point id. +pub fn decode(point: &str, decl: toml::Value) -> Result { + decl.try_into().map_err(|e: toml::de::Error| format!("contributes.{point}: {}", e.message())) +} diff --git a/crates/ide-extension/src/registry.rs b/crates/ide-extension/src/registry.rs new file mode 100644 index 000000000..172b580d7 --- /dev/null +++ b/crates/ide-extension/src/registry.rs @@ -0,0 +1,384 @@ +//! Extension points plus the contributions committed to them, accounted per plugin. + +use crate::capability::Capability; +use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared}; +use parking_lot::{Mutex, RwLock}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +/// Manifest contributions are `Static` (re-committed on every start); `Runtime` +/// ones are registered by the plugin's code and withdrawn when it stops. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + Static, + Runtime, +} + +/// A contribution as the plugin details page lists it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ContributionInfo { + pub point: String, + pub label: String, +} + +struct Entry { + point: &'static str, + key: Option, + decl: toml::Value, + label: String, + scope: Scope, + disposable: Disposable, +} + +/// Every declaration of a package, validated; nothing has taken effect yet. +pub struct PreparedSet { + pub plugin: String, + /// Union of what the declarations need (beyond the manifest's own list). + pub capabilities: Vec, + /// Package files the declarations reference. + pub files: Vec, + /// Declarations for extension points this host does not know (skipped). + pub warnings: Vec, + items: Vec<(&'static str, toml::Value, Prepared)>, +} + +impl PreparedSet { + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn len(&self) -> usize { + self.items.len() + } +} + +impl std::fmt::Debug for PreparedSet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PreparedSet") + .field("plugin", &self.plugin) + .field("items", &self.items.len()) + .field("warnings", &self.warnings) + .finish() + } +} + +#[derive(Default)] +pub struct ExtensionRegistry { + points: RwLock>>, + /// plugin id → its contributions in commit order. + entries: Mutex>>, +} + +impl ExtensionRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Adds an extension point. Ids are unique. + pub fn register(&self, point: Arc) -> Result<(), String> { + let id = point.id(); + crate::manifest::validate_point_id(id)?; + let mut points = self.points.write(); + if points.contains_key(id) { + return Err(format!("extension point {id:?} is already registered")); + } + points.insert(id, point); + Ok(()) + } + + pub fn point_ids(&self) -> Vec<&'static str> { + self.points.read().keys().copied().collect() + } + + pub fn placement(&self, point: &str) -> Option { + self.points.read().get(point).map(|p| p.placement()) + } + + fn point(&self, id: &str) -> Option> { + self.points.read().get(id).cloned() + } + + /// Validates every `contributes.` declaration of `pkg`. Fails as a + /// whole on the first invalid declaration; unknown points are skipped with + /// a warning (forward compatibility). + pub fn prepare(&self, pkg: &Package) -> Result { + let mut set = PreparedSet { + plugin: pkg.id().to_owned(), + capabilities: Vec::new(), + files: Vec::new(), + warnings: Vec::new(), + items: Vec::new(), + }; + for point_id in pkg.manifest.contributes.keys() { + let Some(point) = self.point(point_id) else { + let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped"); + tracing::warn!(plugin = %pkg.id(), "{msg}"); + set.warnings.push(msg); + continue; + }; + for (i, decl) in pkg.manifest.declarations(point_id).into_iter().enumerate() { + let prepared = point + .prepare(pkg, decl.clone()) + .map_err(|e| format!("contributes.{point_id}[{i}]: {}", strip_prefix(&e, point_id)))?; + for cap in &prepared.capabilities { + if !set.capabilities.contains(cap) { + set.capabilities.push(cap.clone()); + } + } + for f in &prepared.files { + if !set.files.contains(f) { + set.files.push(f.clone()); + } + } + set.items.push((point.id(), decl, prepared)); + } + } + Ok(set) + } + + /// Makes a prepared set take effect, all or nothing: if one commit fails, + /// the ones before it are disposed in reverse order. + pub fn commit(&self, set: PreparedSet, scope: Scope) -> Result<(), String> { + let mut committed: Vec = Vec::with_capacity(set.items.len()); + for (point, decl, prepared) in set.items { + let Prepared { key, label, commit, .. } = prepared; + match commit() { + Ok(disposable) => committed.push(Entry { point, key, decl, label, scope, disposable }), + Err(e) => { + tracing::error!(plugin = %set.plugin, "contributes.{point}: commit failed: {e}"); + while let Some(entry) = committed.pop() { + entry.disposable.dispose(); + } + return Err(format!("contributes.{point}: {e}")); + } + } + } + let replaced = { + let mut entries = self.entries.lock(); + let list = entries.entry(set.plugin).or_default(); + let mut replaced = Vec::new(); + for new in &committed { + if let Some(key) = &new.key + && let Some(pos) = list.iter().position(|e| e.point == new.point && e.key.as_ref() == Some(key)) + { + replaced.push(list.remove(pos)); + } + } + list.extend(committed); + replaced + }; + for old in replaced.into_iter().rev() { + old.disposable.dispose(); + } + Ok(()) + } + + /// Registers one declaration at runtime (a plugin's `register-command`). + /// Returns `false` when an identical contribution with the same key exists. + pub fn contribute(&self, pkg: &Package, point_id: &str, decl: toml::Value) -> Result { + let point = self.point(point_id).ok_or_else(|| format!("unknown extension point {point_id:?}"))?; + let prepared = point.prepare(pkg, decl.clone())?; + if let Some(key) = &prepared.key { + let entries = self.entries.lock(); + let same = entries.get(pkg.id()).is_some_and(|list| { + list.iter().any(|e| e.point == point.id() && e.key.as_ref() == Some(key) && e.decl == decl) + }); + if same { + return Ok(false); + } + } + let set = PreparedSet { + plugin: pkg.id().to_owned(), + capabilities: prepared.capabilities.clone(), + files: Vec::new(), + warnings: Vec::new(), + items: vec![(point.id(), decl, prepared)], + }; + self.commit(set, Scope::Runtime)?; + Ok(true) + } + + /// Withdraws everything `plugin` contributed, newest first. + pub fn dispose_plugin(&self, plugin: &str) -> bool { + let list = self.entries.lock().remove(plugin).unwrap_or_default(); + let any = !list.is_empty(); + for entry in list.into_iter().rev() { + entry.disposable.dispose(); + } + any + } + + /// Withdraws what `plugin`'s code registered, keeping its manifest contributions. + pub fn dispose_runtime(&self, plugin: &str) -> bool { + let removed: Vec = { + let mut entries = self.entries.lock(); + let Some(list) = entries.get_mut(plugin) else { return false }; + let (runtime, keep): (Vec, Vec) = list.drain(..).partition(|e| e.scope == Scope::Runtime); + *list = keep; + runtime + }; + let any = !removed.is_empty(); + for entry in removed.into_iter().rev() { + entry.disposable.dispose(); + } + any + } + + pub fn contributions(&self, plugin: &str) -> Vec { + self.entries + .lock() + .get(plugin) + .map(|list| { + list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect() + }) + .unwrap_or_default() + } + + /// How many contributions `plugin` has at `point`. + pub fn count(&self, plugin: &str, point: &str) -> usize { + self.entries.lock().get(plugin).map_or(0, |list| list.iter().filter(|e| e.point == point).count()) + } + + /// Whether `plugin` has a contribution with `key` at `point`. + pub fn has_key(&self, plugin: &str, point: &str, key: &str) -> bool { + self.entries + .lock() + .get(plugin) + .is_some_and(|list| list.iter().any(|e| e.point == point && e.key.as_deref() == Some(key))) + } + + /// Plugins with at least one contribution. + pub fn plugins(&self) -> Vec { + self.entries.lock().iter().filter(|(_, l)| !l.is_empty()).map(|(id, _)| id.clone()).collect() + } +} + +fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str { + e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::Manifest; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Records live items in a shared list; `fail = true` makes commit fail. + struct Recording { + id: &'static str, + live: Arc>>, + commits: Arc, + } + + impl ExtensionPoint for Recording { + fn id(&self) -> &'static str { + self.id + } + fn placement(&self) -> Placement { + Placement::Ui + } + fn prepare(&self, _pkg: &Package, decl: toml::Value) -> Result { + let name = decl.get("name").and_then(|v| v.as_str()).ok_or("missing name")?.to_owned(); + let fail = decl.get("fail").and_then(|v| v.as_bool()).unwrap_or(false); + let (live, commits) = (self.live.clone(), self.commits.clone()); + let label = name.clone(); + let mut p = Prepared::new(label, move || { + commits.fetch_add(1, Ordering::SeqCst); + if fail { + return Err("boom".into()); + } + live.lock().push(name.clone()); + Ok(Disposable::new(move || live.lock().retain(|n| *n != name))) + }); + if let Some(f) = decl.get("file").and_then(|v| v.as_str()) { + p = p.file(f); + } + if decl.get("key").and_then(|v| v.as_bool()).unwrap_or(false) { + p = p.key("k"); + } + Ok(p) + } + } + + fn setup() -> (ExtensionRegistry, Arc>>) { + let reg = ExtensionRegistry::new(); + let live = Arc::new(Mutex::new(Vec::new())); + let commits = Arc::new(AtomicUsize::new(0)); + reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: commits.clone() })).unwrap(); + reg.register(Arc::new(Recording { id: "others", live: live.clone(), commits })).unwrap(); + (reg, live) + } + + fn pkg(contributes: &str) -> Package { + let src = format!("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n{contributes}"); + Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent")) + } + + #[test] + fn commits_and_disposes_per_plugin() { + let (reg, live) = setup(); + assert!( + reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: Default::default() })) + .is_err() + ); + let p = pkg( + "[[contributes.things]]\nname = \"a\"\nfile = \"x.json\"\n[[contributes.things]]\nname = \"b\"\n[contributes.others]\nname = \"c\"", + ); + let set = reg.prepare(&p).unwrap(); + assert_eq!(set.len(), 3); + assert_eq!(set.files, [PathBuf::from("x.json")]); + assert!(live.lock().is_empty(), "prepare has no side effects"); + reg.commit(set, Scope::Static).unwrap(); + assert_eq!(*live.lock(), ["c", "a", "b"]); + assert_eq!(reg.count("p", "things"), 2); + assert_eq!(reg.contributions("p").len(), 3); + assert!(reg.dispose_plugin("p")); + assert!(live.lock().is_empty()); + } + + #[test] + fn a_failed_commit_rolls_back() { + let (reg, live) = setup(); + let p = pkg("[[contributes.things]]\nname = \"a\"\n[[contributes.things]]\nname = \"b\"\nfail = true"); + let err = reg.commit(reg.prepare(&p).unwrap(), Scope::Static).unwrap_err(); + assert!(err.contains("boom")); + assert!(live.lock().is_empty(), "a was undone"); + assert!(reg.contributions("p").is_empty()); + } + + #[test] + fn invalid_declarations_fail_prepare_and_unknown_points_warn() { + let (reg, _) = setup(); + let err = reg.prepare(&pkg("[[contributes.things]]\nnope = 1")).unwrap_err(); + assert_eq!(err, "contributes.things[0]: missing name"); + let set = + reg.prepare(&pkg("[[contributes.futureThing]]\nx = 1\n[[contributes.things]]\nname = \"a\"")).unwrap(); + assert_eq!(set.len(), 1); + assert_eq!(set.warnings.len(), 1); + assert!(set.warnings[0].contains("futureThing")); + } + + #[test] + fn runtime_contributions_replace_by_key_and_dispose_separately() { + let (reg, live) = setup(); + let p = pkg("[[contributes.things]]\nname = \"static\""); + reg.commit(reg.prepare(&p).unwrap(), Scope::Static).unwrap(); + let decl = |name: &str| { + let mut t = toml::Table::new(); + t.insert("name".into(), name.into()); + t.insert("key".into(), true.into()); + toml::Value::Table(t) + }; + assert!(reg.contribute(&p, "things", decl("r1")).unwrap()); + assert!(!reg.contribute(&p, "things", decl("r1")).unwrap(), "identical: no change"); + assert!(reg.contribute(&p, "things", decl("r2")).unwrap()); + assert_eq!(*live.lock(), ["static", "r2"], "same key replaced r1"); + assert!(reg.contribute(&p, "nope", decl("x")).is_err()); + assert!(reg.dispose_runtime("p")); + assert_eq!(*live.lock(), ["static"]); + } +} diff --git a/crates/ide-extension/src/ui.rs b/crates/ide-extension/src/ui.rs new file mode 100644 index 000000000..e12bfceec --- /dev/null +++ b/crates/ide-extension/src/ui.rs @@ -0,0 +1,247 @@ +//! UI contributions: validated declarations projected to the client. +//! +//! A `Ui` extension point validates a declaration completely in Rust and +//! commits it as JSON into the [`UiStore`]; the client receives the store's +//! snapshot and projects each item onto an existing `register*` function +//! (`defineUiPoint` in `src/features/plugins/projection.ts`). Large payloads +//! (icon SVGs) go into the store's assets and are fetched on demand. + +use crate::index::ContributionIndex; +use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// One projected declaration. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UiContribution { + pub plugin_id: String, + /// Extension point id (`commands`, `iconThemes`, …). + pub point: String, + /// Stable within (plugin, point): the client keys its registrations on it. + pub key: String, + /// The validated declaration, shaped by the point. + #[specta(type = specta_typescript::Unknown)] + pub decl: serde_json::Value, +} + +struct Item { + seq: u64, + contribution: UiContribution, +} + +/// Committed UI contributions of all plugins. +#[derive(Default)] +pub struct UiStore { + items: RwLock>, + /// (plugin, asset id) → content. + assets: RwLock>>, + seq: AtomicU64, + revision: AtomicU64, + index: RwLock>>, +} + +impl UiStore { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Adds an item; the returned disposable removes it. + pub fn insert(self: &Arc, contribution: UiContribution) -> Disposable { + let seq = self.seq.fetch_add(1, Ordering::Relaxed); + self.items.write().push(Item { seq, contribution }); + self.revision.fetch_add(1, Ordering::Release); + let store = Arc::downgrade(self); + Disposable::new(move || { + if let Some(store) = store.upgrade() { + store.items.write().retain(|i| i.seq != seq); + store.revision.fetch_add(1, Ordering::Release); + } + }) + } + + /// Stores assets of a plugin (ids are unique per plugin; later ones replace). + pub fn insert_assets(self: &Arc, plugin: &str, assets: Vec<(String, String)>) -> Disposable { + let keys: Vec<(String, String)> = assets.iter().map(|(id, _)| (plugin.to_owned(), id.clone())).collect(); + { + let mut map = self.assets.write(); + for (id, content) in assets { + map.insert((plugin.to_owned(), id), Arc::from(content)); + } + } + let store = Arc::downgrade(self); + Disposable::new(move || { + if let Some(store) = store.upgrade() { + let mut map = store.assets.write(); + for k in keys { + map.remove(&k); + } + } + }) + } + + pub fn asset(&self, plugin: &str, id: &str) -> Option> { + self.assets.read().get(&(plugin.to_owned(), id.to_owned())).cloned() + } + + /// Assets of `plugin` whose id starts with `prefix`. + pub fn assets_with_prefix(&self, plugin: &str, prefix: &str) -> Vec<(String, Arc)> { + self.assets + .read() + .range((plugin.to_owned(), prefix.to_owned())..) + .take_while(|((p, id), _)| p == plugin && id.starts_with(prefix)) + .map(|((_, id), v)| (id.clone(), v.clone())) + .collect() + } + + /// Where [`UiPoint`]s keep validated declarations of installed packages. + pub fn set_index(&self, index: Option>) { + *self.index.write() = index; + } + + /// Bumped on every change: clients compare it to skip re-projection. + pub fn revision(&self) -> u64 { + self.revision.load(Ordering::Acquire) + } + + pub fn snapshot(&self) -> Vec { + self.items.read().iter().map(|i| i.contribution.clone()).collect() + } + + /// Items of one point, optionally of one plugin. + pub fn items(&self, point: &str, plugin: Option<&str>) -> Vec { + self.items + .read() + .iter() + .filter(|i| i.contribution.point == point && plugin.is_none_or(|p| i.contribution.plugin_id == p)) + .map(|i| i.contribution.clone()) + .collect() + } +} + +/// What a [`UiPoint`]'s validator produces for one declaration. +#[derive(Debug, Serialize, Deserialize)] +pub struct UiDecl { + pub key: String, + pub label: String, + pub json: serde_json::Value, + /// Package files referenced (copied on install). + pub files: Vec, + /// Large validated payloads fetched separately by the client. + pub assets: Vec<(String, String)>, +} + +impl UiDecl { + pub fn new(key: impl Into, label: impl Into, json: serde_json::Value) -> Self { + Self { key: key.into(), label: label.into(), json, files: Vec::new(), assets: Vec::new() } + } +} + +type Validate = dyn Fn(&Package, toml::Value) -> Result + Send + Sync; + +/// A `Ui` extension point defined by a validator: committing stores the +/// validated JSON (and assets) in the [`UiStore`]. Most UI contribution types +/// (menus, keybindings, status bar items, icon themes) are one of these. +pub struct UiPoint { + id: &'static str, + store: Arc, + validate: Box, +} + +impl UiPoint { + pub fn new( + id: &'static str, + store: Arc, + validate: impl Fn(&Package, toml::Value) -> Result + Send + Sync + 'static, + ) -> Self { + Self { id, store, validate: Box::new(validate) } + } +} + +impl ExtensionPoint for UiPoint { + fn id(&self) -> &'static str { + self.id + } + + fn placement(&self) -> Placement { + Placement::Ui + } + + fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result { + let index = pkg.digest.as_ref().and_then(|_| self.store.index.read().clone()); + let ui = match index.as_ref().and_then(|i| i.get(pkg, self.id, &decl)) { + Some(ui) => ui, + None => match &index { + Some(i) => { + let ui = (self.validate)(pkg, decl.clone())?; + i.put(pkg, self.id, &decl, &ui); + ui + } + None => (self.validate)(pkg, decl)?, + }, + }; + let UiDecl { key, label, json, files, assets } = ui; + let store = self.store.clone(); + let contribution = + UiContribution { plugin_id: pkg.id().to_owned(), point: self.id.to_owned(), key: key.clone(), decl: json }; + let plugin = pkg.id().to_owned(); + let mut prepared = Prepared::new(label, move || { + let item = store.insert(contribution); + if assets.is_empty() { + return Ok(item); + } + let assets = store.insert_assets(&plugin, assets); + Ok(Disposable::new(move || { + item.dispose(); + assets.dispose(); + })) + }) + .key(key); + prepared.files = files; + Ok(prepared) + } +} + +/// TOML declaration → JSON for the client. +pub fn to_json(v: &toml::Value) -> serde_json::Value { + serde_json::to_value(v).unwrap_or(serde_json::Value::Null) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::Manifest; + use crate::registry::{ExtensionRegistry, Scope}; + + #[test] + fn ui_point_projects_into_the_store() { + let store = UiStore::new(); + let reg = ExtensionRegistry::new(); + reg.register(Arc::new(UiPoint::new("badges", store.clone(), |_pkg, decl| { + let text = decl.get("text").and_then(|v| v.as_str()).ok_or("missing text")?.to_owned(); + let mut d = UiDecl::new(text.clone(), text.clone(), serde_json::json!({ "text": text })); + d.assets.push((format!("badge/{text}"), "".into())); + Ok(d) + }))) + .unwrap(); + let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.badges]]\ntext = \"hi\""; + let pkg = Package::new(Manifest::parse(src).unwrap(), PathBuf::from("/x")); + let rev = store.revision(); + reg.commit(reg.prepare(&pkg).unwrap(), Scope::Static).unwrap(); + assert!(store.revision() > rev); + let items = store.items("badges", Some("p")); + assert_eq!(items.len(), 1); + assert_eq!(items[0].decl["text"], "hi"); + assert_eq!(store.asset("p", "badge/hi").as_deref(), Some("")); + assert_eq!(store.assets_with_prefix("p", "badge/").len(), 1); + reg.dispose_plugin("p"); + assert!(store.snapshot().is_empty()); + assert!(store.asset("p", "badge/hi").is_none()); + } +} diff --git a/crates/ide-extension/src/when.rs b/crates/ide-extension/src/when.rs new file mode 100644 index 000000000..98cd3c931 --- /dev/null +++ b/crates/ide-extension/src/when.rs @@ -0,0 +1,136 @@ +//! Syntax check for `when` clauses in UI declarations. The client evaluates +//! them (`src/platform/context.ts`); validating here keeps the client's +//! projection from failing. Grammar (same as the client): +//! +//! ```text +//! expr := or ; or := and ("||" and)* ; and := unary ("&&" unary)* +//! unary := "!" unary | "(" expr ")" | cmp ; cmp := key (("==" | "!=") value)? +//! ``` + +const MAX_LEN: usize = 512; + +#[derive(Debug, PartialEq)] +enum Tok<'a> { + Id(&'a str), + Str, + Op(&'a str), +} + +fn tokenize(src: &str) -> Result>, String> { + let mut out = Vec::new(); + let b = src.as_bytes(); + let mut i = 0; + while i < b.len() { + let c = b[i] as char; + if c.is_whitespace() { + i += 1; + } else if ["&&", "||", "==", "!="].iter().any(|op| src[i..].starts_with(op)) { + out.push(Tok::Op(&src[i..i + 2])); + i += 2; + } else if matches!(c, '!' | '(' | ')') { + out.push(Tok::Op(&src[i..i + 1])); + i += 1; + } else if c == '\'' || c == '"' { + let end = src[i + 1..].find(c).ok_or_else(|| format!("unterminated string in when clause: {src}"))?; + out.push(Tok::Str); + i += end + 2; + } else { + let len = src[i..] + .find(|ch: char| !(ch.is_alphanumeric() || matches!(ch, '_' | '.' | '-' | ':' | '/'))) + .unwrap_or(src.len() - i); + if len == 0 { + return Err(format!("unexpected '{c}' in when clause: {src}")); + } + out.push(Tok::Id(&src[i..i + len])); + i += len; + } + } + Ok(out) +} + +/// Checks a `when` clause; empty means "always". +pub fn validate_when(src: &str) -> Result<(), String> { + if src.len() > MAX_LEN { + return Err(format!("when clause longer than {MAX_LEN} bytes")); + } + if src.trim().is_empty() { + return Ok(()); + } + let toks = tokenize(src)?; + let mut p = 0; + parse_or(&toks, &mut p, src)?; + if p != toks.len() { + return Err(format!("unexpected token in when clause: {src}")); + } + Ok(()) +} + +fn parse_or(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> { + parse_and(t, p, src)?; + while t.get(*p) == Some(&Tok::Op("||")) { + *p += 1; + parse_and(t, p, src)?; + } + Ok(()) +} + +fn parse_and(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> { + parse_unary(t, p, src)?; + while t.get(*p) == Some(&Tok::Op("&&")) { + *p += 1; + parse_unary(t, p, src)?; + } + Ok(()) +} + +fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> { + match t.get(*p) { + Some(Tok::Op("!")) => { + *p += 1; + parse_unary(t, p, src) + } + Some(Tok::Op("(")) => { + *p += 1; + parse_or(t, p, src)?; + if t.get(*p) != Some(&Tok::Op(")")) { + return Err(format!("missing ')' in when clause: {src}")); + } + *p += 1; + Ok(()) + } + Some(Tok::Id(_)) => { + *p += 1; + if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) { + *p += 1; + match t.get(*p) { + Some(Tok::Id(_) | Tok::Str) => *p += 1, + _ => return Err(format!("missing value after comparison in when clause: {src}")), + } + } + Ok(()) + } + _ => Err(format!("expected a context key in when clause: {src}")), + } +} + +#[cfg(test)] +mod tests { + use super::validate_when; + + #[test] + fn accepts_the_client_grammar() { + for ok in [ + "", + "editorFocus", + "editorLang == rust", + "editorLang != 'rust' && !readOnly", + "(a || b) && toolWindow.project.active", + "resourceScheme == file:/x", + ] { + assert!(validate_when(ok).is_ok(), "{ok}"); + } + for bad in ["a &&", "(a", "a == ", "== a", "a b", "a $ b", "'x", "!()"] { + assert!(validate_when(bad).is_err(), "{bad}"); + } + } +} diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 6f2c1ce54..47095ccb2 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -30,15 +30,23 @@ ide-terminal.workspace = true ide-run.workspace = true ide-sdk.workspace = true ide-http.workspace = true +reqwest.workspace = true +ignore.workspace = true +base64.workspace = true +getrandom = "0.3" +globset.workspace = true ide-db.workspace = true ide-ssh.workspace = true ide-remote.workspace = true ide-jsonrpc.workspace = true ide-agent.workspace = true ide-notebook.workspace = true +ide-extension.workspace = true +ide-plugin.workspace = true ide-tools = { path = "../ide-tools" } serde.workspace = true serde_json.workspace = true +toml.workspace = true specta.workspace = true specta-typescript.workspace = true tokio = { workspace = true, features = ["io-std"] } @@ -53,9 +61,14 @@ tempfile.workspace = true [dev-dependencies] ide-ssh = { workspace = true, features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } +criterion = { version = "0.5", default-features = false } [target.'cfg(unix)'.dependencies] rustix = { version = "1.1", features = ["fs", "process"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + +[[bench]] +name = "plugins" +harness = false diff --git a/crates/ide-host/benches/plugins.rs b/crates/ide-host/benches/plugins.rs new file mode 100644 index 000000000..491fc974a --- /dev/null +++ b/crates/ide-host/benches/plugins.rs @@ -0,0 +1,147 @@ +//! `cargo bench -p ide-host`: the plugin runtime budgets of +//! docs/plugin_plan.md 4.5, checked by `pnpm perf:rust` against +//! `perf/budgets.json` › rust (`plugins/…`). +//! +//! - `load_50`: start-up load of 50 installed plugins (20 with code, 10 +//! languages with a syntax provider, 10 icon themes, 10 declarations only), +//! from the contribution index; no Wasm is instantiated. +//! - `cold_activation`: an idle-reclaimed plugin's next command: activation +//! from the precompiled component plus the call. +//! - `syntax_highlights_2k_lines`: an edit-path call (Solidity highlighting). + +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use ide_core::LanguageId; +use ide_host::plugins::{Consent, PluginHost}; +use ide_plugin::{Invocation, Limits}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +fn repo() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap() +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for e in std::fs::read_dir(from).unwrap().flatten() { + let name = e.file_name(); + if matches!(name.to_str(), Some("target" | "src" | "Cargo.lock" | ".gitignore")) { + continue; + } + let dst = to.join(&name); + if e.path().is_dir() { copy_dir(&e.path(), &dst) } else { std::fs::copy(e.path(), &dst).map(|_| ()).unwrap() } + } +} + +/// A copy of `src` installed under a new id (and new language / theme ids). +fn variant(work: &Path, src: &Path, n: usize, rename: &[(&str, String)]) -> PathBuf { + let dir = work.join(format!("pkg{n}")); + copy_dir(src, &dir); + for file in ["plugin.toml", "languages/solidity.toml"] { + let path = dir.join(file); + if let Ok(mut text) = std::fs::read_to_string(&path) { + for (from, to) in rename { + text = text.replace(from, to); + } + std::fs::write(&path, text).unwrap(); + } + } + dir +} + +fn sink() -> Arc { + Arc::new(|_: Option<&str>, _| {}) +} + +fn install_50(plugins: &Path, work: &Path) { + let host = PluginHost::new(plugins.to_path_buf(), sink(), Limits::default()).unwrap(); + host.load_all(); + let root = repo(); + let mut n = 0; + let mut install = |src: PathBuf, rename: Vec<(&str, String)>| { + let pkg = variant(work, &src, n, &rename); + n += 1; + host.install_from_dir(&pkg, &Consent::default()).unwrap(); + }; + for i in 0..20 { + install(root.join("plugins/hello"), vec![("id = \"hello\"", format!("id = \"bench.hello{i}\""))]); + } + for i in 0..10 { + install( + root.join("plugins/official/solidity"), + vec![ + ("openidea.solidity", format!("bench.solidity{i}")), + ("id = \"solidity\"", format!("id = \"solidity{i}\"")), + ("extensions = [\"sol\"]", format!("extensions = [\"sol{i}\"]")), + ("on-language:solidity", format!("on-language:solidity{i}")), + ("id = \"forge-", format!("id = \"f{i}-")), + ("id = \"hardhat-", format!("id = \"h{i}-")), + ], + ); + } + for i in 0..10 { + install( + root.join("plugins/official/material-icons"), + vec![ + ("openidea.material-icons", format!("bench.icons{i}")), + ("id = \"material\"", format!("id = \"material{i}\"")), + ], + ); + } + for i in 0..10 { + let dir = work.join(format!("static{i}")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("plugin.toml"), + format!( + "id = \"bench.static{i}\"\nname = \"Static {i}\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.commands]]\nid = \"a\"\ntitle = \"Static {i}: A\"\nwhen = \"editorLang == rust\"\n" + ), + ) + .unwrap(); + host.install_from_dir(&dir, &Consent::default()).unwrap(); + } +} + +fn bench(c: &mut Criterion) { + let tmp = tempfile::tempdir().unwrap(); + let plugins = tmp.path().join("plugins"); + install_50(&plugins, &tmp.path().join("work")); + + let mut g = c.benchmark_group("plugins"); + g.sample_size(20); + g.bench_function("load_50", |b| { + b.iter(|| { + let host = PluginHost::new(plugins.clone(), sink(), Limits::default()).unwrap(); + host.load_all(); + assert_eq!(host.instances(), 0, "start-up instantiates no Wasm"); + black_box(host.list(None).plugins.len()) + }) + }); + + let limits = Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() }; + let host = Arc::new(PluginHost::new(plugins.clone(), sink(), limits).unwrap()); + host.load_all(); + host.warm_component_cache(); + g.bench_function("cold_activation", |b| { + b.iter(|| { + host.tick(); + // Fails for want of an editor, after activating and running. + let r = host.run_command("bench.hello0", "word-count", None, Invocation::default()); + black_box(r.is_err()) + }) + }); + + let provider = host.syntax_provider(); + let lang = LanguageId::new("solidity0"); + let unit = "contract Token {\n uint256 public total;\n mapping(address => uint256) balances;\n event Sent(address to, uint256 v);\n function send(address to, uint256 v) public returns (bool) {\n // move funds\n balances[to] += v;\n emit Sent(to, v);\n return true;\n }\n}\n"; + let text = unit.repeat(2000 / unit.lines().count()); + assert!(provider.highlights(&lang, &text).is_some()); + g.bench_function("syntax_highlights_2k_lines", |b| { + b.iter(|| black_box(provider.highlights(&lang, &text).map(|t| t.len()))) + }); + g.finish(); +} + +criterion_group!(benches, bench); +criterion_main!(benches); diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs new file mode 100644 index 000000000..d55fb96ed --- /dev/null +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -0,0 +1,633 @@ +//! `openidea-plugin`: scaffolds, builds, checks and links OpenIDEA plugins. +//! +//! ```text +//! openidea-plugin new [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH] +//! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check` +//! openidea-plugin check [dir] # validate the package as the IDE would install it +//! openidea-plugin link [dir] # load the folder in the IDE without installing it +//! openidea-plugin unlink [dir] +//! openidea-plugin links # list linked folders +//! openidea-plugin keygen # a publisher's Ed25519 key +//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # -.oiplugin +//! openidea-plugin index --publisher ID --name NAME --key F # marketplace index.json +//! ``` +//! +//! `link` writes `links.json` in the IDE's plugins directory +//! (`$OPENIDEA_PLUGINS_DIR`, or the app data directory); the IDE picks the +//! change up when Settings › Plugins is opened or refreshed. Linked plugins +//! are re-read from the folder, so `build` + refresh is the edit loop. + +mod templates; + +use ide_extension::{Manifest, validate_plugin_id}; +use ide_host::plugins::PluginHost; +use ide_plugin::{Limits, PluginEngine}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::sync::Arc; + +const USAGE: &str = "\ +usage: openidea-plugin [options] + +commands: + new [--template T] [--id ID] [--name NAME] [--sdk PATH] + create a plugin; T = command (default), theme, icon-theme, language + build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it + check [dir] validate the package as the IDE would install it + link [dir] load the folder in the IDE without installing it + unlink [dir] stop loading a linked folder + links list linked folders + keygen create a publisher signing key (keep the file secret) + pack [dir] [--key F --publisher ID] [--out FILE] + write -.oiplugin, signed with the key + index --publisher ID --name NAME --key F [--out FILE] + write the marketplace index.json for the packages in dir + +environment: + OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let result = match args.first().map(String::as_str) { + Some("new") => cmd_new(&args[1..]), + Some("build") => cmd_build(&args[1..]), + Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)), + Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)), + Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)), + Some("links") => list_links(), + Some("keygen") => cmd_keygen(&args[1..]), + Some("pack") => cmd_pack(&args[1..]), + Some("index") => cmd_index(&args[1..]), + Some("--version" | "-V") => { + println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION); + Ok(()) + } + Some("help" | "--help" | "-h") | None => { + println!("{USAGE}"); + Ok(()) + } + Some(other) => Err(format!("unknown command {other:?}\n\n{USAGE}")), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + +/// Splits `args` into positionals and `--flag value` / `--switch` options. +struct Args { + positional: Vec, + options: Vec<(String, Option)>, +} + +impl Args { + fn parse(args: &[String], switches: &[&str], valued: &[&str]) -> Result { + let mut out = Args { positional: Vec::new(), options: Vec::new() }; + let mut it = args.iter(); + while let Some(a) = it.next() { + if let Some(name) = a.strip_prefix("--") { + let (name, inline) = match name.split_once('=') { + Some((n, v)) => (n, Some(v.to_owned())), + None => (name, None), + }; + if switches.contains(&name) && inline.is_none() { + out.options.push((name.into(), None)); + } else if valued.contains(&name) { + let v = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("--{name} needs a value"))?; + out.options.push((name.into(), Some(v))); + } else { + return Err(format!("unknown option --{name}")); + } + } else { + out.positional.push(a.clone()); + } + } + Ok(out) + } + + fn value(&self, name: &str) -> Option<&str> { + self.options.iter().rev().find(|(n, _)| n == name).and_then(|(_, v)| v.as_deref()) + } + + fn has(&self, name: &str) -> bool { + self.options.iter().any(|(n, _)| n == name) + } + + fn dir(&self) -> Result { + match self.positional.as_slice() { + [] => Ok(PathBuf::from(".")), + [d] => Ok(PathBuf::from(d)), + _ => Err("expected at most one directory".into()), + } + } +} + +fn dir_arg(args: &[String]) -> Result { + Args::parse(args, &[], &[])?.dir() +} + +fn canonical(dir: &Path) -> Result { + dunce::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display())) +} + +// ---------------------------------------------------------------- new + +fn cmd_new(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &["template", "id", "name", "sdk"])?; + let [dir] = a.positional.as_slice() else { + return Err("usage: openidea-plugin new [--template T] [--id ID] [--name NAME]".into()); + }; + let dir = PathBuf::from(dir); + let template = templates::Template::parse(a.value("template").unwrap_or("command"))?; + let base = dir.file_name().and_then(|n| n.to_str()).ok_or("the directory needs a name")?; + let id = match a.value("id") { + Some(id) => id.to_owned(), + None => slugify(base), + }; + validate_plugin_id(&id)?; + let name = match a.value("name") { + Some(n) => n.to_owned(), + None => title_case(id.rsplit('.').next().unwrap_or(&id)), + }; + if name.trim().is_empty() || name.contains(['"', '\\', '\n']) { + return Err("--name must not be empty or contain quotes, backslashes or newlines".into()); + } + if dir.exists() && std::fs::read_dir(&dir).map_err(|e| e.to_string())?.next().is_some() { + return Err(format!("{} already exists and is not empty", dir.display())); + } + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let dir = canonical(&dir)?; + let sdk = match template { + templates::Template::Command => Some(sdk_path(a.value("sdk"), &dir)?), + _ => None, + }; + let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() }; + let files = templates::render(template, &vars); + for (rel, content) in &files { + let path = dir.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + std::fs::write(&path, content).map_err(|e| format!("{}: {e}", path.display()))?; + } + // Generated packages must be valid as written. + Manifest::load(&dir).map_err(|e| format!("internal error: the template is invalid: {e}"))?; + println!("created {} ({id}) in {}", template.label(), dir.display()); + let shown = std::env::current_dir().ok().and_then(|cwd| dir.strip_prefix(cwd).ok().map(Path::to_path_buf)); + let shown = shown.as_deref().unwrap_or(&dir).display().to_string(); + println!(); + println!("next:"); + if template == templates::Template::Command { + println!(" cargo test --manifest-path {shown}/Cargo.toml # unit tests against the mock host"); + println!(" openidea-plugin build {shown}"); + } else { + println!(" openidea-plugin check {shown}"); + } + println!(" openidea-plugin link {shown} # then Settings › Plugins"); + Ok(()) +} + +/// `My Cool_Plugin` → `my-cool-plugin`. +fn slugify(s: &str) -> String { + let mut out = String::new(); + for c in s.chars() { + match c { + 'a'..='z' | '0'..='9' | '.' => out.push(c), + 'A'..='Z' => out.push(c.to_ascii_lowercase()), + _ if !out.ends_with('-') && !out.is_empty() => out.push('-'), + _ => {} + } + } + out.trim_matches(['-', '.']).to_owned() +} + +/// `my-cool_plugin` → `My Cool Plugin`. +fn title_case(s: &str) -> String { + s.split(['-', '_']) + .filter(|w| !w.is_empty()) + .map(|w| { + let mut c = w.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect::()).unwrap_or_default() + }) + .collect::>() + .join(" ") +} + +/// The SDK dependency path written into a new plugin's Cargo.toml: `--sdk`, +/// or the SDK of the source tree this tool was built from. +fn sdk_path(explicit: Option<&str>, plugin_dir: &Path) -> Result { + let sdk = match explicit { + Some(p) => PathBuf::from(p), + None => PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin")), + }; + let sdk = dunce::canonicalize(&sdk).map_err(|_| { + format!("the plugin SDK was not found at {}; pass --sdk ", sdk.display()) + })?; + if !sdk.join("Cargo.toml").is_file() { + return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display())); + } + let path = relative(plugin_dir, &sdk).unwrap_or(sdk); + Ok(path.to_string_lossy().replace('\\', "/")) +} + +/// `to` relative to `from` when they share a root (keeps packages in a repo movable). +fn relative(from: &Path, to: &Path) -> Option { + let (f, t): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect()); + let common = f.iter().zip(&t).take_while(|(a, b)| a == b).count(); + if common <= 1 { + return None; + } + let mut out = PathBuf::new(); + for _ in common..f.len() { + out.push(".."); + } + for c in &t[common..] { + out.push(c); + } + Some(out) +} + +// ---------------------------------------------------------------- build / check + +fn cmd_build(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &["debug"], &[])?; + let dir = canonical(&a.dir()?)?; + let manifest = Manifest::load(&dir)?; + let cargo_toml = dir.join("Cargo.toml"); + if !cargo_toml.is_file() { + if manifest.wasm.is_some() { + return Err(format!( + "{} has no Cargo.toml: build the component with your toolchain, then run `check`", + dir.display() + )); + } + println!("{} has no code to build", manifest.id); + return check(&dir); + } + let wasm_rel = manifest.wasm.clone().ok_or("plugin.toml has no `wasm = \"dist/plugin.wasm\"`")?; + let crate_name = crate_name(&cargo_toml)?; + ensure_wasm_target(); + let release = !a.has("debug"); + let target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| dir.join("target")); + let mut cmd = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())); + cmd.arg("build").arg("--manifest-path").arg(&cargo_toml).args(["--target", "wasm32-wasip2"]); + if release { + cmd.arg("--release"); + } + cmd.env("CARGO_TARGET_DIR", &target_dir); + println!("building {crate_name} for wasm32-wasip2 ({})", if release { "release" } else { "debug" }); + let status = cmd.status().map_err(|e| format!("cannot run cargo: {e}"))?; + if !status.success() { + return Err("cargo build failed".into()); + } + let built = target_dir + .join("wasm32-wasip2") + .join(if release { "release" } else { "debug" }) + .join(format!("{}.wasm", crate_name.replace('-', "_"))); + if !built.is_file() { + return Err(format!("{} was not produced: is `crate-type = [\"cdylib\"]` set in Cargo.toml?", built.display())); + } + let out = dir.join(&wasm_rel); + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + std::fs::copy(&built, &out).map_err(|e| format!("{}: {e}", out.display()))?; + let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0); + println!(" -> {} ({})", wasm_rel.display(), human_size(size)); + check(&dir) +} + +fn crate_name(cargo_toml: &Path) -> Result { + let src = std::fs::read_to_string(cargo_toml).map_err(|e| format!("{}: {e}", cargo_toml.display()))?; + let doc: toml::Table = src.parse().map_err(|e| format!("{}: {e}", cargo_toml.display()))?; + let package = doc.get("package").and_then(|p| p.as_table()); + let lib_name = doc.get("lib").and_then(|l| l.get("name")).and_then(|n| n.as_str()); + lib_name + .or_else(|| package.and_then(|p| p.get("name")).and_then(|n| n.as_str())) + .map(str::to_owned) + .ok_or_else(|| format!("{}: no [package] name", cargo_toml.display())) +} + +/// Installs the target through rustup when it is missing (no-op without rustup). +fn ensure_wasm_target() { + let Ok(out) = Command::new("rustup").args(["target", "list", "--installed"]).output() else { return }; + if String::from_utf8_lossy(&out.stdout).lines().any(|l| l.trim() == "wasm32-wasip2") { + return; + } + println!("installing the wasm32-wasip2 target"); + let _ = Command::new("rustup").args(["target", "add", "wasm32-wasip2"]).status(); +} + +/// Validates `dir` exactly as Install from Disk would, and checks the +/// component imports only the plugin API. +fn check(dir: &Path) -> Result<(), String> { + let dir = canonical(dir)?; + let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id())); + let result = (|| { + let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + .map_err(|e| e.to_string())?; + let preview = host.preview_install(&dir).map_err(|e| e.to_string())?; + let manifest = Manifest::load(&dir)?; + println!("{} {} — {}", preview.id, preview.version, preview.name); + if manifest.legacy { + println!(" note: plugin.toml uses the 0.1 format (`openidea-api`); see plugins/README.md to move to 0.2"); + } + if let Some(wasm) = &manifest.wasm { + let engine = PluginEngine::new()?; + let component = engine.compile(&dir.join(wasm))?; + let foreign: Vec = engine + .imports(&component) + .into_iter() + .filter(|i| !(i.starts_with("openidea:plugin/") || i.starts_with("wasi:"))) + .collect(); + if !foreign.is_empty() { + return Err(format!( + "the component imports interfaces the IDE does not provide: {}", + foreign.join(", ") + )); + } + let size = std::fs::metadata(dir.join(wasm)).map(|m| m.len()).unwrap_or(0); + println!(" code: {} ({})", wasm.display(), human_size(size)); + } else { + println!(" code: none (contributions only)"); + } + if !manifest.activation.is_empty() { + let events: Vec = manifest.activation.iter().map(ToString::to_string).collect(); + println!(" activation: {}", events.join(", ")); + } + for c in &preview.contributions { + println!(" contributes {} × {}", c.count, c.point); + } + for c in &preview.capabilities { + let kind = if c.optional { "optional" } else { "required" }; + println!(" capability {} ({kind}, {:?} risk): {}", c.id, c.risk, c.description); + } + for w in &preview.warnings { + println!(" warning: {w}"); + } + println!("ok"); + Ok(()) + })(); + let _ = std::fs::remove_dir_all(&scratch); + result +} + +fn human_size(bytes: u64) -> String { + match bytes { + b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / (1 << 20) as f64), + b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0), + b => format!("{b} B"), + } +} + +// ---------------------------------------------------------------- link + +fn plugins_dir() -> Result { + if let Some(dir) = std::env::var_os("OPENIDEA_PLUGINS_DIR") { + return Ok(PathBuf::from(dir)); + } + dirs::data_dir() + .map(|d| d.join("ai.l0ng.openidea").join("plugins")) + .ok_or_else(|| "cannot find the app data directory; set OPENIDEA_PLUGINS_DIR".into()) +} + +#[derive(Default, serde::Serialize, serde::Deserialize)] +struct Links { + #[serde(default)] + paths: Vec, +} + +fn read_links(file: &Path) -> Result { + match std::fs::read_to_string(file) { + Ok(s) => serde_json::from_str(&s).map_err(|e| format!("{}: {e}", file.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Links::default()), + Err(e) => Err(format!("{}: {e}", file.display())), + } +} + +fn link(dir: &Path, add: bool) -> Result<(), String> { + let plugins = plugins_dir()?; + let file = plugins.join("links.json"); + let mut links = read_links(&file)?; + let dir = if add { + let dir = canonical(dir)?; + let m = Manifest::load(&dir)?; + if let Some(wasm) = &m.wasm + && !dir.join(wasm).is_file() + { + println!("note: {} does not exist yet; run `openidea-plugin build` first", wasm.display()); + } + if links.paths.contains(&dir) { + println!("{} is already linked", dir.display()); + return Ok(()); + } + println!("linked {} ({})", m.id, dir.display()); + links.paths.push(dir.clone()); + dir + } else { + let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + let before = links.paths.len(); + links.paths.retain(|p| p != &dir); + if links.paths.len() == before { + return Err(format!("{} is not linked", dir.display())); + } + println!("unlinked {}", dir.display()); + dir + }; + let _ = dir; + std::fs::create_dir_all(&plugins).map_err(|e| format!("{}: {e}", plugins.display()))?; + let json = serde_json::to_string_pretty(&links).map_err(|e| e.to_string())?; + let tmp = file.with_extension("json.tmp"); + std::fs::write(&tmp, json) + .and_then(|()| std::fs::rename(&tmp, &file)) + .map_err(|e| format!("{}: {e}", file.display()))?; + println!("open Settings › Plugins in OpenIDEA to load the change"); + Ok(()) +} + +fn list_links() -> Result<(), String> { + let links = read_links(&plugins_dir()?.join("links.json"))?; + if links.paths.is_empty() { + println!("no linked plugins"); + } + for p in links.paths { + let id = Manifest::load(&p).map(|m| m.id).unwrap_or_else(|e| format!("invalid: {e}")); + println!("{id}\t{}", p.display()); + } + Ok(()) +} + +// ---------------------------------------------------------------- distribution + +use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PublisherKey}; + +fn read_key(path: &str) -> Result { + PublisherKey::from_base64(&std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?) +} + +fn cmd_keygen(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &[])?; + let [file] = a.positional.as_slice() else { return Err("usage: openidea-plugin keygen ".into()) }; + if Path::new(file).exists() { + return Err(format!("{file} exists; not overwriting a key")); + } + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|e| format!("random: {e}"))?; + let key = PublisherKey::from_seed(seed); + std::fs::write(file, key.seed_base64()).map_err(|e| format!("{file}: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600)); + } + println!("public key: {}", key.public_base64()); + println!("register it with your publisher id in the marketplace index; keep {file} secret"); + Ok(()) +} + +/// A scratch host to list a package's files as the IDE would. +fn with_scratch_host(f: impl FnOnce(&PluginHost) -> Result) -> Result { + let scratch = std::env::temp_dir().join(format!("openidea-plugin-pack-{}", std::process::id())); + let result = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + .map_err(|e| e.to_string()) + .and_then(|h| f(&h)); + let _ = std::fs::remove_dir_all(&scratch); + result +} + +fn cmd_pack(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &["key", "publisher", "out"])?; + let dir = canonical(&a.dir()?)?; + check(&dir)?; + let manifest = Manifest::load(&dir)?; + let mut files = with_scratch_host(|h| h.package_files_of(&dir).map_err(|e| e.to_string()))?; + let digest = package::package_digest(&dir, &files).map_err(|e| e.to_string())?; + let signature = match (a.value("key"), a.value("publisher")) { + (Some(key), Some(publisher)) => { + if !manifest.id.starts_with(&format!("{publisher}.")) { + return Err(format!("publisher {publisher:?} may only sign plugins named {publisher}.*")); + } + Some(read_key(key)?.sign(publisher, &manifest.id, &manifest.version, &digest)) + } + (None, None) => None, + _ => return Err("--key and --publisher go together".into()), + }; + for lic in ["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "COPYING", "README.md"] { + if dir.join(lic).is_file() { + files.push(PathBuf::from(lic)); + } + } + let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| { + PathBuf::from(format!("{}-{}.{}", manifest.id, manifest.version, package::PACKAGE_EXTENSION)) + }); + package::pack(&dir, &files, &out, signature.as_ref())?; + let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0); + let signed = signature.as_ref().map_or("unsigned".to_owned(), |s| format!("signed by {}", s.publisher)); + println!("-> {} ({}, {signed})", out.display(), human_size(size)); + Ok(()) +} + +fn cmd_index(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?; + let dir = canonical(&a.dir()?)?; + let publisher = a.value("publisher").ok_or("--publisher is required")?; + let name = a.value("name").unwrap_or(publisher); + let key = read_key(a.value("key").ok_or("--key is required")?)?; + let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json")); + // Revocations and other publishers of an existing index are kept. + let mut index = match std::fs::read_to_string(&out) { + Ok(text) => Index::parse(&text)?, + Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() }, + }; + // Earlier keys of the publisher stay listed (packages they signed remain valid until revoked). + let current = key.public_base64(); + let earlier: Vec = index + .publishers + .iter() + .filter(|p| p.id == publisher) + .flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned()) + .collect(); + index.publishers.retain(|p| p.id != publisher); + let keys: Vec = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect(); + index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys }); + index.plugins.clear(); + let mut packages: Vec = std::fs::read_dir(&dir) + .map_err(|e| e.to_string())? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION)) + .collect(); + packages.sort(); + for path in packages { + let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&scratch); + let unpacked = package::unpack(&path, &scratch)?; + let entry = (|| { + let m = Manifest::load(&scratch)?; + let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?; + let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?; + let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?; + sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?; + Ok::<_, String>(IndexEntry { + id: m.id.clone(), + name: m.name.clone(), + version: m.version.clone(), + description: m.description.clone(), + authors: m.authors.clone(), + publisher: sig.publisher.clone(), + download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + digest, + capabilities: m.capabilities.all().map(|c| c.to_string()).collect(), + }) + })(); + let _ = std::fs::remove_dir_all(&scratch); + let entry = entry?; + println!(" {} {}", entry.id, entry.version); + index.plugins.push(entry); + } + let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?; + std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?; + println!("-> {} ({} plugins)", out.display(), index.plugins.len()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names() { + assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin"); + assert_eq!(slugify("acme.Tools"), "acme.tools"); + assert_eq!(title_case("material-icons"), "Material Icons"); + assert_eq!( + relative(Path::new("/a/b/plugins/x"), Path::new("/a/b/sdk/rust/p")).unwrap(), + PathBuf::from("../../sdk/rust/p") + ); + assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None); + } + + #[test] + fn every_template_is_a_valid_package() { + for t in templates::Template::ALL { + let dir = + std::env::temp_dir().join(format!("openidea-plugin-template-{}-{}", t.label(), std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("../sdk") }; + for (rel, content) in templates::render(t, &vars) { + let path = dir.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + let m = Manifest::load(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label())); + assert_eq!(m.id, "acme.sample"); + if m.wasm.is_none() { + check(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label())); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + } +} diff --git a/crates/ide-host/src/bin/openidea-plugin/templates.rs b/crates/ide-host/src/bin/openidea-plugin/templates.rs new file mode 100644 index 000000000..2f9b504fe --- /dev/null +++ b/crates/ide-host/src/bin/openidea-plugin/templates.rs @@ -0,0 +1,278 @@ +//! The files of `openidea-plugin new`. Placeholders: `__ID__` (plugin id), +//! `__SLUG__` (last id segment), `__NAME__`, `__CRATE__`, `__SDK__`. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Template { + Command, + Theme, + IconTheme, + Language, +} + +impl Template { + pub const ALL: [Template; 4] = [Self::Command, Self::Theme, Self::IconTheme, Self::Language]; + + pub fn parse(s: &str) -> Result { + Self::ALL + .into_iter() + .find(|t| t.label() == s) + .ok_or_else(|| format!("unknown template {s:?} (command, theme, icon-theme, language)")) + } + + pub fn label(self) -> &'static str { + match self { + Self::Command => "command", + Self::Theme => "theme", + Self::IconTheme => "icon-theme", + Self::Language => "language", + } + } +} + +pub struct Vars<'a> { + pub id: &'a str, + pub name: &'a str, + pub sdk: Option<&'a str>, +} + +/// `(relative path, contents)` of every file of the template. +pub fn render(template: Template, vars: &Vars) -> Vec<(String, String)> { + let slug = vars.id.rsplit('.').next().unwrap_or(vars.id).to_owned(); + let crate_name = format!("openidea-plugin-{}", vars.id.replace('.', "-")); + let fill = |s: &str| { + s.replace("__ID__", vars.id) + .replace("__SLUG__", &slug) + .replace("__NAME__", vars.name) + .replace("__CRATE__", &crate_name) + .replace("__SDK__", vars.sdk.unwrap_or("")) + // The theme files come from templates/theme-plugin. + .replace("harbor-night", &slug) + .replace("Harbor Night", vars.name) + }; + let files: Vec<(String, &str)> = match template { + Template::Command => vec![ + ("plugin.toml".into(), COMMAND_MANIFEST), + ("Cargo.toml".into(), COMMAND_CARGO), + ("src/lib.rs".into(), COMMAND_LIB), + (".gitignore".into(), "/target\n"), + ("README.md".into(), COMMAND_README), + ], + Template::Theme => vec![ + ("plugin.toml".into(), THEME_MANIFEST), + (format!("themes/{slug}.theme.json"), THEME_UI), + (format!("themes/{slug}.scheme.json"), THEME_SCHEME), + ], + Template::IconTheme => vec![ + ("plugin.toml".into(), ICON_MANIFEST), + ("icons/theme.json".into(), ICON_THEME), + ("icons/file.svg".into(), ICON_FILE), + ("icons/folder.svg".into(), ICON_FOLDER), + ("icons/folder-open.svg".into(), ICON_FOLDER_OPEN), + ("icons/markdown.svg".into(), ICON_MARKDOWN), + ], + Template::Language => { + vec![("plugin.toml".into(), LANGUAGE_MANIFEST), (format!("languages/{slug}.toml"), LANGUAGE_DEF)] + } + }; + files.into_iter().map(|(path, content)| (path, fill(content))).collect() +} + +const COMMAND_MANIFEST: &str = r#"id = "__ID__" +name = "__NAME__" +version = "0.1.0" +description = "Adds two sample commands." +authors = [] +engines.openidea = "^1.0" +# Built by `openidea-plugin build`. The code is loaded the first time one of +# its commands runs; the commands below show in Find Action before that. +wasm = "dist/plugin.wasm" + +# What the code may do. The user approves these when installing. +[capabilities] +required = ["editor.read"] + +[[contributes.commands]] +id = "say-hello" +title = "__NAME__: Say Hello" +category = "__NAME__" + +[[contributes.commands]] +id = "line-count" +title = "__NAME__: Line Count of Active File" +category = "__NAME__" +"#; + +const COMMAND_CARGO: &str = r#"[package] +name = "__CRATE__" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "__SDK__" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" +"#; + +const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin. + +use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin}; + +#[derive(Default)] +pub struct Main { + greetings: u32, +} + +impl Plugin for Main { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result, String> { + match id { + "say-hello" => { + // Instance state lasts until the plugin is idle for a while; + // keep what must survive in `host::storage`. + self.greetings += 1; + let body = format!("Hello #{} from __NAME__!", self.greetings); + host::notify(NotificationLevel::Info, "__NAME__", &body); + Ok(None) + } + "line-count" => { + let editor = ctx.editor().ok_or("no file is open")?; + let text = editor.document()?.text; + Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path()))) + } + _ => Err(format!("unknown command {id}")), + } + } +} + +register_plugin!(Main); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + #[test] + fn says_hello() { + let host = MockHost::install(); + let mut plugin = Main::default(); + plugin.run_command("say-hello", &host.invocation(), None).unwrap(); + plugin.run_command("say-hello", &host.invocation(), None).unwrap(); + assert_eq!(host.notifications()[1].body, "Hello #2 from __NAME__!"); + } + + #[test] + fn counts_lines() { + let host = MockHost::install(); + host.open_editor("/project/src/main.rs", "rust", "fn main() {\n}\n", &[]); + let out = Main::default().run_command("line-count", &host.invocation(), None).unwrap(); + assert_eq!(out.as_deref(), Some("2 lines in /project/src/main.rs")); + } +} +"#; + +const COMMAND_README: &str = r#"# __NAME__ + +An OpenIDEA plugin. + +```sh +cargo test # logic, against the SDK's mock host +openidea-plugin build # → dist/plugin.wasm, validated like an install +openidea-plugin link # load this folder in the IDE (Settings › Plugins) +``` + +`plugin.toml` declares the commands, so they appear in Find Action before the +code is loaded, and the capabilities the code needs. `src/lib.rs` implements +them with the `openidea-plugin` SDK. +"#; + +const THEME_MANIFEST: &str = r#"id = "__ID__" +name = "__NAME__" +version = "0.1.0" +description = "A dark theme." +authors = [] +engines.openidea = "^1.0" + +# One entry per theme. `editor-scheme` is optional: its tokens are merged over +# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md. +[[contributes.themes]] +path = "themes/__SLUG__.theme.json" +editor-scheme = "themes/__SLUG__.scheme.json" +"#; + +const THEME_UI: &str = include_str!("../../../../../templates/theme-plugin/themes/harbor-night.theme.json"); +const THEME_SCHEME: &str = include_str!("../../../../../templates/theme-plugin/themes/harbor-night.scheme.json"); + +const ICON_MANIFEST: &str = r#"id = "__ID__" +name = "__NAME__" +version = "0.1.0" +description = "File and folder icons." +authors = [] +engines.openidea = "^1.0" + +# Apply it in Settings › Plugins › __NAME__. SVGs are sanitised on install: +# no scripts, event handlers, external references or entities. +[[contributes.iconThemes]] +id = "__SLUG__" +label = "__NAME__" +path = "icons/theme.json" +"#; + +const ICON_THEME: &str = r#"{ + "iconDefinitions": { + "file": "icons/file.svg", + "folder": "icons/folder.svg", + "folder-open": "icons/folder-open.svg", + "markdown": "icons/markdown.svg" + }, + "file": "file", + "folder": "folder", + "folderExpanded": "folder-open", + "fileExtensions": { "md": "markdown", "markdown": "markdown" }, + "fileNames": { "readme": "markdown" }, + "folderNames": {} +} +"#; + +const ICON_FILE: &str = r##" +"##; + +const ICON_FOLDER: &str = r##" +"##; + +const ICON_FOLDER_OPEN: &str = r##" +"##; + +const ICON_MARKDOWN: &str = r##" +"##; + +const LANGUAGE_MANIFEST: &str = r#"id = "__ID__" +name = "__NAME__" +version = "0.1.0" +description = "Language support for .__SLUG__ files: detection, comments and indentation." +authors = [] +engines.openidea = "^1.0" + +# Same format as languages/.toml in the IDE repository. +[[contributes.languages]] +path = "languages/__SLUG__.toml" +"#; + +const LANGUAGE_DEF: &str = r##"id = "__SLUG__" +name = "__NAME__" +extensions = ["__SLUG__"] +line-comment = "#" +indent = { tab-size = 2, insert-spaces = true } +# Borrow an existing CodeMirror mode for instant highlighting. +ui-mode = "Properties files" +"##; diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs index ada210d86..6a1f25785 100644 --- a/crates/ide-host/src/bin/server.rs +++ b/crates/ide-host/src/bin/server.rs @@ -1,6 +1,8 @@ //! `openidea-server --stdio`: the IDE backend for remote development, serving //! one desktop client over stdin / stdout (started by the client over SSH). +use ide_host::plugins::remote::{RemotePluginLanguages, SYNC_COMMAND}; +use ide_host::plugins::remote_host::RemotePlugins; use ide_host::rpc::{self, Dispatcher}; use ide_host::{Host, HostConfig}; use ide_language::LanguageRegistry; @@ -51,8 +53,12 @@ async fn run(base: PathBuf) -> i32 { return 1; } }; + // The desktop's plugin languages (`plugin_sync_languages`); programs pinned on this machine. + let plugin_languages = RemotePluginLanguages::new(Some(&data_dir)); + languages.attach_plugins(plugin_languages.store().clone()); let peer = Arc::new(OnceLock::new()); let sink_peer = peer.clone(); + let plugins_data = data_dir.clone(); let host = Arc::new(Host::new(HostConfig { config_dir, local_data_dir: data_dir.clone(), @@ -64,8 +70,31 @@ async fn run(base: PathBuf) -> i32 { runtime: tokio::runtime::Handle::current(), project_environment: true, })); + // The desktop's code plugins run here, next to the project (`plugin_sync_packages`). + let plugins = match RemotePlugins::start(&plugins_data, &host, peer.clone()) { + Ok(p) => Some(p), + Err(e) => { + tracing::warn!("plugins are unavailable: {e}"); + None + } + }; tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION")); - rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await; + let mut dispatcher = Dispatcher::standard(); + if let Some(plugins) = &plugins { + plugins.register(&mut dispatcher); + } + dispatcher.add(SYNC_COMMAND, move |c| { + let remote = plugin_languages.clone(); + async move { + let report = remote.sync(c.arg("languages")?); + c.host.plugin_languages_changed(); + Ok(report) + } + }); + rpc::serve(host.clone(), dispatcher, peer, tokio::io::stdin(), tokio::io::stdout()).await; + if let Some(plugins) = &plugins { + plugins.host().shutdown(); + } // `process::exit` skips destructors: nothing started for the client may outlive it. if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() { tracing::warn!("language servers did not shut down in time"); diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 898b2884c..edaafb43b 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -1,6 +1,6 @@ //! The IDE backend without a UI toolkit: open projects per window, terminals, //! processes, settings and the remote services. The desktop shell (`src-tauri`) -//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server` +//! wraps one [`Host`] and adds windows and menus; `openidea-server` //! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]). pub mod data_dirs; @@ -10,6 +10,7 @@ mod java_runtime; mod kotlin_bridge; pub mod notebook; pub mod ops; +pub mod plugins; mod project; mod proxy; mod requests; @@ -133,6 +134,17 @@ impl Host { self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone() } + /// Plugin languages or the servers they may start changed (they are part + /// of [`Self::languages`] and every project's fork): open documents are + /// detected again and language servers follow their new configuration. + pub fn plugin_languages_changed(&self) { + for project in self.projects() { + project.documents.redetect_languages(); + let lsp = project.lsp.clone(); + self.runtime.spawn(async move { lsp.reconfigure().await }); + } + } + /// Every open project (all windows). pub fn projects(&self) -> Vec> { self.projects.read().values().cloned().collect() diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index f84ab3c73..984466712 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -1,9 +1,11 @@ use super::doc::blocking; use crate::Host; use crate::apply_workspace_edit as apply_edit; +use crate::plugins::{ProviderKind, editor_providers_for as editor_providers}; use crate::rpc::Dispatcher; use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit}; use ide_lsp::types::*; +use ide_plugin::ProviderDocument; async fn prepare_actions( project: std::sync::Arc, @@ -38,9 +40,87 @@ pub async fn lsp_completion( ) -> IdeResult { let lsp = host.project(window)?.lsp.clone(); let cancel = host.begin_request(request); - let result = lsp.completion(doc, pos, trigger, cancel).await; + let result = lsp.completion(doc, pos, trigger.clone(), cancel).await; host.end_request(request); - result + // Plugin providers (`[[contributes.providers]]`) add theirs. + let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result }; + let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default(); + if items.is_empty() { + return result; + } + let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() }); + list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item))); + Ok(list) +} + +/// The plugin host and the document, when some plugin provides `kind`. +fn plugin_providers( + host: &Host, + window: &str, + doc: DocId, + kind: ProviderKind, +) -> Option<(std::sync::Arc, ProviderDocument)> { + let plugins = editor_providers(kind)?; + let snap = host.project(window).ok()?.documents.snapshot(doc).ok()?; + let d = ProviderDocument { + path: snap.path.0, + language: snap.language.0, + version: u64::from(snap.version), + text: snap.text, + }; + Some((plugins, d)) +} + +/// Plugin calls block (the plugin's actor thread does the work). +async fn plugin_call(f: impl FnOnce() -> T + Send + 'static) -> Option { + tokio::task::spawn_blocking(f).await.ok() +} + +/// Items from plugins carry no server (resolve and commands don't apply). +const PLUGIN_SERVER: ServerId = ServerId(0); + +fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem { + let kind = item.kind.as_deref().and_then(|k| { + Some(match k { + "text" => CompletionKind::Text, + "method" => CompletionKind::Method, + "function" => CompletionKind::Function, + "constructor" => CompletionKind::Constructor, + "field" => CompletionKind::Field, + "variable" => CompletionKind::Variable, + "class" => CompletionKind::Class, + "interface" => CompletionKind::Interface, + "module" => CompletionKind::Module, + "property" => CompletionKind::Property, + "keyword" => CompletionKind::Keyword, + "snippet" => CompletionKind::Snippet, + "value" => CompletionKind::Value, + "constant" => CompletionKind::Constant, + "file" => CompletionKind::File, + "reference" => CompletionKind::Reference, + _ => return None, + }) + }); + CompletionItem { + label: item.label, + label_detail: None, + detail: item.detail, + kind, + documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }), + sort_text: item.sort_text, + filter_text: None, + preselect: false, + deprecated: false, + insert_text: item.insert_text, + is_snippet: false, + replace_range: item.replace, + replace_range_tab: None, + additional_edits: Vec::new(), + commit_characters: Vec::new(), + command: None, + data: None, + server: PLUGIN_SERVER, + } } /// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing. @@ -54,9 +134,26 @@ pub async fn lsp_inline_completion( ) -> IdeResult> { let lsp = host.project(window)?.lsp.clone(); let cancel = host.begin_request(request); - let result = lsp.inline_completion(doc, pos, automatic, cancel).await; + let result = lsp.inline_completion(doc, pos, automatic, cancel.clone()).await; + // Plugins: cancelled (cooperatively) when the editor cancels the request. + let plugin = match plugin_providers(host, window, doc, ProviderKind::InlineCompletion) { + Some((plugins, d)) if !matches!(&result, Ok(items) if !items.is_empty()) => { + let cancel = cancel.clone(); + plugin_call(move || plugins.provide_inline_completion(&d, pos, &cancel)).await.flatten() + } + _ => None, + }; host.end_request(request); - result + match plugin { + Some((_, s)) => Ok(vec![InlineCompletionItem { + insert_text: s.insert_text, + range: s.replace, + filter_text: None, + command: None, + server: PLUGIN_SERVER, + }]), + None => result, + } } pub async fn lsp_resolve_completion( @@ -87,7 +184,40 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) } pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { - host.project(window)?.lsp.goto(doc, pos, kind).await + let result = host.project(window)?.lsp.goto(doc, pos, kind).await; + if kind != GotoKind::Definition { + return result; + } + let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Definition) else { return result }; + let found = plugin_call(move || plugins.provide_definition(&d, pos)).await.unwrap_or_default(); + if found.is_empty() { + return result; + } + let mut out = result.unwrap_or_default(); + out.extend(found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None })); + Ok(out) +} + +/// Gutter markers from plugins (`line-markers` providers) for `doc`. +pub async fn plugin_line_markers( + host: &Host, + window: &str, + doc: DocId, +) -> IdeResult> { + let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::LineMarkers) else { + return Ok(Vec::new()); + }; + let markers = plugin_call(move || plugins.provide_line_markers(&d)).await.unwrap_or_default(); + Ok(markers + .into_iter() + .map(|(plugin_id, m)| crate::plugins::LineMarkerInfo { + plugin_id, + line: m.line, + icon: m.icon, + tooltip: m.tooltip, + target: m.target.map(|l| Location { path: l.path.into(), range: l.range, uri: None }), + }) + .collect()) } /// Implementations of the symbols at `positions`, one list per position (the @@ -165,10 +295,44 @@ pub async fn lsp_code_actions( let project = host.project(window)?; let documents = project.documents.clone(); let version = blocking(move || documents.version(doc)).await?; - let actions = project.lsp.code_actions(doc, range, only).await?; + let result = project.lsp.code_actions(doc, range, only.clone()).await; + let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) { + Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(), + None => Vec::new(), + }; + let plugin_actions: Vec = plugin_actions + .into_iter() + .map(|(_, a)| plugin_code_action(a)) + .filter(|a| { + only.is_empty() + || a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}.")))) + }) + .collect(); + let actions = if plugin_actions.is_empty() { + result? + } else { + let mut out = result.unwrap_or_default(); + out.extend(plugin_actions); + out + }; prepare_actions(project, doc, version, actions).await } +fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction { + let files = + a.edits.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None }).collect(); + CodeAction { + title: a.title, + kind: a.kind, + is_preferred: a.preferred, + disabled_reason: None, + edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }), + command: None, + data: None, + server: PLUGIN_SERVER, + } +} + /// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). pub async fn lsp_code_action_query( host: &Host, diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs new file mode 100644 index 000000000..da79e228c --- /dev/null +++ b/crates/ide-host/src/plugins/api.rs @@ -0,0 +1,790 @@ +//! [`HostApi`] for one plugin's code: what its imports reach in the IDE. +//! Capabilities were checked by the runtime before any of this runs. + +use super::{COMMANDS, MAX_COMMANDS, PluginEvent, PluginEventSink}; +use ide_core::{CancellationToken, TextEdit}; +use ide_extension::{ExtensionRegistry, Package}; +use ide_language::ProgramPin; +use ide_plugin::{ + EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus, + StatusItem, +}; +use parking_lot::{Mutex, RwLock}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +/// Largest storage of one plugin (keys + values). +const MAX_STORAGE: usize = 1 << 20; +const MAX_KEY: usize = 256; +/// Most status bar items per plugin. +const MAX_STATUS_ITEMS: usize = 8; + +/// What plugins see of the open projects; provided by the app +/// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop. +pub trait PluginWorkspace: Send + Sync { + /// Roots of the open projects. + fn projects(&self) -> Vec; + /// Unsaved text of the open document at `path`. + fn buffer_text(&self, path: &Path) -> Option; + /// Version and text of the open document at `path`. + fn document(&self, path: &Path) -> Option<(u64, String)>; + /// Applies `edits` to the open document at `path` if it is still at + /// `version`, as one change (one undo step in the editor). + fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result; + /// Code from the project may run (git hooks and config on fetch). + fn trusted(&self, root: &Path) -> bool; + /// Root of the project open in `window`. + fn project_of(&self, window: &str) -> Option; + /// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones). + fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec) + -> Result<(), HostError>; + /// The user's values of `plugin`'s settings: the project's at `root` + /// (over global), or global. + fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map { + serde_json::Map::new() + } +} + +/// Where plugin secrets live (the OS keychain on the desktop). Plugins can +/// only ask whether one is set; values go out only inside `net` requests. +pub trait SecretStore: Send + Sync { + fn get(&self, plugin: &str, name: &str) -> Option; + /// Sets, or (`None`) deletes. + fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String>; +} + +/// Fallback [`SecretStore`]: one file per plugin, readable only by the user. +pub struct FileSecretStore { + pub dir: PathBuf, +} + +impl FileSecretStore { + fn file(&self, plugin: &str) -> PathBuf { + self.dir.join(plugin).join("secrets.json") + } + + fn load(&self, plugin: &str) -> BTreeMap { + std::fs::read(self.file(plugin)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + } +} + +impl SecretStore for FileSecretStore { + fn get(&self, plugin: &str, name: &str) -> Option { + self.load(plugin).remove(name) + } + + fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> { + let mut map = self.load(plugin); + match value { + Some(v) => map.insert(name.to_owned(), v.to_owned()), + None => map.remove(name), + }; + let file = self.file(plugin); + std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?; + let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?; + let tmp = file.with_extension("tmp"); + std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)); + } + std::fs::rename(&tmp, &file).map_err(|e| e.to_string()) + } +} + +/// Largest response body a plugin receives. +const MAX_RESPONSE: usize = 8 << 20; +/// Largest stdout / stderr kept from a process. +const MAX_OUTPUT: usize = 4 << 20; +/// Most files `find-files` walks before giving up. +const MAX_WALK: usize = 200_000; + +/// Runtime for plugin HTTP requests (actor threads block on it). +pub(super) fn net_runtime() -> &'static tokio::runtime::Runtime { + static RT: std::sync::OnceLock = std::sync::OnceLock::new(); + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("plugin-net") + .enable_all() + .build() + .expect("plugin network runtime") + }) +} + +/// Replaces `{{secret:}}` in `value` with the plugin's secrets. +fn fill_secrets(value: &str, lookup: impl Fn(&str) -> Option) -> Result { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(i) = rest.find("{{secret:") { + out.push_str(&rest[..i]); + let after = &rest[i + "{{secret:".len()..]; + let end = after.find("}}").ok_or_else(|| HostError::Invalid("unterminated {{secret:…}}".into()))?; + let name = &after[..end]; + let secret = lookup(name).ok_or_else(|| HostError::NotFound(format!("secret {name:?} is not set")))?; + out.push_str(&secret); + rest = &after[end + 2..]; + } + out.push_str(rest); + Ok(out) +} + +/// A plugin task shown in the UI. +struct TaskEntry { + plugin: String, + cancel: CancellationToken, + cancelled: bool, +} + +/// State shared by the host and every plugin's [`PluginApi`]. +pub(super) struct Shared { + pub sink: Arc, + pub registry: Arc, + pub workspace: RwLock>>, + /// `/.data`: one directory per plugin id. + pub data_dir: PathBuf, + tasks: Mutex>, + next_task: AtomicU64, + /// (plugin, item id) → item, for windows opened later. + status: Mutex>, + /// plugin → kinds subscribed at run time (kept across idle reclaim). + pub subscriptions: Mutex>>, + pub secrets: RwLock>, + /// (plugin, `process:` capability) → the approved program. + pub pins: Arc>>, + /// Validated UI declarations (settings schemas, declared views). + pub ui: Arc, +} + +impl Shared { + pub fn new( + sink: Arc, + registry: Arc, + data_dir: PathBuf, + pins: Arc>>, + ui: Arc, + ) -> Arc { + Arc::new(Self { + ui, + sink, + registry, + workspace: RwLock::new(None), + secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })), + pins, + data_dir, + tasks: Mutex::default(), + next_task: AtomicU64::new(1), + status: Mutex::default(), + subscriptions: Mutex::default(), + }) + } + + fn workspace(&self) -> Option> { + self.workspace.read().clone() + } + + pub fn project_of(&self, window: &str) -> Option { + self.workspace()?.project_of(window) + } + + pub fn status_items(&self) -> Vec<(String, String, StatusItem)> { + self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect() + } + + /// Withdraws a plugin's run-time state (it stopped or failed). Its + /// subscriptions go only with `subscriptions`, as they survive restarts. + pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) { + let removed: Vec = { + let mut status = self.status.lock(); + let ids: Vec = status.keys().filter(|(p, _)| p == plugin).map(|(_, id)| id.clone()).collect(); + for id in &ids { + status.remove(&(plugin.to_owned(), id.clone())); + } + ids + }; + for id in removed { + self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None }); + } + let tasks: Vec = self.tasks.lock().iter().filter(|(_, t)| t.plugin == plugin).map(|(id, _)| *id).collect(); + for id in tasks { + self.end_task(id); + } + if subscriptions && self.subscriptions.lock().remove(plugin).is_some() { + self.sink.emit(None, PluginEvent::SubscriptionsChanged); + } + } + + /// The user cancelled a task in the UI. + pub fn cancel_task(&self, id: u64) -> bool { + let mut tasks = self.tasks.lock(); + let Some(t) = tasks.get_mut(&id) else { return false }; + t.cancelled = true; + t.cancel.cancel(); + true + } + + fn end_task(&self, id: u64) { + if let Some(t) = self.tasks.lock().remove(&id) { + self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() }); + } + } + + pub fn storage_file(&self, plugin: &str) -> PathBuf { + self.data_dir.join(plugin).join("storage.json") + } +} + +pub(super) struct PluginApi { + pub pkg: Package, + pub shared: Arc, +} + +impl PluginApi { + fn id(&self) -> &str { + self.pkg.id() + } + + fn load_storage(&self) -> Result, HostError> { + match std::fs::read(self.shared.storage_file(self.id())) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}"))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()), + Err(e) => Err(HostError::Failed(format!("storage: {e}"))), + } + } + + fn save_storage(&self, map: &BTreeMap) -> Result<(), HostError> { + let file = self.shared.storage_file(self.id()); + let bytes = serde_json::to_vec(map).map_err(|e| HostError::Failed(e.to_string()))?; + let io = |e: std::io::Error| HostError::Failed(format!("storage: {e}")); + std::fs::create_dir_all(file.parent().expect("has parent")).map_err(io)?; + let tmp = file.with_extension("tmp"); + std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(io) + } + + /// The program approved for the (already checked) `process:` grant, + /// verified right before it starts. + fn approved_program(&self, program: &str) -> Result { + let cap = format!("process:{program}"); + // Granted without a pinned file: the program wasn't found when it was + // allowed, and installing it later approves nothing by itself. + let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| { + HostError::Failed(format!( + "{cap}: `{program}` was not found when this permission was allowed; \ + install it, then allow {cap} again in Settings › Plugins" + )) + })?; + pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}"))) + } +} + +fn failed(e: impl std::fmt::Display) -> HostError { + HostError::Failed(e.to_string()) +} + +impl HostApi for PluginApi { + fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String) { + self.shared + .sink + .emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body }); + } + + fn status_item(&self, id: &str, item: Option) { + let key = (self.id().to_owned(), id.to_owned()); + { + let mut status = self.shared.status.lock(); + match &item { + Some(item) => { + let count = status.keys().filter(|(p, _)| p == self.id()).count(); + if !status.contains_key(&key) && count >= MAX_STATUS_ITEMS { + tracing::warn!(plugin = %self.id(), "more than {MAX_STATUS_ITEMS} status items; {id:?} ignored"); + return; + } + if status.get(&key) == Some(item) { + return; + } + status.insert(key, item.clone()); + } + None => { + if status.remove(&key).is_none() { + return; + } + } + } + } + self.shared + .sink + .emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item }); + } + + fn contribute(&self, point: &str, decl: toml::Value) -> Result { + if point == COMMANDS { + let key = decl.get("id").and_then(|v| v.as_str()).unwrap_or_default(); + if self.shared.registry.count(self.id(), COMMANDS) >= MAX_COMMANDS + && !self.shared.registry.has_key(self.id(), COMMANDS, key) + { + return Err(format!("too many commands (max {MAX_COMMANDS})")); + } + } + self.shared.registry.contribute(&self.pkg, point, decl) + } + + fn projects(&self) -> Vec { + self.shared.workspace().map(|w| w.projects()).unwrap_or_default() + } + + fn buffer_text(&self, path: &Path) -> Option { + self.shared.workspace()?.buffer_text(path) + } + + fn document(&self, path: &Path) -> Option<(u64, String)> { + self.shared.workspace()?.document(path) + } + + fn apply_edits( + &self, + _client: Option<&str>, + path: &Path, + version: u64, + edits: Vec, + ) -> Result { + let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?; + workspace.apply_edits(path, version, &edits) + } + + fn storage_get(&self, key: &str) -> Result, HostError> { + Ok(self.load_storage()?.get(key).cloned()) + } + + fn storage_set(&self, key: &str, value: Option) -> Result<(), HostError> { + if key.is_empty() || key.len() > MAX_KEY { + return Err(HostError::Invalid(format!("keys are 1–{MAX_KEY} bytes"))); + } + let mut map = self.load_storage()?; + let changed = match value { + Some(v) => map.insert(key.to_owned(), v.clone()).as_ref() != Some(&v), + None => map.remove(key).is_some(), + }; + if !changed { + return Ok(()); + } + let size: usize = map.iter().map(|(k, v)| k.len() + v.len()).sum(); + if size > MAX_STORAGE { + return Err(HostError::Invalid(format!("storage is limited to {} KiB", MAX_STORAGE >> 10))); + } + self.save_storage(&map) + } + + fn storage_keys(&self, prefix: &str) -> Result, HostError> { + Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect()) + } + + fn vcs_status(&self, root: &Path) -> Result, HostError> { + let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(None) }; + let info = repo.branch_info().map_err(failed)?; + let changed = repo.status().map_err(failed)?.len(); + Ok(Some(RepositoryStatus { + root: repo.root().to_string_lossy().into_owned(), + branch: info.head, + commit: info.head_commit, + upstream: info.upstream, + ahead: info.ahead, + behind: info.behind, + operation: info.operation, + changed_files: u32::try_from(changed).unwrap_or(u32::MAX), + })) + } + + fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> { + let workspace = self.shared.workspace(); + if !workspace.is_some_and(|w| w.trusted(root)) { + return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into())); + } + let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { + return Err(HostError::NotFound("the project is not in a git repository".into())); + }; + match repo.fetch(cancel, &|_| {}) { + Ok(()) => Ok(()), + Err(_) if cancel.is_cancelled() => Err(HostError::Cancelled), + Err(e) => Err(failed(e.message)), + } + } + + fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result, HostError> { + let matcher = globset::GlobBuilder::new(glob) + .literal_separator(true) + .build() + .map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))? + .compile_matcher(); + let mut out = Vec::new(); + for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() { + if n >= MAX_WALK || out.len() >= limit { + break; + } + if !entry.file_type().is_some_and(|t| t.is_file()) { + continue; + } + let Ok(rel) = entry.path().strip_prefix(root) else { continue }; + if matcher.is_match(rel) { + out.push(entry.path().to_string_lossy().into_owned()); + } + } + out.sort(); + Ok(out) + } + + fn publish_diagnostics(&self, path: &Path, items: Vec) -> Result<(), HostError> { + let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?; + workspace.publish_diagnostics(self.id(), path, items) + } + + fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result { + let secrets = self.shared.secrets.read().clone(); + let id = self.id().to_owned(); + let mut headers = reqwest::header::HeaderMap::new(); + for (name, value) in &request.headers { + let value = fill_secrets(value, |n| secrets.get(&id, n))?; + let name = reqwest::header::HeaderName::from_bytes(name.as_bytes()) + .map_err(|e| HostError::Invalid(format!("header {name:?}: {e}")))?; + let mut value = reqwest::header::HeaderValue::from_str(&value) + .map_err(|e| HostError::Invalid(format!("header {name}: {e}")))?; + value.set_sensitive(true); + headers.append(name, value); + } + let method = reqwest::Method::from_bytes(request.method.to_ascii_uppercase().as_bytes()) + .map_err(|_| HostError::Invalid(format!("method {:?}", request.method)))?; + let client = ide_http::client::shared().map_err(|e| HostError::Failed(e.message))?; + // A redirect to another host is refused after the fact (the shared + // client follows redirects). + let builder = client.request(method, &request.url).headers(headers).timeout(request.timeout); + let builder = match request.body { + Some(body) => builder.body(body), + None => builder, + }; + let cancel = cancel.clone(); + let host = ide_plugin::net_host(&request.url).map_err(HostError::Invalid)?; + net_runtime().block_on(async move { + let send = async { + let response = builder.send().await.map_err(|e| HostError::Failed(e.to_string()))?; + if ide_plugin::net_host(response.url().as_str()).ok().as_deref() != Some(host.as_str()) { + return Err(HostError::Denied(format!( + "net:{} (redirected)", + response.url().host_str().unwrap_or("?") + ))); + } + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .map(|(k, v)| (k.as_str().to_owned(), String::from_utf8_lossy(v.as_bytes()).into_owned())) + .collect(); + let mut body = Vec::new(); + let mut response = response; + while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Failed(e.to_string()))? { + if body.len() + chunk.len() > MAX_RESPONSE { + return Err(HostError::Failed("response larger than 8 MiB".into())); + } + body.extend_from_slice(&chunk); + } + Ok(HttpResponse { status, headers, body }) + }; + tokio::select! { + r = send => r, + () = cancel.cancelled() => Err(HostError::Cancelled), + } + }) + } + + fn secret_is_set(&self, name: &str) -> bool { + self.shared.secrets.read().get(self.id(), name).is_some() + } + + fn run_process( + &self, + program: &str, + args: Vec, + cwd: Option<&Path>, + stdin: Option, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result { + let binary = self.approved_program(program)?; + let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id())); + let _ = std::fs::create_dir_all(&cwd); + let mut child = std::process::Command::new(&binary) + .args(&args) + .current_dir(&cwd) + .stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() }) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?; + if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) { + std::thread::spawn(move || { + use std::io::Write; + let _ = pipe.write_all(input.as_bytes()); + }); + } + let read = |pipe: Option>| { + std::thread::spawn(move || { + use std::io::Read; + let mut out = Vec::new(); + if let Some(p) = pipe { + let _ = p.take(MAX_OUTPUT as u64).read_to_end(&mut out); + } + String::from_utf8_lossy(&out).into_owned() + }) + }; + let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box)); + let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box)); + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait().map_err(|e| HostError::Failed(e.to_string()))? { + break status; + } + if cancel.is_cancelled() || Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(if cancel.is_cancelled() { + HostError::Cancelled + } else { + HostError::Failed(format!("{program} timed out after {timeout:?}")) + }); + } + std::thread::sleep(Duration::from_millis(10)); + }; + Ok(ProcessOutput { + status: status.code(), + stdout: stdout.join().unwrap_or_default(), + stderr: stderr.join().unwrap_or_default(), + }) + } + + fn subscribe(&self, kind: EventKind, on: bool) { + let changed = { + let mut subs = self.shared.subscriptions.lock(); + let set = subs.entry(self.id().to_owned()).or_default(); + if on { set.insert(kind) } else { set.remove(&kind) } + }; + if changed { + self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); + } + } + + fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64 { + let id = self.shared.next_task.fetch_add(1, Ordering::Relaxed); + self.shared.tasks.lock().insert(id, TaskEntry { plugin: self.id().to_owned(), cancel, cancelled: false }); + self.shared.sink.emit( + client, + PluginEvent::TaskStarted { plugin_id: self.id().to_owned(), task_id: id.to_string(), title, cancellable }, + ); + id + } + + fn task_report(&self, id: u64, done: u32, total: Option, message: Option) { + if !self.shared.tasks.lock().contains_key(&id) { + return; + } + self.shared.sink.emit( + None, + PluginEvent::TaskProgress { + plugin_id: self.id().to_owned(), + task_id: id.to_string(), + done, + total, + message, + }, + ); + } + + fn task_cancelled(&self, id: u64) -> bool { + self.shared.tasks.lock().get(&id).is_some_and(|t| t.cancelled) + } + + fn task_end(&self, id: u64) { + self.shared.end_task(id); + } + + fn refresh_view(&self, view: &str) { + self.shared + .sink + .emit(None, PluginEvent::ViewChanged { plugin_id: self.id().to_owned(), view: view.to_owned() }); + } + + fn post_view_message(&self, view: &str, message: String) -> Result<(), HostError> { + let declared = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS] + .iter() + .any(|point| self.shared.ui.items(point, Some(self.id())).iter().any(|i| i.key == view)); + if !declared { + return Err(HostError::NotFound(format!("the plugin declares no view {view:?}"))); + } + let event = PluginEvent::ViewMessage { plugin_id: self.id().to_owned(), view: view.to_owned(), message }; + self.shared.sink.emit(None, event); + Ok(()) + } + + fn open_terminal( + &self, + client: Option<&str>, + title: String, + program: &str, + args: Vec, + cwd: Option<&Path>, + ) -> Result<(), HostError> { + let binary = self.approved_program(program)?; + let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id())); + let _ = std::fs::create_dir_all(&cwd); + let event = PluginEvent::OpenTerminal { + plugin_id: self.id().to_owned(), + title, + program: binary.into(), + args, + cwd: cwd.into(), + }; + self.shared.sink.emit(client, event); + Ok(()) + } + + fn setting(&self, root: Option<&Path>, key: &str) -> Option { + let schema: Vec = self + .shared + .ui + .items(super::ui_points::SETTINGS, Some(self.id())) + .into_iter() + .find_map(|i| serde_json::from_value(i.decl.get("properties")?.clone()).ok())?; + let setting = schema.into_iter().find(|s| s.key == key)?; + let user = self.shared.workspace().map(|w| w.plugin_settings(self.id(), root)).unwrap_or_default(); + let value = user.get(key).filter(|v| setting.accepts(v)).cloned().unwrap_or(setting.default); + serde_json::to_string(&value).ok() + } +} + +/// [`PluginWorkspace`] over the desktop host's open projects. +pub struct HostWorkspace(pub std::sync::Weak); + +impl HostWorkspace { + fn projects(&self) -> Vec> { + self.0.upgrade().map(|h| h.projects()).unwrap_or_default() + } + + /// The project holding an open document at `path`, and its id. + fn open_document(&self, path: &Path) -> Option<(Arc, ide_core::DocId)> { + self.projects().into_iter().find_map(|p| p.documents.by_path(path).map(|doc| (p, doc))) + } +} + +impl PluginWorkspace for HostWorkspace { + fn projects(&self) -> Vec { + let mut roots: Vec = HostWorkspace::projects(self).iter().map(|p| p.root.clone()).collect(); + roots.sort(); + roots.dedup(); + roots + } + + fn buffer_text(&self, path: &Path) -> Option { + HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path)) + } + + fn document(&self, path: &Path) -> Option<(u64, String)> { + let (project, doc) = self.open_document(path)?; + let snap = project.documents.snapshot(doc).ok()?; + Some((u64::from(snap.version), snap.text)) + } + + fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result { + let (project, doc) = + self.open_document(path).ok_or_else(|| HostError::NotFound(format!("{} is not open", path.display())))?; + let version = u32::try_from(version).map_err(|_| HostError::Stale)?; + match project.documents.apply_edits_at(doc, version, edits) { + Ok(Some(v)) => Ok(u64::from(v)), + Ok(None) => Err(HostError::Stale), + Err(e) if e.kind == ide_core::error::ErrorKind::InvalidInput => Err(HostError::Invalid(e.message)), + Err(e) => Err(HostError::Failed(e.message)), + } + } + + fn trusted(&self, root: &Path) -> bool { + HostWorkspace::projects(self).iter().any(|p| p.root == root && p.is_trusted()) + } + + fn project_of(&self, window: &str) -> Option { + self.0.upgrade()?.project(window).ok().map(|p| p.root.clone()) + } + + fn publish_diagnostics( + &self, + source: &str, + path: &Path, + items: Vec, + ) -> Result<(), HostError> { + let projects = HostWorkspace::projects(self); + let project = projects + .iter() + .filter(|p| path.starts_with(&p.root)) + .max_by_key(|p| p.root.as_os_str().len()) + .ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?; + project.sink.emit(ide_core::IdeEvent::Diagnostics { + path: path.to_path_buf().into(), + source: format!("plugin:{source}"), + items, + }); + Ok(()) + } + + fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map { + let Some(host) = self.0.upgrade() else { return serde_json::Map::new() }; + let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) { + Some(p) => p.settings.effective(), + None => host.global_settings.effective(), + }; + settings.plugins.get(plugin).cloned().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn api(dir: &Path, pins: Arc>>) -> PluginApi { + let manifest = ide_extension::Manifest::parse( + "id = \"acme.lint\"\nname = \"Lint\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n", + ) + .unwrap(); + let sink: Arc = Arc::new(|_: Option<&str>, _: PluginEvent| {}); + let shared = Shared::new( + sink, + Arc::new(ExtensionRegistry::new()), + dir.join(".data"), + pins, + ide_extension::UiStore::new(), + ); + PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared } + } + + #[test] + fn programs_run_only_as_approved() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("lint"); + std::fs::write(&bin, "#!/bin/sh\n").unwrap(); + let pins: Arc>> = Arc::default(); + let api = api(tmp.path(), pins.clone()); + + // Allowed while the program was missing: say how to fix it rather than "not granted". + let err = api.approved_program("lint").unwrap_err().to_string(); + assert!( + err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"), + "{err}" + ); + + pins.write().insert(("acme.lint".into(), "process:lint".into()), ProgramPin::approve(&bin).unwrap()); + assert_eq!(api.approved_program("lint").unwrap(), bin); + + std::fs::write(&bin, "#!/bin/sh\necho replaced\n").unwrap(); + assert!( + matches!(api.approved_program("lint"), Err(HostError::Denied(e)) if e.contains("changed since it was approved")) + ); + } +} diff --git a/crates/ide-host/src/plugins/icons.rs b/crates/ide-host/src/plugins/icons.rs new file mode 100644 index 000000000..8df6a94be --- /dev/null +++ b/crates/ide-host/src/plugins/icons.rs @@ -0,0 +1,610 @@ +//! The `iconThemes` extension point: file and folder icons. +//! +//! ```toml +//! [[contributes.iconThemes]] +//! id = "material" +//! label = "Material Icons" +//! path = "icons/theme.json" +//! ``` +//! +//! `theme.json` (paths relative to the package): +//! +//! ```json +//! { +//! "iconDefinitions": { "rust": "icons/rust.svg", "folder-src": "icons/folder-src.svg" }, +//! "file": "file", "folder": "folder", "folderExpanded": "folder-open", +//! "fileExtensions": { "rs": "rust" }, +//! "fileNames": { "cargo.toml": "cargo" }, +//! "folderNames": { "src": "folder-src" } +//! } +//! ``` +//! +//! Every SVG is sanitised here (allow-listed elements and attributes, no +//! scripts, event handlers, external references or entities) and stored as an +//! asset; the client fetches the mapping and the icons only when the theme is +//! selected, so installed icon themes cost nothing at start-up. + +use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +pub const POINT_ID: &str = "iconThemes"; +const MAX_ICONS: usize = 5000; +const MAX_SVG: usize = 64 << 10; +const MAX_MAPPINGS: usize = 20_000; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + id: String, + label: String, + path: PathBuf, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ThemeFile { + icon_definitions: BTreeMap, + #[serde(default)] + file: Option, + #[serde(default)] + folder: Option, + #[serde(default)] + folder_expanded: Option, + #[serde(default)] + file_extensions: BTreeMap, + #[serde(default)] + file_names: BTreeMap, + #[serde(default)] + folder_names: BTreeMap, +} + +/// Asset id of a theme's mapping (JSON). +pub fn map_asset(theme: &str) -> String { + format!("iconTheme/{theme}/map") +} + +/// Asset id prefix of a theme's SVGs (`…/svg/`). +pub fn svg_prefix(theme: &str) -> String { + format!("iconTheme/{theme}/svg/") +} + +pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result { + let d: Decl = decode(POINT_ID, decl)?; + validate_item_id(&d.id, "icon theme id")?; + if d.label.trim().is_empty() { + return Err(format!("icon theme {:?} has an empty label", d.id)); + } + let src = pkg.read_text(&d.path)?; + let theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?; + let at = |what: &str| format!("{} ({what})", d.path.display()); + if theme.icon_definitions.len() > MAX_ICONS { + return Err(format!("{}: more than {MAX_ICONS} icons", d.path.display())); + } + let mappings = theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len(); + if mappings > MAX_MAPPINGS { + return Err(format!("{}: more than {MAX_MAPPINGS} mappings", d.path.display())); + } + let defined: BTreeSet<&str> = theme.icon_definitions.keys().map(String::as_str).collect(); + let check_ref = |icon: &str, what: &str| -> Result<(), String> { + if defined.contains(icon) { Ok(()) } else { Err(format!("{}: unknown icon {icon:?}", at(what))) } + }; + for (what, icon) in [("file", &theme.file), ("folder", &theme.folder), ("folderExpanded", &theme.folder_expanded)] { + if let Some(icon) = icon { + check_ref(icon, what)?; + } + } + // Extensions may be written with their dot (`.rs`); names keep theirs (`.github`, `.gitignore`). + let lower = |map: &BTreeMap, what: &str| -> Result, String> { + let mut out = BTreeMap::new(); + for (k, icon) in map { + check_ref(icon, what)?; + let key = k.trim(); + let key = if what == "fileExtensions" { key.trim_start_matches('.') } else { key }.to_lowercase(); + if key.is_empty() || key.contains(['/', '\\']) { + return Err(format!("{}: invalid key {k:?}", at(what))); + } + out.insert(key, icon.clone()); + } + Ok(out) + }; + let file_extensions = lower(&theme.file_extensions, "fileExtensions")?; + let file_names = lower(&theme.file_names, "fileNames")?; + let folder_names = lower(&theme.folder_names, "folderNames")?; + + let mut assets = Vec::with_capacity(theme.icon_definitions.len() + 1); + let mut files = vec![d.path.clone()]; + for (icon, path) in &theme.icon_definitions { + validate_item_id(icon, "icon id")?; + check_relative(path, "icon")?; + let svg = pkg.read_text(path)?; + if svg.len() > MAX_SVG { + return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10)); + } + let clean = sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display()))?; + assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean)); + files.push(path.clone()); + } + let map = serde_json::json!({ + "file": theme.file, + "folder": theme.folder, + "folderExpanded": theme.folder_expanded, + "fileExtensions": file_extensions, + "fileNames": file_names, + "folderNames": folder_names, + }); + assets.push((map_asset(&d.id), map.to_string())); + let json = serde_json::json!({ "id": d.id, "label": d.label, "icons": theme.icon_definitions.len() }); + let mut out = UiDecl::new(d.id.clone(), d.label, json); + out.files = files; + out.assets = assets; + Ok(out) +} + +// --------------------------------------------------------------------------- +// SVG sanitiser + +const ELEMENTS: &[&str] = &[ + "svg", + "g", + "path", + "circle", + "ellipse", + "line", + "polyline", + "polygon", + "rect", + "defs", + "lineargradient", + "radialgradient", + "stop", + "clippath", + "mask", + "use", + "symbol", + "pattern", + "text", + "tspan", + "filter", + "fegaussianblur", + "feoffset", + "feblend", + "fecolormatrix", + "feflood", + "fecomposite", + "femerge", + "femergenode", + "fedropshadow", +]; + +/// Dropped with their content: harmless metadata. +const STRIPPED: &[&str] = &["title", "desc", "metadata"]; + +const ATTRIBUTES: &[&str] = &[ + "id", + "class", + "viewbox", + "width", + "height", + "x", + "y", + "x1", + "y1", + "x2", + "y2", + "cx", + "cy", + "r", + "rx", + "ry", + "fx", + "fy", + "d", + "points", + "transform", + "fill", + "fill-opacity", + "fill-rule", + "clip-rule", + "clip-path", + "mask", + "stroke", + "stroke-width", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-opacity", + "opacity", + "offset", + "stop-color", + "stop-opacity", + "gradientunits", + "gradienttransform", + "spreadmethod", + "patternunits", + "patterncontentunits", + "patterntransform", + "clippathunits", + "maskunits", + "maskcontentunits", + "preserveaspectratio", + "style", + "href", + "xlink:href", + "xmlns", + "xmlns:xlink", + "version", + "font-family", + "font-size", + "font-weight", + "text-anchor", + "dominant-baseline", + "letter-spacing", + "display", + "visibility", + "color", + "filter", + "in", + "in2", + "result", + "stddeviation", + "dx", + "dy", + "mode", + "operator", + "values", + "type", + "flood-color", + "flood-opacity", + "k1", + "k2", + "k3", + "k4", + "filterunits", + "primitiveunits", + "vector-effect", + "shape-rendering", + "isolation", + "mix-blend-mode", +]; + +/// Parses `src` and re-serialises the allow-listed subset. Fails on anything +/// that could execute, fetch or escape (scripts, `foreignObject`, event +/// handlers, external `href`s, `url()` to anything but a local fragment, +/// DTDs, entities, CDATA). +pub fn sanitize_svg(src: &str) -> Result { + let mut out = String::with_capacity(src.len()); + let mut stack: Vec = Vec::new(); + // Depth inside a stripped element (its content is skipped). + let mut skip_depth = 0usize; + let mut seen_root = false; + let mut rest = src; + while !rest.is_empty() { + let Some(lt) = rest.find('<') else { + if skip_depth == 0 && !stack.is_empty() { + out.push_str(&text(rest)?); + } else if !rest.trim().is_empty() && stack.is_empty() { + return Err("text outside the element".into()); + } + break; + }; + if lt > 0 { + let t = &rest[..lt]; + if skip_depth == 0 && !stack.is_empty() { + out.push_str(&text(t)?); + } else if stack.is_empty() && !t.trim().is_empty() { + return Err("text outside the element".into()); + } + } + rest = &rest[lt..]; + if let Some(after) = rest.strip_prefix("").ok_or("unterminated comment")?; + rest = &after[end + 3..]; + continue; + } + if rest.starts_with("").ok_or("unterminated processing instruction")?; + rest = &rest[end + 2..]; + continue; + } + if rest.starts_with("').ok_or("unterminated closing tag")?; + let name = after[..end].trim().to_ascii_lowercase(); + rest = &after[end + 1..]; + let open = stack.pop().ok_or_else(|| format!("unexpected "))?; + if open != name { + return Err(format!(" does not close <{open}>")); + } + if skip_depth > 0 { + skip_depth -= 1; + } else { + out.push_str(&format!("")); + } + continue; + } + // Start tag. + let end = tag_end(rest).ok_or("unterminated tag")?; + let inner = &rest[1..end]; + rest = &rest[end + 1..]; + let self_closing = inner.trim_end().ends_with('/'); + let inner = inner.trim_end().trim_end_matches('/'); + let name_end = inner.find(|c: char| c.is_whitespace()).unwrap_or(inner.len()); + let raw_name = &inner[..name_end]; + let name = raw_name.to_ascii_lowercase(); + if name.is_empty() { + return Err("empty tag".into()); + } + if stack.is_empty() { + if seen_root || name != "svg" { + return Err("the file must contain exactly one element".into()); + } + seen_root = true; + } + if !self_closing { + stack.push(name.clone()); + } + if skip_depth > 0 { + if !self_closing { + skip_depth += 1; + } + continue; + } + if STRIPPED.contains(&name.as_str()) || name.contains(':') { + // Editor metadata (sodipodi:namedview) or descriptive text. + if !self_closing { + skip_depth = 1; + } + continue; + } + if !ELEMENTS.contains(&name.as_str()) { + return Err(format!("<{raw_name}> is not allowed")); + } + out.push('<'); + out.push_str(raw_name); + for (attr, value) in attributes(&inner[name_end..])? { + let key = attr.to_ascii_lowercase(); + if key.starts_with("on") { + return Err(format!("event handler {attr} is not allowed")); + } + if key.contains(':') && !matches!(key.as_str(), "xlink:href" | "xmlns:xlink") { + continue; // inkscape:label, sodipodi:docname, … + } + if !ATTRIBUTES.contains(&key.as_str()) && !key.starts_with("data-") && !key.starts_with("aria-") { + return Err(format!("attribute {attr} is not allowed")); + } + check_value(&key, &value).map_err(|e| format!("{attr}: {e}"))?; + out.push(' '); + out.push_str(&attr); + out.push_str("=\""); + out.push_str(&escape(&value)); + out.push('"'); + } + out.push_str(if self_closing { "/>" } else { ">" }); + } + if !stack.is_empty() { + return Err(format!("<{}> is not closed", stack.last().map(String::as_str).unwrap_or("?"))); + } + if !seen_root { + return Err("no element".into()); + } + Ok(out) +} + +/// Index of the `>` ending the tag at the start of `s`, skipping quoted values. +fn tag_end(s: &str) -> Option { + let mut quote: Option = None; + for (i, c) in s.char_indices().skip(1) { + match (quote, c) { + (Some(q), c) if c == q => quote = None, + (Some(_), _) => {} + (None, '"' | '\'') => quote = Some(c), + (None, '>') => return Some(i), + (None, '<') => return None, + _ => {} + } + } + None +} + +fn attributes(s: &str) -> Result, String> { + let mut out = Vec::new(); + let mut rest = s.trim_start(); + while !rest.is_empty() { + let eq = rest.find('=').ok_or_else(|| format!("attribute without value near {:?}", clip(rest)))?; + let name = rest[..eq].trim(); + if name.is_empty() || name.contains(|c: char| c.is_whitespace() || c == '"' || c == '\'') { + return Err(format!("malformed attribute near {:?}", clip(rest))); + } + let after = rest[eq + 1..].trim_start(); + let quote = after.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("unquoted attribute value")?; + let close = after[1..].find(quote).ok_or("unterminated attribute value")?; + let value = decode_entities(&after[1..1 + close])?; + if out.iter().any(|(n, _): &(String, String)| n.eq_ignore_ascii_case(name)) { + return Err(format!("duplicate attribute {name}")); + } + out.push((name.to_owned(), value)); + rest = after[close + 2..].trim_start(); + } + Ok(out) +} + +fn check_value(key: &str, value: &str) -> Result<(), String> { + let lower = value.to_ascii_lowercase(); + if matches!(key, "href" | "xlink:href") { + return if value.starts_with('#') { Ok(()) } else { Err("only local references (#id) are allowed".into()) }; + } + if key == "xmlns" || key == "xmlns:xlink" { + return Ok(()); + } + if lower.contains("javascript:") + || lower.contains("expression(") + || lower.contains("@import") + || value.contains('\\') + { + return Err("unsafe value".into()); + } + let mut rest = lower.as_str(); + while let Some(i) = rest.find("url(") { + let inner = rest[i + 4..].trim_start().trim_start_matches(['"', '\'']); + if !inner.starts_with('#') { + return Err("url() may only reference a local fragment".into()); + } + rest = &rest[i + 4..]; + } + Ok(()) +} + +fn decode_entities(s: &str) -> Result { + if !s.contains('&') { + return Ok(s.to_owned()); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + let semi = rest[i..].find(';').ok_or("unterminated entity")?; + let ent = &rest[i + 1..i + semi]; + let c = match ent { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + _ => { + let code = if let Some(hex) = ent.strip_prefix("#x").or_else(|| ent.strip_prefix("#X")) { + u32::from_str_radix(hex, 16).ok() + } else if let Some(dec) = ent.strip_prefix('#') { + dec.parse().ok() + } else { + None + }; + code.and_then(char::from_u32).ok_or_else(|| format!("entity &{ent}; is not allowed"))? + } + }; + out.push(c); + rest = &rest[i + semi + 1..]; + } + out.push_str(rest); + Ok(out) +} + +fn text(t: &str) -> Result { + Ok(escape(&decode_entities(t)?)) +} + +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +fn clip(s: &str) -> &str { + let mut end = s.len().min(24); + while !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_ordinary_icons() { + let src = r##" + + + rust + + + +"##; + let out = sanitize_svg(src).unwrap(); + assert!(out.starts_with("")); + } + + #[test] + fn rejects_active_content() { + let bad = [ + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##"]>"##, + r##"x"##, + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##""##, + r##"hello"##, + r##""##, + ]; + for src in bad { + assert!(sanitize_svg(src).is_err(), "accepted: {src}"); + } + } + + #[test] + fn theme_files_validate_and_produce_assets() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::create_dir_all(dir.join("icons")).unwrap(); + std::fs::write(dir.join("icons/rust.svg"), r#""#) + .unwrap(); + std::fs::write(dir.join("icons/file.svg"), r#""#) + .unwrap(); + std::fs::write( + dir.join("theme.json"), + r#"{"iconDefinitions":{"rust":"icons/rust.svg","file":"icons/file.svg"},"file":"file", + "fileExtensions":{".RS":"rust"},"fileNames":{"Cargo.toml":"rust",".gitignore":"file"}, + "folderNames":{".GitHub":"file","src":"rust"}}"#, + ) + .unwrap(); + let manifest = ide_extension::Manifest::parse( + "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"", + ) + .unwrap(); + let pkg = Package::new(manifest, dir.to_path_buf()); + let decl = |path: &str| { + let mut t = toml::Table::new(); + t.insert("id".into(), "m".into()); + t.insert("label".into(), "M".into()); + t.insert("path".into(), path.into()); + toml::Value::Table(t) + }; + let d = icon_theme(&pkg, decl("theme.json")).unwrap(); + assert_eq!(d.json["icons"], 2); + assert_eq!(d.files.len(), 3); + let map: serde_json::Value = + serde_json::from_str(&d.assets.iter().find(|(k, _)| *k == map_asset("m")).unwrap().1).unwrap(); + assert_eq!(map["fileExtensions"]["rs"], "rust"); + assert_eq!(map["fileNames"]["cargo.toml"], "rust"); + // Names keep their leading dot (only extensions drop it). + assert_eq!(map["fileNames"][".gitignore"], "file"); + assert_eq!(map["folderNames"][".github"], "file"); + assert_eq!(map["folderNames"]["src"], "rust"); + assert!(map["folderNames"].get("github").is_none()); + assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust", svg_prefix("m")))); + + std::fs::write(dir.join("bad.json"), r#"{"iconDefinitions":{},"fileExtensions":{"rs":"nope"}}"#).unwrap(); + assert!(icon_theme(&pkg, decl("bad.json")).unwrap_err().contains("unknown icon")); + std::fs::write(dir.join("icons/evil.svg"), r#""#).unwrap(); + std::fs::write(dir.join("evil.json"), r#"{"iconDefinitions":{"e":"icons/evil.svg"}}"#).unwrap(); + assert!(icon_theme(&pkg, decl("evil.json")).unwrap_err().contains("evil.svg")); + assert!(icon_theme(&pkg, decl("../x.json")).is_err()); + } +} diff --git a/crates/ide-host/src/plugins/install.rs b/crates/ide-host/src/plugins/install.rs new file mode 100644 index 000000000..1409b5f81 --- /dev/null +++ b/crates/ide-host/src/plugins/install.rs @@ -0,0 +1,288 @@ +//! Copying a package into the plugins directory without ever leaving the +//! plugin missing (docs/plugin_plan.md 4.7). Installed plugins are versioned: +//! `///` plus an `active` pointer file (and `previous`, +//! the version to roll back to). A new version is written to a staging +//! directory, renamed into place, and only then made active by replacing the +//! pointer; a swap interrupted by a crash is undone on the next start +//! ([`recover`]). Older installs used `//` directly; they are +//! moved into the versioned layout on their next update. + +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Top-level files copied with a package besides those its declarations reference. +pub const LICENSE_FILES: &[&str] = + &["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING", "README.md"]; +/// Written into an installed package: the package hash the grant ledger compares. +pub const HASH_FILE: &str = ".package-hash"; + +const STAGING: &str = ".staging-"; +const OLD: &str = ".old-"; +/// `/active`: the version in use. +pub const ACTIVE_FILE: &str = "active"; +/// `/previous`: the version an update replaced (for rollback). +pub const PREVIOUS_FILE: &str = "previous"; + +fn nonce() -> String { + let n = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + format!("{n:x}-{}", std::process::id()) +} + +/// Copies `files` (plus manifest and licence files) from `src` into `target` +/// atomically, recording `hash`. +pub fn install_package(src: &Path, target: &Path, files: &[PathBuf], hash: &str) -> io::Result<()> { + let parent = target.parent().ok_or_else(|| io::Error::other("plugin target has no parent"))?; + let name = target.file_name().and_then(|n| n.to_str()).ok_or_else(|| io::Error::other("bad target"))?; + std::fs::create_dir_all(parent)?; + let staging = parent.join(format!("{STAGING}{name}-{}", nonce())); + let result = (|| { + std::fs::create_dir_all(&staging)?; + std::fs::copy(src.join(ide_extension::MANIFEST_FILE), staging.join(ide_extension::MANIFEST_FILE))?; + for file in files { + let to = staging.join(file); + if let Some(dir) = to.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::copy(src.join(file), to)?; + } + for lic in LICENSE_FILES { + if src.join(lic).is_file() { + std::fs::copy(src.join(lic), staging.join(lic))?; + } + } + std::fs::write(staging.join(HASH_FILE), hash)?; + swap(&staging, target, |from, to| std::fs::rename(from, to)) + })(); + if result.is_err() { + let _ = std::fs::remove_dir_all(&staging); + } + result +} + +/// A version as a directory name (semver: `[0-9A-Za-z.+-]`). +fn version_dir(version: &str) -> io::Result<&str> { + let ok = !version.is_empty() + && version.len() <= 64 + && !version.starts_with('.') + && version.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-')); + if ok { Ok(version) } else { Err(io::Error::other(format!("version {version:?} cannot name a directory"))) } +} + +fn read_pointer(root: &Path, name: &str) -> Option { + let v = std::fs::read_to_string(root.join(name)).ok()?; + let v = v.trim(); + (!v.is_empty() && version_dir(v).is_ok()).then(|| v.to_owned()) +} + +/// Replaces the pointer file `name` atomically. +fn write_pointer(root: &Path, name: &str, version: &str) -> io::Result<()> { + let tmp = root.join(format!(".{name}.tmp")); + std::fs::write(&tmp, version)?; + std::fs::rename(&tmp, root.join(name)) +} + +/// The directory of a plugin's active version under its `root` +/// (`/`): `/`, or `root` itself in the flat layout. +pub fn active_dir(root: &Path) -> Option { + if let Some(v) = read_pointer(root, ACTIVE_FILE) { + let dir = root.join(v); + return dir.join(ide_extension::MANIFEST_FILE).is_file().then_some(dir); + } + root.join(ide_extension::MANIFEST_FILE).is_file().then(|| root.to_path_buf()) +} + +/// The version an update replaced, if it is still there. +pub fn previous_version(root: &Path) -> Option { + read_pointer(root, PREVIOUS_FILE).filter(|v| root.join(v).join(ide_extension::MANIFEST_FILE).is_file()) +} + +/// Moves a flat install (`/plugin.toml`) to `//`. +fn migrate_flat(root: &Path, version_of: impl Fn(&Path) -> Option) -> io::Result<()> { + if read_pointer(root, ACTIVE_FILE).is_some() || !root.join(ide_extension::MANIFEST_FILE).is_file() { + return Ok(()); + } + let version = version_of(root).unwrap_or_else(|| "0.0.0".into()); + let version = version_dir(&version).map(str::to_owned).unwrap_or_else(|_| "0.0.0".into()); + let parent = root.parent().unwrap_or(Path::new(".")); + let name = root.file_name().and_then(|n| n.to_str()).ok_or_else(|| io::Error::other("bad plugin directory"))?; + // `.old--…` is restored by `recover` if this is interrupted. + let old = parent.join(format!("{OLD}{name}-{}", nonce())); + std::fs::rename(root, &old)?; + std::fs::create_dir_all(root)?; + std::fs::rename(&old, root.join(&version))?; + write_pointer(root, ACTIVE_FILE, &version) +} + +/// Installs `files` of `src` as `/` and makes it the active +/// version. Returns the version it replaced (now `previous`). +pub fn install_version( + src: &Path, + root: &Path, + version: &str, + files: &[PathBuf], + hash: &str, + version_of: impl Fn(&Path) -> Option, +) -> io::Result> { + let version = version_dir(version)?; + std::fs::create_dir_all(root)?; + migrate_flat(root, version_of)?; + install_package(src, &root.join(version), files, hash)?; + let previous = read_pointer(root, ACTIVE_FILE).filter(|v| v != version); + if let Some(p) = &previous { + write_pointer(root, PREVIOUS_FILE, p)?; + } + write_pointer(root, ACTIVE_FILE, version)?; + Ok(previous) +} + +/// Makes `version` active again (rollback). The replaced version stays until [`prune`]. +pub fn activate_version(root: &Path, version: &str) -> io::Result<()> { + let version = version_dir(version)?; + if !root.join(version).join(ide_extension::MANIFEST_FILE).is_file() { + return Err(io::Error::other(format!("version {version} is not installed"))); + } + let _ = std::fs::remove_file(root.join(PREVIOUS_FILE)); + write_pointer(root, ACTIVE_FILE, version) +} + +/// Removes versions other than the active and the previous one. +pub fn prune(root: &Path) { + let keep: Vec = [ACTIVE_FILE, PREVIOUS_FILE].iter().filter_map(|f| read_pointer(root, f)).collect(); + if keep.is_empty() { + return; + } + let Ok(entries) = std::fs::read_dir(root) else { return }; + for e in entries.flatten() { + let path = e.path(); + let name = e.file_name().to_string_lossy().into_owned(); + if path.is_dir() && !keep.contains(&name) { + let _ = std::fs::remove_dir_all(&path); + } + } +} + +/// Moves `staging` to `target`, keeping the old `target` until the new one is in place. +fn swap(staging: &Path, target: &Path, rename: impl Fn(&Path, &Path) -> io::Result<()>) -> io::Result<()> { + if !target.exists() { + return rename(staging, target); + } + let parent = target.parent().unwrap_or(Path::new(".")); + let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("plugin"); + let old = parent.join(format!("{OLD}{name}-{}", nonce())); + rename(target, &old)?; + if let Err(e) = rename(staging, target) { + // Put the previous version back. + rename(&old, target)?; + return Err(e); + } + if let Err(e) = std::fs::remove_dir_all(&old) { + tracing::warn!("removing the previous version {}: {e}", old.display()); + } + Ok(()) +} + +/// Undoes interrupted installs in `dir`: restores `.old--*` when +/// `` is missing, and removes leftover staging and old directories. +pub fn recover(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + // Inside a versioned plugin: staging and old versions of a version swap. + if path.is_dir() && read_pointer(&path, ACTIVE_FILE).is_some() { + recover(&path); + continue; + } + let Some(file) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue }; + if file.starts_with(STAGING) { + let _ = std::fs::remove_dir_all(&path); + } else if let Some(rest) = file.strip_prefix(OLD) { + // `--`: the name is everything before the last two parts. + let parts: Vec<&str> = rest.rsplitn(3, '-').collect(); + let name = parts.get(2).copied().unwrap_or_default(); + let target = dir.join(name); + // A layout migration interrupted after creating the new root: it is still empty. + if !name.is_empty() && target.is_dir() && active_dir(&target).is_none() { + let _ = std::fs::remove_dir_all(&target); + } + if !name.is_empty() && !target.exists() { + tracing::warn!("restoring {} after an interrupted install", target.display()); + let _ = std::fs::rename(&path, &target); + } else { + let _ = std::fs::remove_dir_all(&path); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_swap_keeps_the_old_version() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("p"); + let staging = tmp.path().join(".staging-p-1"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("v"), "1").unwrap(); + std::fs::create_dir_all(&staging).unwrap(); + std::fs::write(staging.join("v"), "2").unwrap(); + let calls = std::cell::Cell::new(0); + let err = swap(&staging, &target, |a, b| { + calls.set(calls.get() + 1); + if calls.get() == 2 { Err(io::Error::other("disk full")) } else { std::fs::rename(a, b) } + }); + assert!(err.is_err()); + assert_eq!(std::fs::read_to_string(target.join("v")).unwrap(), "1"); + swap(&staging, &target, |a, b| std::fs::rename(a, b)).unwrap(); + assert_eq!(std::fs::read_to_string(target.join("v")).unwrap(), "2"); + assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1, "old version removed"); + } + + #[test] + fn versions_are_installed_side_by_side_and_rolled_back() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + std::fs::create_dir_all(&src).unwrap(); + let root = tmp.path().join("plugins/acme.tools"); + let version_of = |_: &Path| Some("0.9.0".to_owned()); + // A flat install from before versioning. + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join(ide_extension::MANIFEST_FILE), "v0.9").unwrap(); + assert_eq!(active_dir(&root), Some(root.clone())); + + std::fs::write(src.join(ide_extension::MANIFEST_FILE), "v1").unwrap(); + assert_eq!(install_version(&src, &root, "1.0.0", &[], "h1", version_of).unwrap().as_deref(), Some("0.9.0")); + assert_eq!(active_dir(&root), Some(root.join("1.0.0"))); + std::fs::write(src.join(ide_extension::MANIFEST_FILE), "v2").unwrap(); + assert_eq!(install_version(&src, &root, "2.0.0", &[], "h2", version_of).unwrap().as_deref(), Some("1.0.0")); + assert_eq!(previous_version(&root).as_deref(), Some("1.0.0")); + prune(&root); + assert!(!root.join("0.9.0").exists(), "older than previous: pruned"); + + activate_version(&root, "1.0.0").unwrap(); + assert_eq!( + std::fs::read_to_string(active_dir(&root).unwrap().join(ide_extension::MANIFEST_FILE)).unwrap(), + "v1" + ); + assert_eq!(previous_version(&root), None); + assert!(install_version(&src, &root, "../x", &[], "h", version_of).is_err()); + } + + #[test] + fn recover_restores_an_interrupted_swap() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".old-acme.tools-1a2b-77")).unwrap(); + std::fs::write(tmp.path().join(".old-acme.tools-1a2b-77/v"), "1").unwrap(); + std::fs::create_dir_all(tmp.path().join(".staging-acme.tools-1a2c-77")).unwrap(); + std::fs::create_dir_all(tmp.path().join("x")).unwrap(); + std::fs::create_dir_all(tmp.path().join(".old-x-1-2")).unwrap(); + recover(tmp.path()); + assert_eq!(std::fs::read_to_string(tmp.path().join("acme.tools/v")).unwrap(), "1"); + let mut names: Vec<_> = + std::fs::read_dir(tmp.path()).unwrap().flatten().map(|e| e.file_name().into_string().unwrap()).collect(); + names.sort(); + assert_eq!(names, ["acme.tools", "x"]); + } +} diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs new file mode 100644 index 000000000..8e34c2b63 --- /dev/null +++ b/crates/ide-host/src/plugins/market.rs @@ -0,0 +1,293 @@ +//! The marketplace (docs/plugin_plan.md 4.7): a static index (`index.json` + +//! packages, served from any URL or folder) lists publishers and their keys, +//! plugins, and revocations. The host keeps the last index it read +//! (`market-index.json`), checks signatures against it, and disables what it +//! revokes — at start-up and on every refresh (the desktop refreshes daily). + +use super::{PluginHost, PluginSource, PluginStatus}; +use ide_core::{IdeError, IdeResult}; +use ide_extension::Manifest; +use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// The last index read, in the plugins directory. +pub const INDEX_FILE: &str = "market-index.json"; +const DOWNLOADS: &str = ".downloads"; +const MAX_INDEX: usize = 16 << 20; +const MAX_PACKAGE: usize = 256 << 20; + +/// A marketplace entry with what is installed. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct MarketPlugin { + #[serde(flatten)] + pub entry: IndexEntry, + /// Installed version, if any. + pub installed: Option, +} + +/// Reads `url`: `http(s)://…`, `file://…` or a local path. +pub fn read_url(url: &str, max: usize) -> Result, String> { + if url.starts_with("https://") || url.starts_with("http://") { + let client = ide_http::client::shared().map_err(|e| e.message)?; + let url = url.to_owned(); + return super::api::net_runtime().block_on(async move { + let response = client.get(&url).send().await.map_err(|e| format!("{url}: {e}"))?; + if !response.status().is_success() { + return Err(format!("{url}: HTTP {}", response.status())); + } + let bytes = response.bytes().await.map_err(|e| format!("{url}: {e}"))?; + if bytes.len() > max { + return Err(format!("{url}: larger than {} MiB", max >> 20)); + } + Ok(bytes.to_vec()) + }); + } + let path = url.strip_prefix("file://").unwrap_or(url); + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + if bytes.len() > max { + return Err(format!("{path}: larger than {} MiB", max >> 20)); + } + Ok(bytes) +} + +/// `href` relative to the index at `base`. +pub fn resolve(base: &str, href: &str) -> String { + if href.contains("://") || href.starts_with('/') { + return href.to_owned(); + } + match base.rfind('/') { + Some(i) => format!("{}/{href}", &base[..i]), + None => href.to_owned(), + } +} + +impl PluginHost { + pub(super) fn load_market_index(dir: &Path) -> Option { + let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?; + Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok() + } + + /// The marketplace index URL (none: no marketplace). + pub fn market_url(&self) -> Option { + self.state.lock().market_url.clone() + } + + pub fn set_market_url(&self, url: Option) { + self.state.lock().market_url = url.filter(|u| !u.trim().is_empty()); + self.save_state(); + } + + /// Reads the index again, keeps it, and applies its revocations. + pub fn refresh_market(&self) -> IdeResult> { + let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?; + let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?; + let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?; + let index = Index::parse(&text).map_err(IdeError::invalid)?; + let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), &text); + *self.market.write() = Some(index); + // Trust may have changed: who signed what, what is revoked. + let installed: Vec<_> = + self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect(); + for plugin in installed { + let publisher = self.verified_publisher(plugin.dir()); + plugin.meta.lock().publisher = publisher; + } + self.apply_revocations(); + Ok(self.market_plugins()) + } + + /// The plugins of the last index read. + pub fn market_plugins(&self) -> Vec { + let Some(index) = self.market.read().clone() else { return Vec::new() }; + let plugins = self.plugins.read(); + index + .plugins + .iter() + .filter(|e| index.revocation(&e.id, &e.version).is_none()) + .cloned() + .map(|entry| { + let installed = plugins + .get(&entry.id) + .filter(|p| p.source == PluginSource::Installed) + .map(|p| p.manifest().version.clone()); + MarketPlugin { entry, installed } + }) + .collect() + } + + /// Downloads a marketplace plugin, checks it against the index (digest and + /// signature), and unpacks it for the install page. Returns the folder to + /// install from. + pub fn download_market_plugin(&self, id: &str) -> IdeResult { + let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?; + let index = self.market.read().clone().ok_or_else(|| IdeError::unavailable("refresh the marketplace first"))?; + let entry = index + .plugins + .iter() + .find(|e| e.id == id) + .cloned() + .ok_or_else(|| IdeError::not_found(format!("{id} is not in the marketplace")))?; + let bytes = read_url(&resolve(&url, &entry.download), MAX_PACKAGE).map_err(IdeError::unavailable)?; + let downloads = self.plugins_dir.join(DOWNLOADS); + std::fs::create_dir_all(&downloads)?; + let archive = downloads.join(format!("{}-{}.{}", entry.id, entry.version, package::PACKAGE_EXTENSION)); + std::fs::write(&archive, bytes)?; + let dir = self.unpack_package(&archive)?; + let _ = std::fs::remove_file(&archive); + let manifest = Manifest::load(&dir).map_err(IdeError::invalid)?; + let files = self.package_files_in(&dir)?; + let digest = package::package_digest(&dir, &files)?; + if (manifest.id.as_str(), manifest.version.as_str(), digest.as_str()) + != (&entry.id, &entry.version, &entry.digest) + { + let _ = std::fs::remove_dir_all(&dir); + return Err(IdeError::invalid("the download does not match the marketplace's entry")); + } + if self.verified_publisher(&dir).is_none() { + let _ = std::fs::remove_dir_all(&dir); + return Err(IdeError::invalid("the marketplace package is not signed by a trusted publisher")); + } + Ok(dir) + } + + /// Unpacks a package file (`.oiplugin`) next to the downloads; returns the folder. + pub fn unpack_package(&self, archive: &Path) -> IdeResult { + let stem = archive.file_stem().and_then(|s| s.to_str()).unwrap_or("package"); + let dir = self.plugins_dir.join(DOWNLOADS).join(format!("{stem}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let unpacked = package::unpack(archive, &dir).map_err(IdeError::invalid)?; + // Kept with the files: install checks it and keeps it with the installed copy. + if let Some(sig) = unpacked.signature { + let json = serde_json::to_string_pretty(&sig).map_err(|e| IdeError::internal(e.to_string()))?; + std::fs::write(dir.join(SIGNATURE_FILE), json)?; + } + Ok(dir) + } + + /// Files of the package in `dir` (what its declarations reference, and + /// the component): what a package file holds besides the manifest. + pub fn package_files_of(&self, dir: &Path) -> IdeResult> { + self.package_files_in(dir) + } + + pub(super) fn package_files_in(&self, dir: &Path) -> IdeResult> { + let manifest = Manifest::load(dir).map_err(IdeError::invalid)?; + let pkg = ide_extension::Package::new(manifest, dir.to_path_buf()); + let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?; + Ok(set.files.into_iter().chain(pkg.manifest.wasm.clone()).collect()) + } + + /// The publisher whose valid, trusted signature the package in `dir` carries. + pub(super) fn verified_publisher(&self, dir: &Path) -> Option { + let sig = read_signature(dir)?; + let index = self.market.read().clone()?; + let manifest = Manifest::load(dir).ok()?; + let files = self.package_files_in(dir).ok()?; + let digest = package::package_digest(dir, &files).ok()?; + index + .verify(&sig, &manifest.id, &manifest.version, &digest) + .map_err(|e| tracing::warn!("{}: {e}", dir.display())) + .ok() + } + + /// A signature that is present but doesn't verify (tampered, foreign prefix, + /// revoked or unknown key): installing is refused. + pub(super) fn signature_problem(&self, dir: &Path) -> Option { + let sig = read_signature(dir)?; + let manifest = Manifest::load(dir).ok()?; + let files = self.package_files_in(dir).ok()?; + let digest = package::package_digest(dir, &files).ok()?; + if let Err(e) = sig.check(&manifest.id, &manifest.version, &digest) { + return Some(e); + } + let index = self.market.read().clone()?; + index.verify(&sig, &manifest.id, &manifest.version, &digest).err() + } + + /// Why an installed plugin must not run (index revocations, revoked signing key). + pub(super) fn revoked_reason(&self, id: &str, version: &str, dir: &Path) -> Option { + let market = self.market.read(); + let index = market.as_ref()?; + if let Some(r) = index.revocation(id, version) { + return Some(r.reason.clone()); + } + let sig = read_signature(dir)?; + index.key_revoked(&sig.key).then(|| "its publisher's signing key was revoked".to_owned()) + } + + /// Disables installed plugins the index revokes (telling the user once), + /// and starts again those it no longer revokes. + pub(super) fn apply_revocations(&self) { + let installed: Vec<_> = + self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect(); + let mut changed = false; + for plugin in installed { + let reason = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir()); + let current = plugin.meta.lock().error.clone().filter(|e| e.starts_with("revoked: ")); + match (reason, current) { + (Some(r), Some(c)) if c == format!("revoked: {r}") => {} + (Some(reason), was) => { + self.stop(&plugin); + { + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::Failed; + meta.error = Some(format!("revoked: {reason}")); + } + if was.is_none() { + self.emit( + None, + super::PluginEvent::Notification { + plugin_id: plugin.id().to_owned(), + level: ide_plugin::NotificationLevel::Warning, + title: format!("{} was disabled", plugin.manifest().name), + body: format!("The marketplace revoked this plugin: {reason}"), + }, + ); + } + changed = true; + } + (None, Some(_)) => { + { + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::Inactive; + meta.error = None; + } + if !self.state.lock().disabled.contains(plugin.id()) { + self.start(&plugin); + } + changed = true; + } + (None, None) => {} + } + } + if changed { + self.changed(); + } + } +} + +pub(super) fn read_signature(dir: &Path) -> Option { + let text = std::fs::read_to_string(dir.join(SIGNATURE_FILE)).ok()?; + serde_json::from_str(&text).ok() +} + +/// Keeps the signature of a package with its installed copy. +pub(super) fn copy_signature(src: &Path, installed: &Path) { + if src.join(SIGNATURE_FILE).is_file() { + let _ = std::fs::copy(src.join(SIGNATURE_FILE), installed.join(SIGNATURE_FILE)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn downloads_resolve_against_the_index() { + assert_eq!(resolve("https://x.dev/market/index.json", "p/a.oiplugin"), "https://x.dev/market/p/a.oiplugin"); + assert_eq!(resolve("/srv/market/index.json", "a.oiplugin"), "/srv/market/a.oiplugin"); + assert_eq!(resolve("https://x.dev/index.json", "https://cdn.dev/a"), "https://cdn.dev/a"); + } +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs new file mode 100644 index 000000000..4ca93b2fb --- /dev/null +++ b/crates/ide-host/src/plugins/mod.rs @@ -0,0 +1,2266 @@ +//! The plugin host (`docs/plugin_plan.md` 3): assembles the extension +//! registry (`ide-extension`), the grant ledger and the Wasm runtime +//! (`ide-plugin`). It runs where the project is — in the desktop app for local +//! projects, in `openidea-server` for remote ones; the desktop shell only adapts +//! it to Tauri commands. +//! +//! Lifecycle of a package: its manifest is parsed and every declaration +//! validated by its extension point (no side effects); the grant ledger decides +//! whether it may load (project and new plugins wait for the user's consent); +//! then its contributions are committed all or nothing. Code is instantiated +//! only when one of its activation events fires (a command runs, or start-up +//! finished for `on-startup-finished`). +//! +//! Sources: `//` (installed), `/.openidea/plugins/*/` +//! (project plugins, loaded by [`PluginHost::sync_project`] for trusted +//! projects) and folders linked with `openidea-plugin link` (`links.json`). + +mod api; +mod icons; +mod install; +mod market; +mod points; +mod providers; +pub mod remote; +pub mod remote_host; +mod run_detectors; +mod theme; +mod types; +pub mod ui_points; +pub mod views; + +pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore}; +pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix}; +pub use market::{INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin}; +pub use points::{COMMANDS, CommandDecl, MAX_COMMANDS, register_builtin_points}; +pub(crate) use providers::editor_providers as editor_providers_for; +pub use providers::{ProviderKind, set_editor_providers}; +pub use theme::{ThemeDef, ThemeKind}; +pub use types::*; + +use ide_core::{IdeError, IdeResult, LanguageId}; +use ide_extension::ledger::{self, BinaryGrant, Decision, Ledger, Request, Subject}; +use ide_extension::{ + ActivationEvent, Capability, CapabilityInfo, ContributionIndex, ExtensionRegistry, MANIFEST_FILE, Manifest, + Package, Scope, UiContribution, UiStore, +}; +use ide_language::plugin_point::PluginLanguages; +use ide_language::{LanguageConfig, ProgramPin}; +use ide_plugin::{ + Actor, ActorError, CANCELLED, CallError, HostApi, Invocation, Limits, PluginEngine, Runtime, RuntimeSetup, +}; +use parking_lot::{Condvar, Mutex, RwLock}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +const STATE_FILE: &str = "state.json"; +const LINKS_FILE: &str = "links.json"; +/// Project plugins live here, relative to a project root. +pub const DEV_PLUGINS_DIR: &str = ".openidea/plugins"; + +#[derive(Default, Serialize, Deserialize)] +struct PersistedState { + #[serde(default)] + disabled: BTreeSet, + #[serde(default)] + theme: Option, + /// Marketplace index URL (or folder path). + #[serde(default, skip_serializing_if = "Option::is_none")] + market_url: Option, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +struct ThemeRef { + plugin: String, + theme: String, + /// The theme's window frame colour as the UI resolved it (`#rrggbb[aa]`), so + /// new windows open in it before the plugin is loaded. + #[serde(default, skip_serializing_if = "Option::is_none")] + frame: Option, +} + +#[derive(Default, Serialize, Deserialize)] +struct Links { + #[serde(default)] + paths: Vec, +} + +struct Meta { + status: PluginStatus, + error: Option, + warnings: Vec, + /// Capabilities the ledger granted (valid while live). + granted: Vec, + /// Required capabilities still awaiting consent (`NeedsConsent`). + pending: Vec, + /// Capabilities the declarations need beyond the manifest (`process:` of a language server). + declared_extra: Vec, + stats: PluginStats, + /// Durations of the recent calls (ms), for the p95. + latencies: VecDeque, + /// Consecutive crashes; reset by a call that returns. + failures: u32, + /// No restart before this (backoff after a crash). + retry_at: Option, + /// The component exports `syntax-provider` (known once activated). + has_syntax: bool, + /// Next `on-schedule` activation. + next_schedule: Option, + /// The trusted publisher of a signed installed package. + publisher: Option, +} + +impl Meta { + fn new() -> Self { + Self { + status: PluginStatus::Disabled, + error: None, + warnings: Vec::new(), + granted: Vec::new(), + pending: Vec::new(), + declared_extra: Vec::new(), + stats: PluginStats::default(), + latencies: VecDeque::new(), + failures: 0, + retry_at: None, + has_syntax: false, + publisher: None, + next_schedule: None, + } + } + + fn record_call(&mut self, elapsed: Duration, ok: bool) { + let ms = elapsed.as_secs_f64() * 1000.0; + self.stats.calls += 1; + self.stats.max_call_ms = self.stats.max_call_ms.max(ms); + if !ok { + self.stats.errors += 1; + } + if self.latencies.len() == LATENCY_WINDOW { + self.latencies.pop_front(); + } + self.latencies.push_back(ms as f32); + } + + fn p95(&self) -> f64 { + if self.latencies.is_empty() { + return 0.0; + } + let mut sorted: Vec = self.latencies.iter().copied().collect(); + sorted.sort_by(f32::total_cmp); + f64::from(sorted[(sorted.len() * 95).div_ceil(100).saturating_sub(1)]) + } +} + +/// Calls kept for the latency percentile. +const LATENCY_WINDOW: usize = 128; + +/// Events waiting for one plugin (from one window). +struct EventQueue { + since: Instant, + events: Vec, +} + +/// Most events queued per plugin and window; older ones are dropped. +const MAX_QUEUED_EVENTS: usize = 500; +/// How long events for a plugin that is not running are collected before it +/// is activated to receive them. +const IDLE_BATCH: Duration = Duration::from_secs(2); + +struct LoadedPlugin { + pkg: Package, + /// The manifest failed to parse: `pkg` is a placeholder. + invalid: bool, + source: PluginSource, + /// Project that owns a project plugin. + dev_root: Option, + meta: Mutex, + /// The running instance, on its own thread. + runtime: Mutex>>, + /// Event batches handed to the actor and not finished yet. + batches: Arc>, +} + +#[derive(Default)] +struct Batches { + /// Posted, not finished. + pending: u32, + /// Kinds of the batch being handled right now. + running: Option>, + /// A finished batch changed contributions: the next flush announces it. + changed: bool, +} + +impl LoadedPlugin { + /// Statistics of one call; a call that returned (`healthy`) ends a crash streak. + fn record_call(&self, elapsed: Duration, ok: bool, healthy: bool) { + let mut meta = self.meta.lock(); + meta.record_call(elapsed, ok); + if healthy && meta.failures > 0 { + meta.failures = 0; + meta.retry_at = None; + meta.error = None; + } + } + + fn id(&self) -> &str { + self.pkg.id() + } + + fn manifest(&self) -> &Manifest { + &self.pkg.manifest + } + + fn dir(&self) -> &Path { + &self.pkg.dir + } + + fn status(&self) -> PluginStatus { + self.meta.lock().status + } + + fn subject(&self) -> Subject { + match self.source { + PluginSource::Installed => Subject::Installed(self.id().to_owned()), + PluginSource::Dev | PluginSource::Linked => Subject::Path(self.dir().to_path_buf()), + } + } +} + +/// The user's answer on an install or consent page. +#[derive(Clone, Debug, Default, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Consent { + /// Optional capabilities the user accepted (`net:crates.io`, …). Required + /// ones are implied by consenting at all. + pub optional: Vec, +} + +impl Consent { + /// Accept everything the plugin asks for (tests, trusted official plugins). + pub fn all() -> Self { + Self { optional: vec!["*".into()] } + } + + fn accepts(&self, cap: &Capability) -> bool { + self.optional.iter().any(|s| s == "*" || s.parse::().is_ok_and(|c| c == *cap)) + } +} + +/// What the install page shows before anything is copied. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct InstallPreview { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub authors: Vec, + pub has_code: bool, + pub capabilities: Vec, + pub contributions: Vec, + pub warnings: Vec, + /// Version currently installed under the same id. + pub replaces: Option, + /// Not signed by a publisher the marketplace index trusts: warn the user. + pub unsigned: bool, + /// The verified publisher. + pub publisher: Option, +} + +type ProjectPolicy = Arc bool + Send + Sync>; +/// (plugin, window) of a batch of workbench events. +type QueueKey = (String, Option); +type PathVar = Arc Option + Send + Sync>; + +pub struct PluginHost { + project_policy: RwLock, + engine: PluginEngine, + plugins_dir: PathBuf, + sink: Arc, + limits: Limits, + registry: Arc, + ui: Arc, + languages: Arc, + run_detectors: Arc, + /// `[[contributes.providers]]` of enabled plugins. + providers: Arc, + /// Validated UI declarations of installed packages (start-up reuses them). + index: Arc, + ledger: Mutex, + plugins: RwLock>>, + state: Mutex, + /// What every plugin's [`api::PluginApi`] shares: sink, registry, + /// workspace, status items, tasks, subscriptions. + shared: Arc, + /// Workbench events waiting for delivery: (plugin, window) → batch. + queues: Mutex>, + /// Serializes loading / unloading so concurrent windows can't race. + lifecycle: Mutex<()>, + /// Set once the first [`load_all`](Self::load_all) has finished. + initial_load: (Mutex, Condvar), + /// `on-startup-finished` has fired. + startup_finished: AtomicBool, + /// The theme last announced with `ThemeChanged`. + announced_theme: Mutex>, + /// [`PluginLanguages::revision`] last announced with `LanguagesChanged`. + announced_languages: AtomicU64, + /// `PATH` used to resolve `process:` programs at grant time. + path_var: RwLock, + /// (plugin id, `process:` capability) → the approved program; read by the + /// language store's policy (plugin language servers and debug adapters). + pins: Arc>>, + /// The last marketplace index read: trust root for signatures, revocations. + market: RwLock>, + /// Updates not confirmed yet (the new version hasn't started once): what + /// to roll back to. + updates: Mutex>, +} + +struct PendingUpdate { + previous: String, + grant: Option, +} + +impl PluginHost { + /// Creates the host; call [`load_all`](Self::load_all) to load installed plugins. + pub fn new(plugins_dir: PathBuf, sink: Arc, limits: Limits) -> IdeResult { + std::fs::create_dir_all(&plugins_dir)?; + install::recover(&plugins_dir); + let state = std::fs::read_to_string(plugins_dir.join(STATE_FILE)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let registry = Arc::new(ExtensionRegistry::new()); + let ui = UiStore::new(); + let index = Arc::new(ContributionIndex::new( + plugins_dir.join(".cache").join("contributions"), + env!("CARGO_PKG_VERSION"), + )); + ui.set_index(Some(index.clone())); + let languages = PluginLanguages::new(); + let run_detectors: Arc = Arc::default(); + let providers: Arc = Arc::default(); + register_builtin_points(®istry, &ui, &languages, &run_detectors, &providers).map_err(IdeError::internal)?; + let pins: Arc>> = Arc::default(); + let policy_pins = pins.clone(); + languages.set_process_policy(Arc::new(move |plugin, cap| { + policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned() + })); + let shared = + api::Shared::new(sink.clone(), registry.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone()); + let market = RwLock::new(Self::load_market_index(&plugins_dir)); + let host = Self { + market, + updates: Mutex::default(), + project_policy: RwLock::new(Arc::new(|_| false)), + engine: PluginEngine::new().map_err(IdeError::internal)?, + ledger: Mutex::new(Ledger::load(&plugins_dir)), + plugins_dir, + sink, + limits, + registry, + ui, + languages, + run_detectors, + providers, + index, + plugins: RwLock::default(), + state: Mutex::new(state), + shared, + queues: Mutex::default(), + lifecycle: Mutex::new(()), + initial_load: (Mutex::new(false), Condvar::new()), + startup_finished: AtomicBool::new(false), + announced_theme: Mutex::new(None), + announced_languages: AtomicU64::new(0), + path_var: RwLock::new(Arc::new(|| std::env::var_os("PATH"))), + pins, + }; + host.refresh_pins(&host.ledger.lock()); + Ok(host) + } + + pub fn plugins_dir(&self) -> &Path { + &self.plugins_dir + } + + /// The extension registry: feature crates register their points here. + pub fn registry(&self) -> &Arc { + &self.registry + } + + /// `PATH` for resolving `process:` programs when they are granted. + pub fn set_program_path(&self, path: PathVar) { + *self.path_var.write() = path; + } + + /// Loads every installed and linked package (already loaded ones are kept). + /// Instantiates no Wasm: code runs on its activation events. + pub fn load_all(&self) { + let _guard = self.lifecycle.lock(); + let dirs = package_dirs(&self.plugins_dir); + self.migrate_grants(&dirs); + for dir in dirs { + if !self.is_loaded_from(&dir) { + self.load_dir(&dir, PluginSource::Installed, None); + } + } + for dir in self.links().paths { + if !self.is_loaded_from(&dir) && dir.join(MANIFEST_FILE).is_file() { + self.load_dir(&dir, PluginSource::Linked, None); + } + } + let (done, cv) = &self.initial_load; + *done.lock() = true; + cv.notify_all(); + self.changed(); + } + + /// Fires `on-startup-finished`: activates the plugins waiting for it. + /// Plugins loaded afterwards with that event activate right away. + pub fn startup_finished(&self) { + if self.startup_finished.swap(true, Ordering::AcqRel) { + return; + } + let waiting: Vec<_> = self + .plugins + .read() + .values() + .filter(|p| { + p.status() == PluginStatus::Inactive + && p.manifest().activation.contains(&ActivationEvent::StartupFinished) + }) + .cloned() + .collect(); + for p in waiting { + let _ = self.ensure_active(&p, None); + } + self.changed(); + } + + /// Where compiled components are kept between runs. + fn component_cache(&self) -> PathBuf { + self.plugins_dir.join(".cache").join("components") + } + + /// Precompiles every loaded plugin's component that is not cached yet and + /// drops cache entries no loaded plugin uses (after an engine upgrade or an + /// uninstall), compiled components and indexed contributions alike. Slow; + /// call it on a background thread after start-up. + pub fn warm_component_cache(&self) { + let digests: Vec> = self.plugins.read().values().filter_map(|p| p.pkg.digest.clone()).collect(); + self.index.prune(digests.iter().map(|d| &**d)); + let cache = self.component_cache(); + let wasm: Vec = self + .plugins + .read() + .values() + .filter(|p| p.status() != PluginStatus::Disabled) + .filter_map(|p| p.manifest().wasm.as_ref().map(|w| p.dir().join(w))) + .collect(); + let mut keep = BTreeSet::new(); + for file in wasm { + match self.engine.precompile(&file, &cache) { + Ok(name) => { + keep.insert(name); + } + Err(e) => tracing::debug!("precompiling {}: {e}", file.display()), + } + } + if let Ok(entries) = std::fs::read_dir(&cache) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if !keep.contains(&name) { + let _ = std::fs::remove_file(e.path()); + } + } + } + } + + /// Fires an activation event from the UI (`on-language:rust`, `on-file:…`, `on-view:…`). + pub fn activation_event(&self, event: &ActivationEvent, project: Option<&Path>) { + let targets: Vec<_> = self + .plugins + .read() + .values() + .filter(|p| p.status() == PluginStatus::Inactive) + .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) + .filter(|p| p.manifest().activation.iter().any(|a| activation_matches(a, event))) + .cloned() + .collect(); + if targets.is_empty() { + return; + } + for p in targets { + let _ = self.ensure_active(&p, None); + } + self.changed(); + } + + /// Waits (at most `timeout`) for the first [`load_all`](Self::load_all), so a + /// listing made at startup already holds the installed plugins and the + /// selected theme. Returns whether loading has finished. + pub fn wait_initial_load(&self, timeout: Duration) -> bool { + let (done, cv) = &self.initial_load; + let mut done = done.lock(); + if !*done { + cv.wait_while_for(&mut done, |d| !*d, timeout); + } + *done + } + + /// Whether project plugins of a root may load (project trust). + pub fn set_project_policy(&self, policy: ProjectPolicy) { + *self.project_policy.write() = policy; + } + + /// Unloads the project plugins of `root` (the project became untrusted or closed). + pub fn restrict_project(&self, root: &Path) { + let _guard = self.lifecycle.lock(); + let ids: Vec<_> = self + .plugins + .read() + .iter() + .filter(|(_, p)| p.dev_root.as_deref() == Some(root)) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + self.unload(&id); + } + self.changed(); + } + + /// Loads new project plugins from `/.openidea/plugins/` (trusted + /// projects only) and unloads vanished ones. Each still needs the user's + /// consent before it runs. + pub fn sync_project(&self, root: &Path) { + if !(self.project_policy.read())(root) { + self.restrict_project(root); + return; + } + let _guard = self.lifecycle.lock(); + let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins")); + let stale: Vec = self + .plugins + .read() + .iter() + .filter(|(_, p)| p.dev_root.as_deref() == Some(root) && !dirs.iter().any(|d| d == p.dir())) + .map(|(id, _)| id.clone()) + .collect(); + let mut changed = !stale.is_empty(); + for id in stale { + self.unload(&id); + } + for dir in dirs { + if !self.is_loaded_from(&dir) { + self.load_dir(&dir, PluginSource::Dev, Some(root.to_path_buf())); + changed = true; + } + } + if changed { + self.changed(); + } + } + + /// Re-reads `links.json` (written by `openidea-plugin link`): loads new + /// linked plugins and unloads unlinked ones. + pub fn sync_links(&self) { + let links = self.links().paths; + let _guard = self.lifecycle.lock(); + let stale: Vec = self + .plugins + .read() + .iter() + .filter(|(_, p)| p.source == PluginSource::Linked && !links.iter().any(|l| l == p.dir())) + .map(|(id, _)| id.clone()) + .collect(); + let mut changed = !stale.is_empty(); + for id in stale { + self.unload(&id); + } + for dir in links { + if !self.is_loaded_from(&dir) && dir.join(MANIFEST_FILE).is_file() { + self.load_dir(&dir, PluginSource::Linked, None); + changed = true; + } + } + if changed { + self.changed(); + } + } + + /// Links a development plugin folder (loaded until unlinked; granted by path). + pub fn link(&self, dir: &Path) -> IdeResult { + let dir = dunce::canonicalize(dir)?; + Manifest::load(&dir).map_err(IdeError::invalid)?; + let mut links = self.links(); + if !links.paths.contains(&dir) { + links.paths.push(dir.clone()); + self.save_links(&links)?; + } + self.sync_links(); + let id = self.plugins.read().values().find(|p| p.dir() == dir).map(|p| p.id().to_owned()); + self.info_of(&id.ok_or_else(|| IdeError::invalid("the linked plugin's id is already in use"))?) + } + + pub fn unlink(&self, id: &str) -> IdeResult<()> { + let plugin = self.get(id)?; + if plugin.source != PluginSource::Linked { + return Err(IdeError::invalid(format!("plugin {id:?} is not linked"))); + } + let mut links = self.links(); + links.paths.retain(|p| p != plugin.dir()); + self.save_links(&links)?; + self.sync_links(); + Ok(()) + } + + /// Installed and linked plugins plus the project plugins of `project`. + pub fn list(&self, project: Option<&Path>) -> PluginsSnapshot { + let visible: Vec> = self + .plugins + .read() + .values() + .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) + .cloned() + .collect(); + let ids: BTreeSet<&str> = visible.iter().map(|p| p.id()).collect(); + let ui: Vec = + self.ui.snapshot().into_iter().filter(|c| ids.contains(c.plugin_id.as_str())).collect(); + let mut plugins: Vec = visible.iter().map(|p| self.info(p)).collect(); + plugins.sort_by_key(|p| p.name.to_lowercase()); + PluginsSnapshot { plugins, active_theme: self.active_theme(), ui } + } + + pub fn info_of(&self, id: &str) -> IdeResult { + let plugin = self.get(id)?; + Ok(self.info(&plugin)) + } + + /// UI contributions of one point (all live plugins). + pub fn ui_items(&self, point: &str) -> Vec { + self.ui.items(point, None) + } + + /// Validated assets of a plugin whose id starts with `prefix` (icon theme SVGs). + pub fn assets(&self, id: &str, prefix: &str) -> IdeResult> { + let plugin = self.get(id)?; + if !plugin.status().is_live() { + return Err(IdeError::unavailable(format!("plugin {id:?} is not enabled"))); + } + Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect()) + } + + pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + { + let mut state = self.state.lock(); + if enabled { + state.disabled.remove(id) + } else { + state.disabled.insert(id.to_owned()) + }; + } + self.save_state(); + self.stop(&plugin); + if enabled { + self.start(&plugin); + } else { + plugin.meta.lock().status = PluginStatus::Disabled; + } + self.changed(); + Ok(()) + } + + /// Re-reads a plugin from disk (e.g. after rebuilding a dev plugin). + pub fn reload(&self, id: &str) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + let (dir, source, root) = (plugin.dir().to_path_buf(), plugin.source, plugin.dev_root.clone()); + self.unload(id); + let new_id = self.load_dir(&dir, source, root); + self.changed(); + self.info_of(&new_id) + } + + /// Validates a package for the install page: nothing is copied or run. + pub fn preview_install(&self, src: &Path) -> IdeResult { + let (pkg, set) = self.validate_package(src)?; + if let Some(problem) = self.signature_problem(src) { + return Err(IdeError::invalid(format!("refusing {}: {problem}", pkg.id()))); + } + let publisher = self.verified_publisher(src); + let m = &pkg.manifest; + let mut capabilities: Vec = + m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), false)).collect(); + for extra in &set.capabilities { + if !m.declares(extra) { + capabilities.push(CapabilityInfo::new(extra, true, false)); + } + } + capabilities.sort_by(|a, b| b.risk.cmp(&a.risk).then(a.id.cmp(&b.id))); + Ok(InstallPreview { + id: m.id.clone(), + name: m.name.clone(), + version: m.version.clone(), + description: m.description.clone(), + authors: m.authors.clone(), + has_code: m.wasm.is_some(), + capabilities, + contributions: counts(m), + warnings: set.warnings.clone(), + replaces: self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone()), + unsigned: publisher.is_none(), + publisher, + }) + } + + /// Installs the package in `src` (manifest + the files its declarations + /// reference) with the user's `consent`, replacing an installed version + /// with the same id atomically. + pub fn install_from_dir(&self, src: &Path, consent: &Consent) -> IdeResult { + let _guard = self.lifecycle.lock(); + let (pkg, set) = self.validate_package(src)?; + let id = pkg.id().to_owned(); + if let Some(existing) = self.plugins.read().get(&id) + && existing.source != PluginSource::Installed + { + return Err(IdeError::new( + ide_core::error::ErrorKind::AlreadyExists, + format!("a development plugin with id {id:?} is loaded from {}", existing.dir().display()), + )); + } + // A signature that doesn't hold is worse than none. + if let Some(problem) = self.signature_problem(src) { + return Err(IdeError::invalid(format!("refusing {id}: {problem}"))); + } + let version = pkg.manifest.version.clone(); + if let Some(r) = self.market.read().as_ref().and_then(|i| i.revocation(&id, &version)) { + return Err(IdeError::invalid(format!("{id} {version} was revoked: {}", r.reason))); + } + let mut files = set.files.clone(); + files.extend(pkg.manifest.wasm.clone()); + let hash = ledger::package_hash(src, &files)?; + let root = self.plugins_dir.join(&id); + let current = install::active_dir(&root); + let same_dir = dunce::canonicalize(src) + .ok() + .zip(current.as_ref().and_then(|c| dunce::canonicalize(c).ok())) + .is_some_and(|(a, b)| a == b); + let subject = Subject::Installed(id.clone()); + let old_grant = self.ledger.lock().get(&subject).cloned(); + self.unload(&id); + let previous = if same_dir { + std::fs::write(current.as_ref().expect("same dir").join(install::HASH_FILE), &hash)?; + None + } else { + let version_of = |d: &Path| Manifest::load(d).ok().map(|m| m.version); + let previous = install::install_version(src, &root, &version, &files, &hash, version_of)?; + market::copy_signature(src, &root.join(&version)); + previous + }; + // Consent covers what the install page showed. + let request = request_for(&subject, &pkg.manifest, &hash, &set.capabilities); + self.record_consent(&request, consent); + let dir = install::active_dir(&root).ok_or_else(|| IdeError::internal("the installed version is missing"))?; + self.load_dir(&dir, PluginSource::Installed, None); + if let Some(previous) = previous { + let failed = self + .get(&id) + .ok() + .filter(|p| p.status() == PluginStatus::Failed) + .map(|p| p.meta.lock().error.clone().unwrap_or_else(|| "it failed to load".into())); + if let Some(reason) = failed { + self.roll_back(&id, &previous, &version, old_grant); + self.changed(); + return Err(IdeError::invalid(format!( + "{} {version} could not start ({reason}); version {previous} was restored", + pkg.manifest.name + ))); + } + // Confirmed once the new version's code has started (or right away without code). + if pkg.manifest.wasm.is_some() { + self.updates.lock().insert(id.clone(), PendingUpdate { previous, grant: old_grant }); + } + } + install::prune(&root); + self.changed(); + self.info_of(&id) + } + + /// Makes `previous` the active version of `id` again and drops `failed` + /// (its directory, its consent). Called with the plugin unloaded or not. + fn roll_back(&self, id: &str, previous: &str, failed: &str, grant: Option) { + let root = self.plugins_dir.join(id); + self.unload(id); + if let Err(e) = install::activate_version(&root, previous) { + tracing::warn!("plugin {id}: rolling back to {previous}: {e}"); + return; + } + if failed != previous { + let _ = std::fs::remove_dir_all(root.join(failed)); + } + { + let mut ledger = self.ledger.lock(); + ledger.restore(&Subject::Installed(id.to_owned()), grant); + self.save_ledger(&ledger); + } + self.updates.lock().remove(id); + if let Some(dir) = install::active_dir(&root) { + self.load_dir(&dir, PluginSource::Installed, None); + } + } + + /// An update whose code failed to start: back to the previous version. + /// Returns the message for the user when it rolled back. + fn roll_back_failed_update( + &self, + plugin: &Arc, + reason: &str, + client: Option<&str>, + ) -> Option { + let id = plugin.id().to_owned(); + let pending = self.updates.lock().remove(&id)?; + let version = plugin.manifest().version.clone(); + let name = plugin.manifest().name.clone(); + // The caller may hold the lifecycle lock (activation during start-up): don't wait for it forever. + let _guard = self.lifecycle.try_lock_for(Duration::from_secs(5)); + self.roll_back(&id, &pending.previous, &version, pending.grant); + let message = format!("{name} {version} failed to start ({reason}); version {} was restored", pending.previous); + self.emit( + client, + PluginEvent::Notification { + plugin_id: id, + level: ide_plugin::NotificationLevel::Warning, + title: format!("{name} update rolled back"), + body: message.clone(), + }, + ); + self.changed(); + Some(message) + } + + /// Settings › Plugins › Roll Back: the version the last update replaced. + pub fn roll_back_update(&self, id: &str) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + if plugin.source != PluginSource::Installed { + return Err(IdeError::invalid("only installed plugins have versions to roll back to")); + } + let root = self.plugins_dir.join(id); + let previous = install::previous_version(&root) + .ok_or_else(|| IdeError::not_found(format!("{id} has no earlier version to roll back to")))?; + let current = plugin.manifest().version.clone(); + // The earlier version's consent: what it declared, as granted now where it overlaps. + let grant = self.ledger.lock().get(&Subject::Installed(id.to_owned())).cloned(); + self.roll_back(id, &previous, ¤t, grant); + self.changed(); + self.info_of(id) + } + + /// Confirms a plugin waiting for consent (project plugins, new capabilities). + pub fn grant(&self, id: &str, consent: &Consent) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + if plugin.invalid { + return Err(IdeError::invalid("the plugin's manifest is invalid")); + } + let hash = self.package_hash_of(&plugin); + let extra = plugin.meta.lock().declared_extra.clone(); + let request = request_for(&plugin.subject(), plugin.manifest(), &hash, &extra); + self.record_consent(&request, consent); + self.stop(&plugin); + if !self.state.lock().disabled.contains(id) { + self.start(&plugin); + } + self.changed(); + self.info_of(id) + } + + /// Grants or revokes one capability (Settings › Plugins › Permissions). + /// Revoking a required one stops the plugin until consent is given again. + pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + let cap: Capability = capability.parse().map_err(IdeError::invalid)?; + if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) { + return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}"))); + } + { + let mut ledger = self.ledger.lock(); + let subject = plugin.subject(); + if ledger.get(&subject).is_none() { + return Err(IdeError::invalid(format!("plugin {id:?} has not been confirmed yet"))); + } + ledger.set(&subject, &cap, granted); + if granted + && let Capability::Process(program) = &cap + && let Some(bin) = self.resolve_binary(program) + { + ledger.set_binary(&subject, &cap, bin); + } + self.save_ledger(&ledger); + } + self.stop(&plugin); + if !self.state.lock().disabled.contains(id) { + self.start(&plugin); + } + self.changed(); + self.info_of(id) + } + + /// The program a `process:` grant allows, checked against the file + /// approved for it. Errors ask the user to confirm again. + pub fn program_for(&self, id: &str, program: &str) -> IdeResult { + let plugin = self.get(id)?; + let cap = Capability::Process(program.to_owned()); + self.ledger + .lock() + .check_binary(&plugin.subject(), &cap) + .map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e)) + } + + pub fn uninstall(&self, id: &str) -> IdeResult<()> { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + if plugin.source != PluginSource::Installed { + return Err(IdeError::invalid( + "development plugins are removed by deleting them from .openidea/plugins or unlinking them", + )); + } + self.unload(id); + // Every version (`/`), or the flat directory of an older install. + let root = self.plugins_dir.join(id); + std::fs::remove_dir_all(if root.is_dir() { root } else { plugin.dir().to_path_buf() })?; + self.updates.lock().remove(id); + // Its storage goes with it. + let _ = std::fs::remove_dir_all(self.shared.data_dir.join(id)); + { + let mut ledger = self.ledger.lock(); + ledger.remove(&plugin.subject()); + self.save_ledger(&ledger); + } + { + let mut state = self.state.lock(); + state.disabled.remove(id); + if state.theme.as_ref().is_some_and(|t| t.plugin == id) { + state.theme = None; + } + } + self.save_state(); + self.changed(); + Ok(()) + } + + /// Runs a plugin command, activating the plugin first when needed + /// (`on-command`). Blocks for up to the configured time limit. + pub fn run_command( + &self, + id: &str, + command: &str, + arg: Option<&str>, + invocation: Invocation, + ) -> IdeResult> { + let (cmd, arg) = (command.to_owned(), arg.map(str::to_owned)); + self.code_call(id, invocation, "running the command", move |rt, inv| rt.run_command(&cmd, arg.as_deref(), inv)) + } + + /// Content of a declared view, or of a custom editor (`invocation.editor` + /// is its document). Activates the plugin (`on-view`). + pub fn render_view( + &self, + id: &str, + view: &str, + viewport: Option, + invocation: Invocation, + ) -> IdeResult { + self.declared_view(id, view)?; + let view = view.to_owned(); + self.code_call(id, invocation, "rendering the view", move |rt, inv| rt.render_view(&view, viewport, inv)) + } + + /// A user action in a view or custom editor. + pub fn view_event( + &self, + id: &str, + view: &str, + event: ide_plugin::ViewEvent, + invocation: Invocation, + ) -> IdeResult<()> { + self.declared_view(id, view)?; + let view = view.to_owned(); + self.code_call(id, invocation, "handling the view's event", move |rt, inv| { + rt.view_event(&view, event.clone(), inv).map(|fx| ((), fx)) + }) + } + + fn declared_view(&self, id: &str, view: &str) -> IdeResult<()> { + let declared = [ui_points::VIEWS, ui_points::CUSTOM_EDITORS] + .iter() + .any(|point| self.ui.items(point, Some(id)).iter().any(|i| i.key == view)); + if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) } + } + + /// One call into a plugin's code for the UI: checks it may run (asking + /// for missing consent), activates it, retries once when the instance was + /// reclaimed meanwhile, and reports crashes. + fn code_call( + &self, + id: &str, + invocation: Invocation, + what: &str, + f: impl Fn(&mut Runtime, Invocation) -> Result<(R, ide_plugin::CallEffects), CallError> + Clone + Send + 'static, + ) -> IdeResult { + let plugin = self.get(id)?; + let client = invocation.client.clone(); + { + let meta = plugin.meta.lock(); + let reason = match meta.status { + PluginStatus::Disabled => Some(format!("plugin {id:?} is disabled")), + PluginStatus::Static => Some(format!("plugin {id:?} has no code")), + PluginStatus::NeedsConsent => Some(format!("plugin {id:?} is waiting for your permission")), + PluginStatus::Failed => { + Some(format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed"))) + } + PluginStatus::Active | PluginStatus::Inactive => None, + }; + if let Some(reason) = reason { + return Err(IdeError::unavailable(reason)); + } + } + let pending = plugin.meta.lock().pending.clone(); + if !pending.is_empty() { + // Migrated from 0.1: ask for what was not granted automatically, on first use. + self.emit( + client.as_deref(), + PluginEvent::ConsentRequested { + plugin_id: id.to_owned(), + capabilities: pending.iter().map(|c| CapabilityInfo::new(c, false, false)).collect(), + }, + ); + return Err(IdeError::new( + ide_core::error::ErrorKind::PermissionDenied, + format!("plugin {id:?} needs your permission first"), + )); + } + // Twice at most: the instance may be reclaimed between picking it and queueing the call. + for attempt in 0..2 { + let (actor, activated) = self.ensure_active(&plugin, client.as_deref())?; + if activated { + self.changed(); + } + let started = Instant::now(); + let (call, inv) = (f.clone(), invocation.clone()); + let result = actor.call(None, move |rt| call(rt, inv)); + if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) { + let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_)))); + self.record_call(&plugin, started.elapsed(), result.is_ok(), !crashed); + } + match result { + Ok((value, fx)) => { + if fx.contributions_changed { + self.changed(); + } + return Ok(value); + } + Err(ActorError::Call(CallError::Guest(e))) => return Err(IdeError::internal(e)), + Err(ActorError::Call(CallError::Trap(e))) => { + self.reap(&plugin, &actor, client.as_deref()); + return Err(IdeError::internal(format!("plugin {id:?} {e}"))); + } + Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled) => { + self.reap(&plugin, &actor, client.as_deref()); + if attempt == 1 { + break; + } + } + } + } + Err(IdeError::unavailable(format!("plugin {id:?} stopped before {what}; try again"))) + } + + /// Interrupts the plugin's running call, if any. Its instance is dropped + /// and started again on next use; this does not count as a crash. + pub fn cancel(&self, id: &str) { + if let Some(actor) = self.plugins.read().get(id).and_then(|p| p.runtime.lock().clone()) { + actor.cancel(); + } + } + + /// Runs a `syntax-provider` call of the plugin contributing `language`, + /// activating it on first use. `None` when no running plugin provides it + /// (or the call failed: the editor then shows plain text). + fn syntax_call( + &self, + language: &LanguageId, + f: impl FnOnce(&mut Runtime, Duration) -> Result + Send + 'static, + ) -> Option { + let owner = self.languages.owner_of(language)?; + let plugin = self.get(&owner).ok()?; + { + let meta = plugin.meta.lock(); + let usable = + matches!(meta.status, PluginStatus::Active | PluginStatus::Inactive) && meta.pending.is_empty(); + // Known not to provide syntax (activated before without it). + if !usable || (meta.stats.activations > 0 && !meta.has_syntax) { + return None; + } + } + plugin.manifest().wasm.as_ref()?; + let (actor, activated) = self.ensure_active(&plugin, None).ok()?; + if activated { + self.changed(); + } + if !plugin.meta.lock().has_syntax { + return None; + } + // Edit path: stop waiting at the soft deadline (the result is dropped + // but the instance kept); interrupt at the hard one. + let started = Instant::now(); + let hard = self.limits.edit_hard; + let result = actor.call(Some(self.limits.edit_soft), move |rt| f(rt, hard)); + self.finish_provider_call(&plugin, &actor, started, result) + } + + /// Plugins' `runDetectors`, for [`ide_run::set_external_detection`]. + pub fn run_detection(&self) -> Arc { + Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() }) + } + + /// The syntax of plugin languages, for [`ide_language::set_external_syntax`]. + pub fn syntax_provider(self: &Arc) -> Arc { + Arc::new(PluginSyntax(Arc::downgrade(self))) + } + + /// The UI reports which file its focused editor shows (`editor-focused`). + pub fn set_active_file(&self, client: &str, path: Option) { + self.post_events(Some(client), vec![WorkbenchEvent::EditorFocused(path)]); + } + + /// Where plugin secrets are kept (default: files under the plugins + /// directory; the desktop app uses the OS keychain). + pub fn set_secret_store(&self, store: Arc) { + *self.shared.secrets.write() = store; + } + + /// Stores (or, with `None`, deletes) a secret the plugin declares under + /// `[[contributes.secrets]]`. + pub fn set_secret(&self, id: &str, name: &str, value: Option<&str>) -> IdeResult<()> { + let plugin = self.get(id)?; + if !self.ui.items(providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == name) { + return Err(IdeError::not_found(format!("plugin {id:?} declares no secret {name:?}"))); + } + let value = value.map(str::trim).filter(|v| !v.is_empty()); + self.shared.secrets.read().set(plugin.id(), name, value).map_err(IdeError::internal) + } + + /// The declared secrets of a plugin and whether each is set. + pub fn secrets(&self, id: &str) -> IdeResult> { + let plugin = self.get(id)?; + let store = self.shared.secrets.read().clone(); + Ok(self + .ui + .items(providers::SECRETS_POINT, Some(plugin.id())) + .into_iter() + .map(|s| { + let set = store.get(plugin.id(), &s.key).is_some(); + (s.key, set) + }) + .collect()) + } + + /// What plugins see of the open projects (documents, edits, trust). + pub fn set_workspace(&self, workspace: Option>) { + *self.shared.workspace.write() = workspace; + } + + /// Every plugin status bar item: (plugin, item id, item). + pub fn status_items(&self) -> Vec<(String, String, ide_plugin::StatusItem)> { + self.shared.status_items() + } + + /// The user cancelled a plugin task (`task_id` from `TaskStarted`). + pub fn cancel_task(&self, task_id: &str) -> bool { + task_id.parse().is_ok_and(|id| self.shared.cancel_task(id)) + } + + /// Kinds of workbench events some running or activatable plugin wants; + /// the UI reports only these. + pub fn event_kinds(&self) -> BTreeSet { + let subs = self.shared.subscriptions.lock().clone(); + let mut out = BTreeSet::new(); + for p in self.plugins.read().values() { + for kind in EventKind::ALL { + if self.wants(p, kind, subs.get(p.id())) { + out.insert(kind); + } + } + } + out + } + + /// Whether `plugin` gets `kind` events: subscribed at run time or with + /// `on-event:`, the capability granted, and able to run. + fn wants(&self, plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet>) -> bool { + let declared = + plugin.manifest().activation.iter().any(|a| matches!(a, ActivationEvent::Event(k) if k == kind.name())); + if !declared && !runtime.is_some_and(|s| s.contains(&kind)) { + return false; + } + let meta = plugin.meta.lock(); + matches!(meta.status, PluginStatus::Active | PluginStatus::Inactive) + && kind.capability().is_none_or(|cap| meta.granted.iter().any(|g| g.covers(&cap))) + } + + /// Queues workbench events from window `client` for the plugins that want + /// them; [`flush_events`](Self::flush_events) delivers them in batches. + pub fn post_events(&self, client: Option<&str>, events: Vec) { + if events.is_empty() { + return; + } + let subs = self.shared.subscriptions.lock().clone(); + let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); + let now = Instant::now(); + let mut queues = self.queues.lock(); + for p in plugins { + let wanted: Vec = + events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect(); + if wanted.is_empty() { + continue; + } + let queue = queues + .entry((p.id().to_owned(), client.map(str::to_owned))) + .or_insert_with(|| EventQueue { since: now, events: Vec::new() }); + queue.events.extend(wanted); + let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS); + queue.events.drain(..excess); + } + } + + /// Delivers queued events: to running plugins right away, to idle ones + /// (which must be activated) after [`IDLE_BATCH`], so frequent events + /// don't keep waking them. Call it a few times a second. + pub fn flush_events(&self) { + self.flush_events_at(Instant::now()); + } + + #[doc(hidden)] + pub fn flush_events_at(&self, now: Instant) { + let due: Vec<(QueueKey, Vec)> = { + let mut queues = self.queues.lock(); + let plugins = self.plugins.read(); + let keys: Vec<_> = queues + .iter() + .filter(|((id, _), q)| { + let running = + plugins.get(id).is_some_and(|p| p.runtime.lock().as_ref().is_some_and(|a| a.is_alive())); + running + || now.saturating_duration_since(q.since) >= IDLE_BATCH + || q.events.len() >= MAX_QUEUED_EVENTS / 2 + || q.events.contains(&WorkbenchEvent::Scheduled) + }) + .map(|(k, _)| k.clone()) + .collect(); + keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q.events))).collect() + }; + let mut changed = false; + for ((id, client), events) in due { + changed |= self.deliver(&id, client, events); + } + // Batches that finished since: contributions they changed, crashes. + let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); + for p in plugins { + changed |= std::mem::take(&mut p.batches.lock().changed); + let dead = p.runtime.lock().clone().filter(|a| !a.is_alive()); + if let Some(actor) = dead { + self.reap(&p, &actor, None); + } + } + if changed { + self.changed(); + } + } + + /// Waits (at most `timeout`) until every posted event batch was handled. + /// Returns whether they all were. + #[doc(hidden)] + pub fn wait_events(&self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + let busy = self.plugins.read().values().any(|p| p.batches.lock().pending > 0); + if !busy { + self.flush_events_at(Instant::now()); + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(5)); + } + } + + /// Hands one batch to the plugin's `event-handler.handle` without waiting + /// for it. A newer batch of the same kinds cooperatively cancels the one + /// being handled (a save while the previous save is still being checked). + /// Returns whether the plugin was activated. + fn deliver(&self, id: &str, client: Option, events: Vec) -> bool { + let Ok(plugin) = self.get(id) else { return false }; + if !plugin.meta.lock().pending.is_empty() { + return false; + } + let Ok((actor, activated)) = self.ensure_active(&plugin, client.as_deref()) else { return false }; + let invocation = Invocation { + workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)), + client: client.clone(), + editor: None, + }; + let kinds: BTreeSet = events.iter().map(WorkbenchEvent::kind).collect(); + { + let mut batches = plugin.batches.lock(); + if batches.running.as_ref() == Some(&kinds) && !kinds.contains(&EventKind::Scheduled) { + actor.cancel_soft(); + } + batches.pending += 1; + } + let started = Instant::now(); + let (start, finish) = (plugin.batches.clone(), plugin.batches.clone()); + let done_plugin = plugin.clone(); + actor.post( + move |rt| { + start.lock().running = Some(kinds); + rt.handle_events(events, invocation) + }, + move |result| { + let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_)))); + if !matches!(result, Err(ActorError::Stopped)) { + done_plugin.record_call(started.elapsed(), result.is_ok(), !crashed); + } + match &result { + Err(ActorError::Call(CallError::Guest(e))) => { + tracing::debug!(plugin = %done_plugin.id(), "events: {e}") + } + Err(ActorError::Call(CallError::Trap(e))) => { + tracing::warn!(plugin = %done_plugin.id(), "events: {e}") + } + _ => {} + } + let mut batches = finish.lock(); + batches.pending = batches.pending.saturating_sub(1); + batches.running = None; + batches.changed |= result.is_ok_and(|fx| fx.contributions_changed); + }, + ); + activated + } + + /// Selects a plugin theme (`None` restores the built-in theme). + pub fn set_theme(&self, selection: Option<(&str, &str)>) -> IdeResult> { + if let Some((plugin, theme)) = selection { + self.get(plugin)?; + if !self.ui.items(theme::POINT_ID, Some(plugin)).iter().any(|t| t.key == theme) { + return Err(IdeError::not_found(format!("plugin {plugin:?} has no theme {theme:?}"))); + } + } + self.state.lock().theme = + selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into(), frame: None }); + self.save_state(); + let active = self.active_theme(); + *self.announced_theme.lock() = active.clone(); + self.emit(None, PluginEvent::ThemeChanged { theme: active.clone() }); + Ok(active) + } + + /// Records the selected theme's resolved window frame colour (`#rgb`, + /// `#rrggbb` or `#rrggbbaa`); ignored without a selected theme. + pub fn set_theme_frame(&self, frame: Option<&str>) -> IdeResult<()> { + if let Some(f) = frame + && !is_hex_color(f) + { + return Err(IdeError::invalid(format!("not a hex colour: {f:?}"))); + } + let changed = match self.state.lock().theme.as_mut() { + Some(t) if t.frame.as_deref() != frame => { + t.frame = frame.map(str::to_owned); + true + } + _ => false, + }; + if changed { + self.save_state(); + } + Ok(()) + } + + /// Frame colour for a new window: the recorded one of the selected theme, + /// unless its plugin is disabled. Available before plugins are loaded. + pub fn startup_frame(&self) -> Option { + let state = self.state.lock(); + let t = state.theme.as_ref()?; + if state.disabled.contains(&t.plugin) { + return None; + } + t.frame.clone() + } + + /// The selected plugin theme, when its plugin is enabled and live. + pub fn active_theme(&self) -> Option { + let sel = self.state.lock().theme.clone()?; + let item = self.ui.items(theme::POINT_ID, Some(&sel.plugin)).into_iter().find(|t| t.key == sel.theme)?; + let theme: ThemeDef = serde_json::from_value(item.decl).ok()?; + Some(ActiveTheme { plugin_id: sel.plugin, theme }) + } + + /// Languages contributed by live plugins. + pub fn languages(&self) -> Vec { + self.languages.all() + } + + /// Plugin languages for a remote project's server, with the programs each + /// plugin was granted ([`remote::RemotePluginLanguages::sync`]). + pub fn remote_languages(&self) -> Vec { + let plugins = self.plugins.read(); + let ledger = self.ledger.lock(); + self.languages + .with_owners() + .into_iter() + .map(|(plugin, language)| { + let grant = plugins.get(&plugin).and_then(|p| ledger.get(&p.subject()).cloned()); + let granted = ide_language::plugin_point::required_capabilities(&language) + .into_iter() + .filter(|cap| grant.as_ref().is_some_and(|g| g.confirmed && g.has(cap))) + .filter_map(|cap| match cap { + Capability::Process(program) => Some(program), + _ => None, + }) + .collect(); + remote::RemoteLanguage { plugin, language, granted } + }) + .collect() + } + + /// The overlay of plugin languages (for the language registry). + pub fn language_store(&self) -> &Arc { + &self.languages + } + + /// Plugin packages found in `dirs` (first occurrence of an id wins). Invalid + /// manifests are skipped; packages that fail validation carry a `problem`. + pub fn catalog(&self, dirs: &[PathBuf]) -> Vec { + let mut out: Vec = Vec::new(); + for dir in dirs.iter().flat_map(|d| package_dirs(d)) { + let Ok(m) = Manifest::load(&dir) else { continue }; + if out.iter().any(|c| c.id == m.id) { + continue; + } + let pkg = Package::new(m.clone(), dir.clone()); + let problem = match self.registry.prepare(&pkg) { + Err(e) => Some(e), + Ok(_) => m + .wasm + .as_ref() + .filter(|w| !dir.join(w).is_file()) + .map(|w| format!("{} is missing; build the plugin first", w.display())), + }; + out.push(CatalogPlugin { + has_code: m.wasm.is_some(), + capabilities: m + .capabilities + .all() + .map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), false)) + .collect(), + contributions: counts(&m), + id: m.id, + name: m.name, + version: m.version, + description: m.description, + authors: m.authors, + homepage: m.homepage, + change_notes: m.change_notes, + path: dir.into(), + problem, + }); + } + out.sort_by_key(|a| a.name.to_lowercase()); + out + } + + /// Deactivates every plugin (also done on drop). + pub fn shutdown(&self) { + let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect(); + for a in &actors { + a.stop(false); + } + for a in actors { + a.stop(true); + } + } + + /// Periodic upkeep (every few seconds): reports crashes nobody waited + /// for, reclaims idle instances and fires due `on-schedule` activations + /// (which don't keep a plugin alive: it is reclaimed again when idle). + pub fn tick(&self) { + self.tick_at(Instant::now()); + } + + /// [`tick`](Self::tick) as if it were `now` (schedules only; tests). + #[doc(hidden)] + pub fn tick_at(&self, now: Instant) { + let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); + let mut changed = false; + for p in plugins { + let actor = p.runtime.lock().clone(); + if let Some(a) = &actor { + if !a.is_alive() { + self.reap(&p, a, None); + continue; + } + if self.limits.idle_timeout.is_some_and(|idle| !a.is_busy() && a.idle_for() >= idle) { + changed |= self.reclaim(&p, a); + } + } + let every = p.manifest().activation.iter().filter_map(|e| match e { + ActivationEvent::Schedule(secs) => Some(Duration::from_secs(u64::from(*secs).max(1))), + _ => None, + }); + let Some(every) = every.min() else { continue }; + let due = { + let mut meta = p.meta.lock(); + let next = *meta.next_schedule.get_or_insert(now + every); + if next > now { + false + } else { + meta.next_schedule = Some(now + every); + meta.status == PluginStatus::Inactive && meta.pending.is_empty() + } + }; + let restricted = p.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)); + if due && !restricted && self.startup_finished.load(Ordering::Acquire) { + // Activates it if needed and calls `handle([scheduled])`. + self.queues + .lock() + .entry((p.id().to_owned(), None)) + .or_insert_with(|| EventQueue { since: now, events: Vec::new() }) + .events + .push(WorkbenchEvent::Scheduled); + } + } + if changed { + self.changed(); + } + self.flush_events_at(now); + } + + /// Number of plugins with a live Wasm instance. + pub fn instances(&self) -> usize { + self.plugins.read().values().filter(|p| p.runtime.lock().as_ref().is_some_and(|a| a.is_alive())).count() + } + + // ---- internals ---- + + fn get(&self, id: &str) -> IdeResult> { + let plugin = + self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?; + if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) { + return Err(IdeError::unavailable("Project is in Restricted Mode")); + } + Ok(plugin) + } + + fn is_loaded_from(&self, dir: &Path) -> bool { + self.plugins.read().values().any(|p| p.dir() == dir) + } + + /// Parses and validates a package directory; nothing takes effect. + fn validate_package(&self, src: &Path) -> IdeResult<(Package, ide_extension::PreparedSet)> { + let manifest = Manifest::load(src).map_err(IdeError::invalid)?; + let pkg = Package::new(manifest, src.to_path_buf()); + let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?; + if let Some(wasm) = &pkg.manifest.wasm { + if !src.join(wasm).is_file() { + return Err(IdeError::invalid(format!( + "{} is missing (referenced by plugin.toml); build the plugin first", + wasm.display() + ))); + } + // Precompiled on the way: the installed copy has the same bytes. + self.engine.compile_cached(&src.join(wasm), &self.component_cache()).map_err(IdeError::invalid)?; + } + Ok((pkg, set)) + } + + /// Loads one package and starts it unless disabled. Returns the plugin id. + fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option) -> String { + let (manifest, error) = match Manifest::load(dir) { + Ok(m) => (m, None), + Err(e) => (placeholder_manifest(dir), Some(e)), + }; + let invalid = error.is_some(); + let id = manifest.id.clone(); + let mut pkg = Package::new(manifest, dir.to_path_buf()); + if source == PluginSource::Installed + && let Ok(digest) = std::fs::read_to_string(dir.join(install::HASH_FILE)) + && !digest.trim().is_empty() + { + // Installed copies don't change: their validated UI declarations are indexed. + pkg = pkg.with_digest(digest.trim()); + } + let plugin = Arc::new(LoadedPlugin { + pkg, + invalid, + source, + dev_root, + meta: Mutex::new(Meta::new()), + batches: Arc::default(), + runtime: Mutex::new(None), + }); + { + let mut plugins = self.plugins.write(); + if let Some(existing) = plugins.get(&id) { + tracing::warn!( + "plugin {id:?} at {} ignored: already loaded from {}", + dir.display(), + existing.dir().display() + ); + return id; + } + plugins.insert(id.clone(), plugin.clone()); + } + if let Some(e) = error { + tracing::warn!("plugin {}: {e}", dir.display()); + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::Failed; + meta.error = Some(e); + } else if !self.state.lock().disabled.contains(&id) { + self.start(&plugin); + } + id + } + + fn unload(&self, id: &str) { + let Some(plugin) = self.plugins.write().remove(id) else { return }; + self.stop(&plugin); + } + + /// Validates, checks grants and commits the contributions; activates code + /// whose activation event already fired. + fn start(&self, plugin: &Arc) { + self.registry.dispose_plugin(plugin.id()); + if plugin.source == PluginSource::Installed + && let Some(reason) = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir()) + { + return self.set_failed(plugin, format!("revoked: {reason}")); + } + if plugin.source == PluginSource::Installed { + let publisher = self.verified_publisher(plugin.dir()); + plugin.meta.lock().publisher = publisher; + } + let set = match self.registry.prepare(&plugin.pkg) { + Ok(set) => set, + Err(e) => return self.set_failed(plugin, e), + }; + let hash = self.package_hash_of(plugin); + let request = request_for(&plugin.subject(), plugin.manifest(), &hash, &set.capabilities); + let decision = { + let mut ledger = self.ledger.lock(); + let d = ledger.decide(&request); + self.save_ledger(&ledger); + d + }; + { + let mut meta = plugin.meta.lock(); + meta.warnings = set.warnings.clone(); + meta.declared_extra = set.capabilities.clone(); + meta.error = None; + } + let (granted, pending) = match decision { + Decision::Allowed { granted, pending } => (granted, pending), + Decision::NeedsConfirmation { new, .. } => { + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::NeedsConsent; + meta.pending = new; + meta.granted.clear(); + return; + } + }; + if let Err(e) = self.registry.commit(set, Scope::Static) { + return self.set_failed(plugin, e); + } + { + let mut meta = plugin.meta.lock(); + meta.granted = granted; + meta.pending = pending; + meta.status = if plugin.manifest().wasm.is_some() { PluginStatus::Inactive } else { PluginStatus::Static }; + } + let eager = plugin.manifest().activation.contains(&ActivationEvent::StartupFinished) + && self.startup_finished.load(Ordering::Acquire); + if eager { + let _ = self.ensure_active(plugin, None); + } + } + + /// The plugin's running instance, instantiating and activating its code + /// first if needed. Returns whether it was activated now. + fn ensure_active(&self, plugin: &Arc, client: Option<&str>) -> IdeResult<(Arc, bool)> { + let mut runtime = plugin.runtime.lock(); + if let Some(actor) = runtime.as_ref() { + if actor.is_alive() { + return Ok((actor.clone(), false)); + } + // Crashed while nobody was waiting for it. + let actor = actor.clone(); + drop(runtime); + self.reap(plugin, &actor, client); + runtime = plugin.runtime.lock(); + if let Some(actor) = runtime.as_ref().filter(|a| a.is_alive()) { + return Ok((actor.clone(), false)); + } + } + if plugin.status() != PluginStatus::Inactive { + return Err(IdeError::unavailable(format!("plugin {:?} cannot be activated", plugin.id()))); + } + if let Some(wait) = plugin.meta.lock().retry_at.map(|at| at.saturating_duration_since(Instant::now())) + && !wait.is_zero() + { + return Err(IdeError::unavailable(format!( + "plugin {:?} crashed and restarts in {:.1}s", + plugin.id(), + wait.as_secs_f64() + ))); + } + let Some(wasm) = plugin.manifest().wasm.clone() else { + return Err(IdeError::unavailable(format!("plugin {:?} has no code", plugin.id()))); + }; + let started = Instant::now(); + let granted = plugin.meta.lock().granted.clone(); + let api: Arc = Arc::new(api::PluginApi { pkg: plugin.pkg.clone(), shared: self.shared.clone() }); + let setup = RuntimeSetup { + plugin_id: plugin.id().to_owned(), + version: plugin.manifest().version.clone(), + granted, + host: api, + limits: self.limits, + }; + let instance = self + .engine + .compile_cached(&plugin.dir().join(wasm), &self.component_cache()) + .and_then(|component| Runtime::instantiate(&self.engine, &component, setup)); + let mut rt = match instance { + Ok(rt) => rt, + Err(e) => { + drop(runtime); + if let Some(message) = self.roll_back_failed_update(plugin, &e, client) { + return Err(IdeError::unavailable(message)); + } + self.fail(plugin, &e, client); + return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id()))); + } + }; + let crash = match rt.activate() { + Ok(_) => None, + Err(CallError::Trap(e)) => Some(format!("activation {e}")), + Err(CallError::Guest(e)) => { + drop(runtime); + let e = format!("activation failed: {e}"); + if let Some(message) = self.roll_back_failed_update(plugin, &e, client) { + return Err(IdeError::unavailable(message)); + } + self.fail(plugin, &e, client); + return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id()))); + } + }; + if let Some(e) = crash { + drop(runtime); + if let Some(message) = self.roll_back_failed_update(plugin, &e, client) { + return Err(IdeError::unavailable(message)); + } + self.crashed(plugin, &e, client); + return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id()))); + } + // An update's first start: confirmed. + self.updates.lock().remove(plugin.id()); + let has_syntax = rt.has_syntax(); + let actor = match Actor::spawn(plugin.id(), rt) { + Ok(a) => Arc::new(a), + Err(e) => return Err(IdeError::internal(format!("plugin {:?}: {e}", plugin.id()))), + }; + *runtime = Some(actor.clone()); + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::Active; + meta.has_syntax = has_syntax; + meta.stats.activations += 1; + meta.stats.activation_ms = started.elapsed().as_secs_f64() * 1000.0; + Ok((actor, true)) + } + + fn record_call(&self, plugin: &LoadedPlugin, elapsed: Duration, ok: bool, healthy: bool) { + plugin.record_call(elapsed, ok, healthy); + } + + /// Clears `actor` from the plugin if it ended, reporting its crash once. + fn reap(&self, plugin: &LoadedPlugin, actor: &Arc, client: Option<&str>) { + if actor.is_alive() { + return; + } + { + let mut runtime = plugin.runtime.lock(); + if runtime.as_ref().is_some_and(|r| Arc::ptr_eq(r, actor)) { + *runtime = None; + } + } + let Some(error) = actor.take_crash() else { return }; + if error.starts_with(CANCELLED) { + // Cancelled on request: restart on next use, no backoff. + let mut meta = plugin.meta.lock(); + if meta.status == PluginStatus::Active { + meta.status = PluginStatus::Inactive; + } + drop(meta); + self.changed(); + return; + } + self.crashed(plugin, &error, client); + } + + /// A trap dropped the plugin's instance: restart it on next use after a + /// backoff, or stop it for the session after too many crashes in a row. + fn crashed(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { + let failures = { + let mut meta = plugin.meta.lock(); + meta.failures += 1; + meta.stats.crashes += 1; + meta.failures + }; + if failures >= self.limits.max_failures.max(1) { + let error = + if failures > 1 { format!("{error} (crashed {failures} times in a row)") } else { error.to_owned() }; + return self.fail(plugin, &error, client); + } + let delay = self.limits.backoff(failures); + tracing::warn!(plugin = %plugin.id(), "crashed ({failures}), restarting in {delay:?}: {error}"); + { + let mut meta = plugin.meta.lock(); + if meta.status == PluginStatus::Active { + meta.status = PluginStatus::Inactive; + } + meta.error = Some(error.to_owned()); + meta.retry_at = Some(Instant::now() + delay); + } + if failures == 1 { + self.emit( + client, + PluginEvent::Notification { + plugin_id: plugin.id().to_owned(), + level: NotificationLevel::Warning, + title: format!("Plugin \u{201c}{}\u{201d} crashed", plugin.manifest().name), + body: format!("{error}\nIt restarts the next time it is needed."), + }, + ); + } + self.changed(); + } + + /// Ends an idle instance; the plugin activates again on its next event. + fn reclaim(&self, plugin: &LoadedPlugin, actor: &Arc) -> bool { + { + let mut runtime = plugin.runtime.lock(); + if !runtime.as_ref().is_some_and(|r| Arc::ptr_eq(r, actor)) || actor.is_busy() { + return false; + } + *runtime = None; + } + actor.stop(false); + let mut meta = plugin.meta.lock(); + if meta.status == PluginStatus::Active { + meta.status = PluginStatus::Inactive; + } + meta.stats.reclaims += 1; + tracing::debug!(plugin = %plugin.id(), "reclaimed after {:?} idle", actor.idle_for()); + true + } + + fn stop(&self, plugin: &LoadedPlugin) { + if let Some(actor) = plugin.runtime.lock().take() { + actor.stop(true); + } + self.registry.dispose_plugin(plugin.id()); + self.shared.clear_plugin(plugin.id(), true); + self.queues.lock().retain(|(id, _), _| id != plugin.id()); + } + + fn set_failed(&self, plugin: &LoadedPlugin, error: String) { + tracing::warn!(plugin = %plugin.id(), "{error}"); + self.registry.dispose_plugin(plugin.id()); + let mut meta = plugin.meta.lock(); + meta.status = PluginStatus::Failed; + meta.error = Some(error); + } + + fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { + tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}"); + if let Some(actor) = plugin.runtime.lock().take() { + actor.stop(false); + } + self.shared.clear_plugin(plugin.id(), true); + self.set_failed(plugin, error.to_owned()); + self.emit( + client, + PluginEvent::Notification { + plugin_id: plugin.id().to_owned(), + level: NotificationLevel::Error, + title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest().name), + body: error.to_owned(), + }, + ); + self.changed(); + } + + /// Contributions or statuses changed: tell the UI and re-announce the theme if it changed. + fn changed(&self) { + // Held by this thread when called from a grant change, which refreshed them already. + if let Some(ledger) = self.ledger.try_lock() { + self.refresh_pins(&ledger); + } + self.emit(None, PluginEvent::CommandsChanged); + let languages = self.languages.revision(); + if self.announced_languages.swap(languages, Ordering::AcqRel) != languages { + self.emit(None, PluginEvent::LanguagesChanged); + } + let active = self.active_theme(); + let mut announced = self.announced_theme.lock(); + if *announced != active { + *announced = active.clone(); + drop(announced); + self.emit(None, PluginEvent::ThemeChanged { theme: active }); + } + } + + fn emit(&self, client: Option<&str>, event: PluginEvent) { + self.sink.emit(client, event); + } + + fn save_state(&self) { + let json = serde_json::to_string_pretty(&*self.state.lock()).unwrap_or_default(); + if let Err(e) = std::fs::write(self.plugins_dir.join(STATE_FILE), json) { + tracing::warn!("saving plugin state: {e}"); + } + } + + fn save_ledger(&self, ledger: &Ledger) { + if let Err(e) = ledger.save() { + tracing::warn!("saving plugin grants: {e}"); + } + self.refresh_pins(ledger); + // Grants of programs that exist only on a remote host change no local pin, + // but what a remote server may start (`LanguagesChanged` re-syncs it). + if !self.languages.with_owners().is_empty() { + self.languages.invalidate(); + } + } + + /// Recomputes the approved programs of every plugin; plugin languages + /// pick up a change on their next lookup. + fn refresh_pins(&self, ledger: &Ledger) { + let mut pins = HashMap::new(); + for plugin in self.plugins.read().values() { + let subject = plugin.subject(); + let declared = + plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone()); + for cap in declared.filter(|c| matches!(c, Capability::Process(_))) { + if let Some(bin) = ledger.pinned_binary(&subject, &cap) { + pins.insert( + (plugin.id().to_owned(), cap.to_string()), + ProgramPin { path: bin.path, hash: bin.hash }, + ); + } + } + } + let mut current = self.pins.write(); + if *current != pins { + *current = pins; + drop(current); + self.languages.invalidate(); + } + } + + fn links(&self) -> Links { + std::fs::read_to_string(self.plugins_dir.join(LINKS_FILE)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + fn save_links(&self, links: &Links) -> IdeResult<()> { + let json = serde_json::to_string_pretty(links).map_err(|e| IdeError::internal(e.to_string()))?; + std::fs::write(self.plugins_dir.join(LINKS_FILE), json)?; + Ok(()) + } + + /// The installed package's recorded hash; development plugins are granted by path. + fn package_hash_of(&self, plugin: &LoadedPlugin) -> String { + if plugin.source != PluginSource::Installed { + return String::new(); + } + let file = plugin.dir().join(install::HASH_FILE); + if let Ok(h) = std::fs::read_to_string(&file) { + return h.trim().to_owned(); + } + // Installed before hashes were recorded: hash once and remember. + let files = self.registry.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default(); + let files: Vec = files.into_iter().chain(plugin.manifest().wasm.clone()).collect(); + let hash = ledger::package_hash(plugin.dir(), &files).unwrap_or_default(); + let _ = std::fs::write(&file, &hash); + hash + } + + /// One-time migration from 0.1, where declaring a permission granted it. + fn migrate_grants(&self, installed: &[PathBuf]) { + let mut ledger = self.ledger.lock(); + if !ledger.needs_migration() { + return; + } + let manifests: Vec = installed.iter().filter_map(|d| Manifest::load(d).ok()).collect(); + let requests: Vec> = + manifests.iter().map(|m| request_for(&Subject::Installed(m.id.clone()), m, "", &[])).collect(); + ledger.migrate(&requests); + self.save_ledger(&ledger); + } + + fn record_consent(&self, request: &Request<'_>, consent: &Consent) { + let accepted: Vec = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect(); + let binaries: BTreeMap = request + .required + .iter() + .chain(&accepted) + .filter_map(|c| match c { + Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)), + _ => None, + }) + .collect(); + let mut ledger = self.ledger.lock(); + ledger.confirm(request, &accepted, binaries); + self.save_ledger(&ledger); + } + + fn resolve_binary(&self, program: &str) -> Option { + let path_var = (self.path_var.read())(); + let path = ledger::resolve_program(program, path_var.as_deref()) + .map_err(|e| tracing::info!("process:{program}: {e}")) + .ok()?; + let hash = ledger::file_hash(&path).ok()?; + Some(BinaryGrant { path, hash }) + } + + fn info(&self, p: &LoadedPlugin) -> PluginInfo { + let m = p.manifest(); + let meta = p.meta.lock(); + let grant = self.ledger.lock().get(&p.subject()).cloned(); + let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.confirmed && g.has(c)); + let mut capabilities: Vec = + m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect(); + for extra in &meta.declared_extra { + if !m.declares(extra) { + capabilities.push(CapabilityInfo::new(extra, true, granted(extra))); + } + } + PluginInfo { + id: m.id.clone(), + name: m.name.clone(), + version: m.version.clone(), + description: m.description.clone(), + authors: m.authors.clone(), + homepage: m.homepage.clone(), + change_notes: m.change_notes.clone(), + source: p.source, + path: p.dir().to_path_buf().into(), + enabled: !self.state.lock().disabled.contains(&m.id), + status: meta.status, + error: meta.error.clone(), + has_code: m.wasm.is_some(), + capabilities, + contributions: self.registry.contributions(&m.id), + activation: m.activation.iter().map(ToString::to_string).collect(), + warnings: meta.warnings.clone(), + stats: PluginStats { p95_call_ms: meta.p95(), ..meta.stats }, + publisher: meta.publisher.clone(), + previous_version: (p.source == PluginSource::Installed) + .then(|| install::previous_version(&self.plugins_dir.join(&m.id))) + .flatten(), + } + } +} + +impl Drop for PluginHost { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn request_for<'a>(subject: &Subject, m: &'a Manifest, hash: &'a str, declared: &[Capability]) -> Request<'a> { + let mut optional = m.capabilities.optional.clone(); + // What declarations need (a language server's `process:`) is optional: + // refusing it keeps the rest of the plugin working. + for cap in declared { + if !m.declares(cap) && !optional.contains(cap) { + optional.push(cap.clone()); + } + } + Request { + subject: subject.clone(), + plugin_id: &m.id, + version: &m.version, + package_hash: hash, + required: m.capabilities.required.clone(), + optional, + } +} + +fn counts(m: &Manifest) -> Vec { + m.declaration_counts().into_iter().map(|(point, count)| ContributionCount { point, count }).collect() +} + +fn activation_matches(declared: &ActivationEvent, fired: &ActivationEvent) -> bool { + match (declared, fired) { + (ActivationEvent::Language(a), ActivationEvent::Language(b)) => a == b || a == "*", + (ActivationEvent::View(a), ActivationEvent::View(b)) => a == b, + (ActivationEvent::File(glob), ActivationEvent::File(path)) => glob_match(glob, path), + _ => false, + } +} + +/// `**` matches across `/`, `*` and `?` within one segment. +fn glob_match(glob: &str, path: &str) -> bool { + fn go(p: &[u8], s: &[u8]) -> bool { + match p.first() { + None => s.is_empty(), + Some(b'*') if p.get(1) == Some(&b'*') => { + let rest = p[2..].strip_prefix(b"/").unwrap_or(&p[2..]); + if rest.is_empty() { + return true; + } + (0..=s.len()).any(|i| (i == 0 || s[i - 1] == b'/') && go(rest, &s[i..])) || go(rest, s) + } + Some(b'*') => (0..=s.len()).take_while(|&i| i == 0 || s[i - 1] != b'/').any(|i| go(&p[1..], &s[i..])), + Some(b'?') => s.first().is_some_and(|c| *c != b'/') && go(&p[1..], &s[1..]), + Some(c) => s.first() == Some(c) && go(&p[1..], &s[1..]), + } + } + go(glob.as_bytes(), path.as_bytes()) +} + +fn is_hex_color(s: &str) -> bool { + s.strip_prefix('#').is_some_and(|h| matches!(h.len(), 3 | 6 | 8) && h.bytes().all(|b| b.is_ascii_hexdigit())) +} + +/// Subdirectories of `dir` that contain a `plugin.toml`, sorted. +/// The active version of every installed plugin (`/`, or `` +/// for installs from before versioning). +fn package_dirs(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut dirs: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))) + .filter_map(|p| install::active_dir(&p)) + .collect(); + dirs.sort(); + dirs +} + +/// Stand-in for a package whose manifest failed to parse, so it still shows up (as failed). +fn placeholder_manifest(dir: &Path) -> Manifest { + let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Manifest { + id: format!("invalid-{}", name.to_lowercase().replace(|c: char| !c.is_ascii_alphanumeric(), "-")), + name, + version: "0.0.0".into(), + description: String::new(), + authors: Vec::new(), + homepage: None, + license: None, + change_notes: None, + engine: ide_extension::ENGINE_VERSION.into(), + legacy: false, + wasm: None, + activation: Vec::new(), + capabilities: Default::default(), + contributes: Default::default(), + } +} + +/// [`PluginHost::syntax_provider`]: plugin languages' `syntax-provider` exports. +struct PluginSyntax(std::sync::Weak); + +type SyntaxFn = fn(&mut Runtime, &str, &str, Duration) -> Result; + +/// A syntax call with its own copies of the arguments, to run on the plugin's thread. +fn owned( + lang: &LanguageId, + text: &str, + f: SyntaxFn, +) -> impl FnOnce(&mut Runtime, Duration) -> Result + Send + 'static { + let (lang, text) = (lang.as_str().to_owned(), text.to_owned()); + move |rt, timeout| f(rt, &lang, &text, timeout) +} + +impl ide_language::ExternalSyntax for PluginSyntax { + fn highlights(&self, lang: &LanguageId, text: &str) -> Option> { + let tokens = self.0.upgrade()?.syntax_call(lang, owned(lang, text, Runtime::syntax_highlights))?; + Some( + tokens + .into_iter() + .map(|t| ide_language::ExternalToken { start: t.start, end: t.end, kind: t.kind }) + .collect(), + ) + } + + fn outline(&self, lang: &LanguageId, text: &str) -> Option> { + let symbols = self.0.upgrade()?.syntax_call(lang, owned(lang, text, Runtime::syntax_outline))?; + Some( + symbols + .into_iter() + .map(|s| ide_language::ExternalSymbol { + name: s.name, + detail: s.detail, + kind: s.kind, + start: s.start, + end: s.end, + name_start: s.name_start, + name_end: s.name_end, + parent: s.parent, + }) + .collect(), + ) + } + + fn folding(&self, lang: &LanguageId, text: &str) -> Option> { + let folds = self.0.upgrade()?.syntax_call(lang, owned(lang, text, Runtime::syntax_folding))?; + Some( + folds + .into_iter() + .map(|f| ide_language::ExternalFold { start_line: f.start_line, end_line: f.end_line, kind: f.kind }) + .collect(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn globs() { + assert!(glob_match("**/*Mapper.xml", "src/main/UserMapper.xml")); + assert!(glob_match("**/*Mapper.xml", "UserMapper.xml")); + assert!(!glob_match("*.xml", "a/b.xml")); + assert!(glob_match("pom.xml", "pom.xml")); + assert!(glob_match("src/**", "src/a/b")); + assert!(glob_match("a?c", "abc") && !glob_match("a?c", "a/c")); + } +} diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs new file mode 100644 index 000000000..f6f32f9e3 --- /dev/null +++ b/crates/ide-host/src/plugins/points.rs @@ -0,0 +1,71 @@ +//! First-party extension points registered by the host, plus +//! [`register_builtin_points`], the one place that lists them. Feature crates +//! own their points (`ide_language::plugin_point`); adding a contribution type +//! means adding a point here or in its crate, and a `defineUiPoint` projection +//! on the client for `Ui` points — nothing else. + +use ide_extension::{ExtensionRegistry, Package, UiDecl, UiPoint, UiStore, decode, validate_item_id, validate_when}; +use ide_language::plugin_point::{LanguagesPoint, PluginLanguages}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +pub const COMMANDS: &str = "commands"; +/// Most commands one plugin may register. +pub const MAX_COMMANDS: usize = 256; +const MAX_TITLE: usize = 256; + +/// `[[contributes.commands]]`: an action `plugin..` in Search +/// Everywhere and the keymap editor; shown before the plugin's code runs. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CommandDecl { + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// Enablement, evaluated by the client against context keys (`editorLang == rust`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub when: Option, +} + +fn command(_pkg: &Package, decl: toml::Value) -> Result { + let c: CommandDecl = decode(COMMANDS, decl)?; + validate_item_id(&c.id, "command id")?; + if c.title.trim().is_empty() { + return Err(format!("command {:?} has an empty title", c.id)); + } + if c.title.len() > MAX_TITLE || c.category.as_ref().is_some_and(|x| x.len() > MAX_TITLE) { + return Err(format!("command {:?}: title or category longer than {MAX_TITLE} bytes", c.id)); + } + if let Some(when) = &c.when { + validate_when(when).map_err(|e| format!("command {:?}: {e}", c.id))?; + } + let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c }; + let json = serde_json::to_value(&c).map_err(|e| e.to_string())?; + Ok(UiDecl::new(c.id.clone(), c.title, json)) +} + +/// Registers every first-party extension point. +pub fn register_builtin_points( + registry: &ExtensionRegistry, + ui: &Arc, + languages: &Arc, + run_detectors: &Arc, + providers: &Arc, +) -> Result<(), String> { + registry.register(Arc::new(UiPoint::new(COMMANDS, ui.clone(), command)))?; + registry.register(Arc::new(super::theme::ThemesPoint::new(ui.clone())))?; + registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?; + registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?; + registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?; + registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?; + registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?; + use super::ui_points as p; + registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?; + registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?; + registry.register(Arc::new(UiPoint::new(p::STATUS_BAR, ui.clone(), p::status_item)))?; + registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?; + registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?; + registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?; + Ok(()) +} diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs new file mode 100644 index 000000000..aa457b54c --- /dev/null +++ b/crates/ide-host/src/plugins/providers.rs @@ -0,0 +1,332 @@ +//! Editor providers of plugins (docs/plugin_plan.md P6): completion, inline +//! completion, code actions, go to definition and gutter line markers, for +//! the languages a plugin declares. The host calls only plugins whose +//! declaration matches, so the others stay unloaded. +//! +//! ```toml +//! [[contributes.providers]] +//! kinds = ["definition", "line-markers"] +//! languages = ["java", "xml"] # editor language ids; "*" for any +//! ``` +//! +//! And `[[contributes.secrets]]`: secrets the user can store for the plugin +//! (shown in Settings › Plugins), used only through `net` request headers. +//! +//! ```toml +//! [[contributes.secrets]] +//! name = "api-key" +//! label = "API key" +//! description = "Create one at https://example.com/keys" +//! ``` + +use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub const POINT_ID: &str = "providers"; +pub const SECRETS_POINT: &str = "secrets"; + +/// What a plugin can provide in the editor. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderKind { + Completion, + InlineCompletion, + CodeActions, + Definition, + LineMarkers, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + kinds: Vec, + languages: Vec, +} + +/// (sequence, plugin, kinds, languages) of one declaration. +type Declared = (u64, String, Vec, Vec); + +/// Provider declarations of enabled plugins. +#[derive(Default)] +pub struct Providers { + items: RwLock>, + seq: AtomicU64, +} + +impl Providers { + fn add(self: &Arc, plugin: String, decl: Decl) -> Disposable { + let seq = self.seq.fetch_add(1, Ordering::Relaxed); + self.items.write().push((seq, plugin, decl.kinds, decl.languages)); + let weak = Arc::downgrade(self); + Disposable::new(move || { + if let Some(this) = weak.upgrade() { + this.items.write().retain(|(s, ..)| *s != seq); + } + }) + } + + /// Plugins providing `kind` for `language`, in declaration order. + pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec { + let mut out: Vec = Vec::new(); + for (_, plugin, kinds, languages) in self.items.read().iter() { + if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) { + out.push(plugin.clone()); + } + } + out + } + + /// Whether any plugin provides `kind` (for any language). + pub fn any(&self, kind: ProviderKind) -> bool { + self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind)) + } +} + +pub struct ProvidersPoint { + store: Arc, +} + +impl ProvidersPoint { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +impl ExtensionPoint for ProvidersPoint { + fn id(&self) -> &'static str { + POINT_ID + } + + fn placement(&self) -> Placement { + Placement::Workspace + } + + fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result { + let d: Decl = decode(POINT_ID, decl)?; + if pkg.manifest.wasm.is_none() { + return Err("contributes.providers: providers need code (`wasm`)".into()); + } + if d.kinds.is_empty() || d.languages.is_empty() || d.languages.len() > 64 { + return Err("contributes.providers: give `kinds` and 1–64 `languages`".into()); + } + for l in &d.languages { + if l != "*" + && (l.is_empty() + || l.len() > 64 + || !l.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))) + { + return Err(format!("contributes.providers: invalid language id {l:?}")); + } + } + let label = format!( + "{} for {}", + d.kinds + .iter() + .map(|k| serde_json::to_value(k).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default()) + .collect::>() + .join(", "), + d.languages.join(", ") + ); + let (store, plugin) = (self.store.clone(), pkg.id().to_owned()); + Ok(Prepared::new(label, move || Ok(store.add(plugin, d)))) + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SecretDecl { + name: String, + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, +} + +/// `[[contributes.secrets]]` (a UI point: Settings › Plugins lists them). +pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result { + let d: SecretDecl = decode(SECRETS_POINT, decl)?; + validate_item_id(&d.name, "secret name")?; + if d.label.trim().is_empty() || d.label.len() > 128 || d.description.as_ref().is_some_and(|x| x.len() > 1024) { + return Err(format!("secret {:?}: `label` 1–128 bytes, `description` up to 1 KiB", d.name)); + } + let json = serde_json::to_value(&d).map_err(|e| e.to_string())?; + Ok(UiDecl::new(d.name.clone(), d.label, json)) +} + +// ---- dispatch --------------------------------------------------------------- + +use super::{LoadedPlugin, PluginHost, PluginStatus}; +use ide_core::CancellationToken; +use ide_plugin::{ + ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument, + Runtime, +}; +use std::sync::Weak; +use std::time::{Duration, Instant}; + +/// How long a superseded inline completion may take to return after its +/// request was cancelled before it is interrupted. +const CANCEL_GRACE: Duration = Duration::from_millis(250); + +static EDITOR_PROVIDERS: RwLock>> = RwLock::new(None); + +/// Makes `host`'s plugin providers part of completion, code actions, go to +/// definition and inline completion (`None` removes them). +pub fn set_editor_providers(host: Option<&Arc>) { + *EDITOR_PROVIDERS.write() = host.map(Arc::downgrade); +} + +/// The plugin host whose providers take part, when any declares `kind`. +pub(crate) fn editor_providers(kind: ProviderKind) -> Option> { + let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?; + host.providers.any(kind).then_some(host) +} + +impl PluginHost { + /// Usable plugins declaring `kind` for `language`. + fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec> { + self.providers + .plugins_for(kind, language) + .into_iter() + .filter_map(|id| self.get(&id).ok()) + .filter(|p| { + let m = p.meta.lock(); + matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty() + }) + .collect() + } + + /// One edit-path call (soft / hard deadline); `None` when it failed or was late. + fn edit_call( + &self, + plugin: &Arc, + f: impl FnOnce(&mut Runtime, Duration) -> Result + Send + 'static, + ) -> Option { + let (actor, activated) = self.ensure_active(plugin, None).ok()?; + if activated { + self.changed(); + } + let started = Instant::now(); + let hard = self.limits.edit_hard; + let result = actor.call(Some(self.limits.edit_soft), move |rt| f(rt, hard)); + self.finish_provider_call(plugin, &actor, started, result) + } + + pub(super) fn finish_provider_call( + &self, + plugin: &Arc, + actor: &Arc, + started: Instant, + result: Result, + ) -> Option { + let elapsed = started.elapsed(); + match result { + Ok(r) => { + self.record_call(plugin, elapsed, true, true); + Some(r) + } + Err(ActorError::SoftTimeout) => { + plugin.meta.lock().stats.slow_calls += 1; + None + } + Err(ActorError::Cancelled) => None, + Err(ActorError::Call(CallError::Guest(e))) => { + self.record_call(plugin, elapsed, false, true); + tracing::debug!(plugin = %plugin.id(), "provider: {e}"); + None + } + Err(ActorError::Call(CallError::Trap(_)) | ActorError::Stopped) => { + self.record_call(plugin, elapsed, false, false); + self.reap(plugin, actor, None); + None + } + } + } + + pub fn provide_completion( + &self, + doc: &ProviderDocument, + at: ide_core::Pos, + trigger: Option<&str>, + ) -> Vec<(String, CompletionItem)> { + let mut out = Vec::new(); + for p in self.provider_plugins(ProviderKind::Completion, &doc.language) { + let (d, t) = (doc.clone(), trigger.map(str::to_owned)); + if let Some(items) = + self.edit_call(&p, move |rt, timeout| rt.provide_completion(&d, at, t.as_deref(), timeout)) + { + out.extend(items.into_iter().map(|i| (p.id().to_owned(), i))); + } + } + out + } + + pub fn provide_code_actions( + &self, + doc: &ProviderDocument, + selection: ide_core::Range, + ) -> Vec<(String, CodeAction)> { + let mut out = Vec::new(); + for p in self.provider_plugins(ProviderKind::CodeActions, &doc.language) { + let d = doc.clone(); + if let Some(actions) = + self.edit_call(&p, move |rt, timeout| rt.provide_code_actions(&d, selection, timeout)) + { + out.extend(actions.into_iter().map(|a| (p.id().to_owned(), a))); + } + } + out + } + + pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec { + let mut out = Vec::new(); + for p in self.provider_plugins(ProviderKind::Definition, &doc.language) { + let d = doc.clone(); + if let Some(locations) = self.edit_call(&p, move |rt, timeout| rt.provide_definition(&d, at, timeout)) { + out.extend(locations); + } + } + out + } + + pub fn provide_line_markers(&self, doc: &ProviderDocument) -> Vec<(String, LineMarker)> { + let mut out = Vec::new(); + for p in self.provider_plugins(ProviderKind::LineMarkers, &doc.language) { + let d = doc.clone(); + if let Some(markers) = self.edit_call(&p, move |rt, timeout| rt.provide_line_markers(&d, timeout)) { + out.extend(markers.into_iter().map(|m| (p.id().to_owned(), m))); + } + } + out + } + + /// Ghost text from the first plugin that has some. Not bound by the + /// edit-path soft deadline (it may need the network) but by `cancel`: + /// the editor cancels a request as soon as the user types on. + pub fn provide_inline_completion( + &self, + doc: &ProviderDocument, + at: ide_core::Pos, + cancel: &CancellationToken, + ) -> Option<(String, InlineSuggestion)> { + for p in self.provider_plugins(ProviderKind::InlineCompletion, &doc.language) { + if cancel.is_cancelled() { + return None; + } + let Ok((actor, activated)) = self.ensure_active(&p, None) else { continue }; + if activated { + self.changed(); + } + let (d, timeout) = (doc.clone(), self.limits.timeout); + let started = Instant::now(); + let result = + actor.call_cancellable(cancel, CANCEL_GRACE, move |rt| rt.provide_inline_completion(&d, at, timeout)); + if let Some(Some(s)) = self.finish_provider_call(&p, &actor, started, result) { + return Some((p.id().to_owned(), s)); + } + } + None + } +} diff --git a/crates/ide-host/src/plugins/remote.rs b/crates/ide-host/src/plugins/remote.rs new file mode 100644 index 000000000..b1d8ffa21 --- /dev/null +++ b/crates/ide-host/src/plugins/remote.rs @@ -0,0 +1,223 @@ +//! Plugin languages of a remote project (docs/plugin_plan.md 3, placement +//! rules): `Workspace` contributions take effect where the project is, so the +//! desktop pushes its plugins' language definitions to `openidea-server` +//! (`plugin_sync_languages`) together with the `process:` programs the user +//! granted. The server resolves each granted program on its own `PATH`, pins +//! the file (path + hash, kept in `plugin-pins.json`) the first time and +//! starts only that file afterwards; a program missing from a sync is revoked. +//! Revoking and granting again on the desktop re-pins a replaced program. + +use ide_extension::Capability; +use ide_extension::ledger::{self, BinaryGrant}; +use ide_language::plugin_point::{PluginLanguages, validate_definition}; +use ide_language::{LanguageConfig, ProgramPin}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +pub const PINS_FILE: &str = "plugin-pins.json"; +/// The server command the desktop calls. +pub const SYNC_COMMAND: &str = "plugin_sync_languages"; + +/// One plugin language as the desktop sends it. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteLanguage { + pub plugin: String, + pub language: LanguageConfig, + /// Program names of the plugin's granted `process:` capabilities. + #[serde(default)] + pub granted: Vec, +} + +/// What the server made of a sync. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SyncReport { + pub languages: usize, + /// `plugin: program` → the file it starts. + pub pinned: BTreeMap, + /// Granted programs not found on this machine's `PATH`. + pub unresolved: Vec, + /// Definitions that failed validation (not applied). + pub rejected: Vec, +} + +#[derive(Default, Serialize, Deserialize)] +struct PinsFile { + /// `plugin\nprocess:program` → the approved file. + #[serde(default)] + pins: BTreeMap, +} + +type PathVar = Arc Option + Send + Sync>; + +/// The server's side: an overlay for its language registry, filled by syncs. +pub struct RemotePluginLanguages { + store: Arc, + pins: Arc>>, + file: Option, + applied: Mutex>, + path_var: PathVar, +} + +fn key(plugin: &str, cap: &Capability) -> String { + format!("{plugin}\n{cap}") +} + +impl RemotePluginLanguages { + /// Pins persist in `data_dir` (none: in memory, for tests). + pub fn new(data_dir: Option<&std::path::Path>) -> Arc { + Self::with_path(data_dir, Arc::new(|| std::env::var_os("PATH"))) + } + + pub fn with_path(data_dir: Option<&std::path::Path>, path_var: PathVar) -> Arc { + let file = data_dir.map(|d| d.join(PINS_FILE)); + let saved: PinsFile = file + .as_ref() + .and_then(|f| std::fs::read_to_string(f).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let pins = Arc::new(Mutex::new(saved.pins)); + let store = PluginLanguages::new(); + let policy_pins = pins.clone(); + store.set_process_policy(Arc::new(move |plugin, cap| { + policy_pins.lock().get(&key(plugin, cap)).map(|b| ProgramPin { path: b.path.clone(), hash: b.hash.clone() }) + })); + Arc::new(Self { store, pins, file, applied: Mutex::default(), path_var }) + } + + /// For [`ide_language::LanguageRegistry::attach_plugins`]. + pub fn store(&self) -> &Arc { + &self.store + } + + /// Replaces every plugin language with `languages`. + pub fn sync(&self, languages: Vec) -> SyncReport { + let mut report = SyncReport::default(); + let mut wanted: BTreeMap = BTreeMap::new(); + let mut accepted = Vec::new(); + for l in languages { + if let Err(e) = validate_definition(&l.language) { + report.rejected.push(format!("{} ({}): {e}", l.language.id, l.plugin)); + continue; + } + for program in &l.granted { + let cap = Capability::Process(program.clone()); + // Only programs the definition starts: a grant is not a licence for anything else. + if ide_language::plugin_point::required_capabilities(&l.language).contains(&cap) { + wanted.insert(key(&l.plugin, &cap), (program.clone(), cap)); + } + } + accepted.push(l); + } + { + let mut pins = self.pins.lock(); + pins.retain(|k, _| wanted.contains_key(k)); + for (k, (program, _)) in &wanted { + if pins.contains_key(k) { + continue; + } + let resolved = ledger::resolve_program(program, (self.path_var)().as_deref()).and_then(|path| { + ledger::file_hash(&path).map(|hash| BinaryGrant { path, hash }).map_err(|e| e.to_string()) + }); + match resolved { + Ok(bin) => { + pins.insert(k.clone(), bin); + } + Err(e) => { + tracing::info!("process:{program}: {e}"); + report.unresolved.push(program.clone()); + } + } + } + for (k, bin) in pins.iter() { + report.pinned.insert(k.replace("\nprocess:", ": "), bin.path.clone()); + } + self.save(&pins); + } + let mut applied = self.applied.lock(); + applied.clear(); // disposes the previous sync's languages + for l in accepted { + applied.push(self.store.add(l.plugin, l.language)); + } + report.languages = applied.len(); + self.store.invalidate(); + report + } + + fn save(&self, pins: &BTreeMap) { + let Some(file) = &self.file else { return }; + let json = serde_json::to_string_pretty(&PinsFile { pins: pins.clone() }).unwrap_or_default(); + let tmp = file.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp, json).and_then(|()| std::fs::rename(&tmp, file)) { + tracing::warn!("saving {}: {e}", file.display()); + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use ide_core::LanguageId; + use ide_language::LanguageRegistry; + use std::os::unix::fs::PermissionsExt; + + fn toy(granted: &[&str]) -> RemoteLanguage { + let language = ide_language::plugin_point::parse_definition( + "id = \"toy\"\nname = \"Toy\"\nextensions = [\"toy\"]\n\ + [[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n", + ) + .unwrap(); + RemoteLanguage { + plugin: "acme.toy".into(), + language, + granted: granted.iter().map(|s| (*s).to_owned()).collect(), + } + } + + #[test] + fn syncs_languages_and_pins_granted_programs_on_this_machine() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + std::fs::write(bin.join("toylsp"), "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(bin.join("toylsp"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone().into_os_string(); + let remote = RemotePluginLanguages::with_path(Some(tmp.path()), Arc::new(move || Some(path.clone()))); + let registry = LanguageRegistry::load(None).unwrap(); + registry.attach_plugins(remote.store().clone()); + let project = registry.fork(); + let id = LanguageId::new("toy"); + + // Not granted: the language, no server. + let report = remote.sync(vec![toy(&[])]); + assert_eq!(report.languages, 1); + assert_eq!(project.detect(std::path::Path::new("/r/a.toy"), None), id); + assert!(project.get(&id).unwrap().language_servers.is_empty()); + + // Granted: resolved here and pinned; persisted. + let report = remote.sync(vec![toy(&["toylsp", "rm"])]); + assert_eq!(report.pinned.len(), 1, "only programs the definition starts: {report:?}"); + let pin = project.get(&id).unwrap().language_servers[0].pinned.clone().unwrap(); + assert!(pin.verify().is_ok()); + assert!(tmp.path().join(PINS_FILE).is_file()); + + // Replaced: refused at start until revoked and granted again. + std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho x\n").unwrap(); + remote.sync(vec![toy(&["toylsp"])]); + assert!(project.get(&id).unwrap().language_servers[0].pinned.as_ref().unwrap().verify().is_err()); + remote.sync(vec![toy(&[])]); + remote.sync(vec![toy(&["toylsp"])]); + assert!(project.get(&id).unwrap().language_servers[0].pinned.as_ref().unwrap().verify().is_ok()); + + // Missing programs are reported; invalid definitions rejected; empty sync removes all. + let mut bad = toy(&[]); + bad.language.id = LanguageId::new("a b"); + let report = remote.sync(vec![bad]); + assert_eq!(report.rejected.len(), 1); + assert_eq!(project.detect(std::path::Path::new("/r/a.toy"), None), LanguageId::plain_text()); + } +} diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs new file mode 100644 index 000000000..86986e57a --- /dev/null +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -0,0 +1,502 @@ +//! Code plugins of remote projects (docs/plugin_plan.md 3, placement rule 1): +//! Wasm runs where the project is, so `openidea-server` hosts the desktop's +//! code plugins and they read, search, run processes and publish diagnostics +//! on the remote machine. +//! +//! The desktop stays the authority over what is installed and granted. It +//! pushes its live code plugins with [`SYNC_PACKAGES`]: first their digests +//! and grants, then the files of those the server doesn't have (one package +//! per request); a package missing from a sync is uninstalled. The server +//! installs them under `/remote-plugins` and records the desktop's +//! grants (a `process:` grant is pinned to the program on this machine's +//! `PATH`). +//! +//! The commands a window needs at run time (`plugin_run_command`, +//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`, +//! `plugin_cancel_task`, `plugin_set_active_file`, `plugin_line_markers`, +//! `plugin_view_render`, `plugin_view_event`) +//! carry the desktop commands' names and arguments, so a remote window's UI +//! routes them here unchanged. Plugin results come back as `plugin` +//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's +//! keychain and are asked for with a `plugin_secret` request +//! ([`SecretRequest`]) when a request header needs one (rule 4). + +use super::{ + Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore, +}; +use crate::rpc::Dispatcher; +use base64::Engine as _; +use ide_core::{IdeError, IdeResult}; +use ide_extension::{MANIFEST_FILE, ledger}; +use ide_jsonrpc::Peer; +use ide_plugin::{Invocation, Limits}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock, Weak}; +use std::time::Duration; + +/// The server command the desktop calls with its packages. +pub const SYNC_PACKAGES: &str = "plugin_sync_packages"; +/// Server → client notification carrying a [`PluginEventMessage`]. +pub const EVENT_NOTIFICATION: &str = "plugin"; +/// Server → client request ([`SecretRequest`] → `Option`). +pub const SECRET_REQUEST: &str = "plugin_secret"; +/// Under the server's data directory. +pub const PLUGINS_DIR: &str = "remote-plugins"; +const INCOMING_DIR: &str = "remote-plugins.incoming"; +const SECRET_TIMEOUT: Duration = Duration::from_secs(10); +const EVENT_FLUSH: Duration = Duration::from_millis(250); +const TICKS_PER_SUPERVISION: u32 = 20; + +/// One live code plugin of the desktop. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemotePackage { + pub id: String, + /// [`package_digest`] of the package. + pub digest: String, + /// Capabilities the user granted on the desktop. + pub granted: Vec, +} + +/// One file of a package (`path` relative to the package, `data` base64). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PackageFile { + pub path: String, + pub data: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageSyncReport { + /// Packages the server needs the files of (send them in another sync). + pub missing: Vec, + pub installed: Vec, + pub removed: Vec, + /// `id: reason`. + pub failed: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PluginEventMessage { + /// The window that invoked the plugin (`None`: every window of the connection). + pub window: Option, + pub event: PluginEvent, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SecretRequest { + pub plugin: String, + pub name: String, +} + +/// Identifies a package's content: the manifest, the files its declarations +/// reference and the component. +pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult { + let mut all = files.to_vec(); + all.push(PathBuf::from(MANIFEST_FILE)); + Ok(ledger::package_hash(dir, &all)?) +} + +/// Whether a plugin event means something to a remote window (the desktop +/// owns the plugin list, themes and consent). +pub fn forwarded(event: &PluginEvent) -> bool { + matches!( + event, + PluginEvent::Notification { .. } + | PluginEvent::StatusItemChanged { .. } + | PluginEvent::TaskStarted { .. } + | PluginEvent::TaskProgress { .. } + | PluginEvent::TaskEnded { .. } + | PluginEvent::SubscriptionsChanged + | PluginEvent::ViewChanged { .. } + | PluginEvent::ViewMessage { .. } + | PluginEvent::OpenTerminal { .. } + ) +} + +// ---- desktop side ----------------------------------------------------------- + +impl PluginHost { + fn package_files(&self, plugin: &super::LoadedPlugin) -> IdeResult> { + let set = self.registry.prepare(&plugin.pkg).map_err(IdeError::invalid)?; + Ok(set.files.into_iter().chain(plugin.manifest().wasm.clone()).collect()) + } + + /// The live code plugins (installed or linked) for a remote project's server. + pub fn remote_packages(&self) -> Vec { + let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); + plugins + .iter() + .filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some()) + .filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive)) + .filter_map(|p| { + let files = self.package_files(p).ok()?; + let digest = package_digest(p.dir(), &files).ok()?; + let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect(); + Some(RemotePackage { id: p.id().to_owned(), digest, granted }) + }) + .collect() + } + + /// The files of a package for [`SYNC_PACKAGES`], the manifest first. + pub fn remote_package_files(&self, id: &str) -> IdeResult> { + let plugin = self.get(id)?; + let files = self.package_files(&plugin)?; + std::iter::once(PathBuf::from(MANIFEST_FILE)) + .chain(files) + .map(|rel| { + let bytes = std::fs::read(plugin.dir().join(&rel))?; + let path = rel.to_str().ok_or_else(|| IdeError::invalid("non UTF-8 file name"))?.replace('\\', "/"); + Ok(PackageFile { path, data: base64::engine::general_purpose::STANDARD.encode(bytes) }) + }) + .collect() + } + + /// A secret a plugin declares, for its remote instance (`plugin_secret`). + pub fn declared_secret(&self, id: &str, name: &str) -> Option { + let plugin = self.get(id).ok()?; + if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == name) { + return None; + } + let store = self.shared.secrets.read().clone(); + store.get(plugin.id(), name) + } +} + +// ---- server side ------------------------------------------------------------ + +/// The plugin host of `openidea-server`. +pub struct RemotePlugins { + host: Arc, + incoming: PathBuf, + sync: Mutex<()>, +} + +/// Secrets from the desktop's keychain, asked for when used. +struct ClientSecrets { + peer: Arc>, + runtime: tokio::runtime::Handle, +} + +impl SecretStore for ClientSecrets { + fn get(&self, plugin: &str, name: &str) -> Option { + let peer = self.peer.get()?.clone(); + let request = SecretRequest { plugin: plugin.to_owned(), name: name.to_owned() }; + let ask = async move { + tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Option>(SECRET_REQUEST, request, None)).await + }; + // Plugin threads block on the runtime; a runtime worker (a host + // command asking) must leave its thread to the other tasks first. + let answer = if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(|| self.runtime.block_on(ask)) + } else { + self.runtime.block_on(ask) + }; + match answer { + Ok(Ok(value)) => value, + Ok(Err(e)) => { + tracing::warn!("plugin secret {plugin}/{name}: {e}"); + None + } + Err(_) => None, + } + } + + fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> { + Err("secrets are stored on the desktop".into()) + } +} + +impl RemotePlugins { + /// Creates the host over `/remote-plugins`, loads what earlier + /// sessions installed and starts event delivery and supervision. + pub fn start(data_dir: &Path, projects: &Arc, peer: Arc>) -> IdeResult> { + let sink_peer = peer.clone(); + let sink = move |client: Option<&str>, event: PluginEvent| { + if forwarded(&event) + && let Some(p) = sink_peer.get() + { + let _ = p.notify(EVENT_NOTIFICATION, PluginEventMessage { window: client.map(str::to_owned), event }); + } + }; + let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?); + host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects))))); + host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() })); + let trust = Arc::downgrade(projects); + host.set_project_policy(Arc::new(move |root| { + trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false)) + })); + super::set_editor_providers(Some(&host)); + ide_language::set_external_syntax(Some(host.syntax_provider())); + ide_run::set_external_detection(Some(host.run_detection())); + host.load_all(); + host.startup_finished(); + spawn_supervision(Arc::downgrade(&host))?; + Ok(Arc::new(Self { host, incoming: data_dir.join(INCOMING_DIR), sync: Mutex::new(()) })) + } + + pub fn host(&self) -> &Arc { + &self.host + } + + /// Makes the installed packages match `packages`: uninstalls the others, + /// follows changed grants, installs those with `files` and reports the + /// rest as missing. + pub fn sync( + &self, + packages: Vec, + mut files: BTreeMap>, + ) -> PackageSyncReport { + let _guard = self.sync.lock(); + let mut report = PackageSyncReport::default(); + let wanted: BTreeSet<&str> = packages.iter().map(|p| p.id.as_str()).collect(); + let installed: Vec = self + .host + .plugins + .read() + .values() + .filter(|p| p.source == PluginSource::Installed) + .map(|p| p.id().to_owned()) + .collect(); + for id in installed.iter().filter(|id| !wanted.contains(id.as_str())) { + match self.host.uninstall(id) { + Ok(()) => report.removed.push(id.clone()), + Err(e) => report.failed.push(format!("{id}: {e}")), + } + } + for pkg in packages { + if !valid_id(&pkg.id) { + report.failed.push(format!("{}: invalid plugin id", pkg.id)); + continue; + } + let consent = Consent { optional: pkg.granted.clone() }; + if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) { + if let Err(e) = self.follow_grants(&pkg, &consent) { + report.failed.push(format!("{}: {e}", pkg.id)); + } + continue; + } + match files.remove(&pkg.id) { + None => report.missing.push(pkg.id), + Some(list) => match self.install(&pkg, list, &consent) { + Ok(()) => report.installed.push(pkg.id), + Err(e) => report.failed.push(format!("{}: {}", pkg.id, e.message)), + }, + } + } + report + } + + fn current_digest(&self, id: &str) -> Option { + let plugin = self.host.plugins.read().get(id).cloned()?; + if plugin.source != PluginSource::Installed || plugin.invalid { + return None; + } + let files = self.host.package_files(&plugin).ok()?; + package_digest(plugin.dir(), &files).ok() + } + + fn follow_grants(&self, pkg: &RemotePackage, consent: &Consent) -> IdeResult<()> { + let info = self.host.info_of(&pkg.id)?; + let have: BTreeSet<&str> = info.capabilities.iter().filter(|c| c.granted).map(|c| c.id.as_str()).collect(); + let want: BTreeSet<&str> = pkg.granted.iter().map(String::as_str).collect(); + if have != want || info.status == super::PluginStatus::NeedsConsent { + self.host.grant(&pkg.id, consent)?; + } + Ok(()) + } + + fn install(&self, pkg: &RemotePackage, files: Vec, consent: &Consent) -> IdeResult<()> { + let dir = self.incoming.join(&pkg.id); + let _ = std::fs::remove_dir_all(&dir); + let result = (|| { + let mut rels = Vec::new(); + for f in files { + let rel = safe_relative(&f.path)?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(f.data.as_bytes()) + .map_err(|e| IdeError::invalid(format!("{}: {e}", f.path)))?; + let to = dir.join(&rel); + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&to, bytes)?; + if rel != Path::new(MANIFEST_FILE) { + rels.push(rel); + } + } + if package_digest(&dir, &rels)? != pkg.digest { + return Err(IdeError::invalid("the package arrived incomplete or changed")); + } + let info = self.host.install_from_dir(&dir, consent)?; + if info.id != pkg.id { + let _ = self.host.uninstall(&info.id); + return Err(IdeError::invalid(format!("the package is plugin {:?}", info.id))); + } + Ok(()) + })(); + let _ = std::fs::remove_dir_all(&dir); + result + } + + /// The run-time commands of remote windows plus [`SYNC_PACKAGES`]. + pub fn register(self: &Arc, d: &mut Dispatcher) { + let this = self.clone(); + d.add(SYNC_PACKAGES, move |c| { + let this = this.clone(); + async move { + let packages: Vec = c.arg("packages")?; + let files: Option>> = c.arg("files")?; + blocking(move || Ok(this.sync(packages, files.unwrap_or_default()))).await + } + }); + let host = Arc::downgrade(&self.host); + let h = host.clone(); + d.add("plugin_run_command", move |c| { + let h = h.clone(); + async move { + let host = alive(&h)?; + let plugin_id: String = c.arg("pluginId")?; + let command_id: String = c.arg("commandId")?; + let arg: Option = c.arg("arg")?; + let editor: Option = c.arg("editor")?; + let invocation = Invocation { + client: Some(c.window.clone()), + workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()), + editor: editor.map(PluginEditor::context), + }; + blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await + } + }); + let h = host.clone(); + d.add("plugin_post_events", move |c| { + let h = h.clone(); + async move { + alive(&h)?.post_events(Some(&c.window), c.arg("events")?); + Ok(()) + } + }); + let h = host.clone(); + d.add("plugin_event_kinds", move |_| { + let h = h.clone(); + async move { Ok(alive(&h)?.event_kinds().into_iter().collect::>()) } + }); + let h = host.clone(); + d.add("plugin_status_items", move |_| { + let h = h.clone(); + async move { + Ok(alive(&h)? + .status_items() + .into_iter() + .map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item }) + .collect::>()) + } + }); + let h = host.clone(); + d.add("plugin_cancel_task", move |c| { + let h = h.clone(); + async move { Ok(alive(&h)?.cancel_task(&c.arg::("taskId")?)) } + }); + let h = host.clone(); + d.add("plugin_set_active_file", move |c| { + let h = h.clone(); + async move { + alive(&h)?.set_active_file(&c.window, c.arg("path")?); + Ok(()) + } + }); + let h = host.clone(); + d.add("plugin_view_render", move |c| { + let h = h.clone(); + async move { + let plugins = alive(&h)?; + super::views::render( + &c.host, + plugins, + &c.window, + c.arg("pluginId")?, + c.arg("view")?, + c.arg("viewport")?, + c.arg("doc")?, + ) + .await + } + }); + let h = host.clone(); + d.add("plugin_view_event", move |c| { + let h = h.clone(); + async move { + let plugins = alive(&h)?; + super::views::event( + &c.host, + plugins, + &c.window, + c.arg("pluginId")?, + c.arg("view")?, + c.arg("event")?, + c.arg("doc")?, + ) + .await + } + }); + d.add("plugin_line_markers", |c| async move { + crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await + }); + } +} + +fn alive(host: &Weak) -> IdeResult> { + host.upgrade().ok_or_else(|| IdeError::unavailable("the plugin host has shut down")) +} + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} + +fn spawn_supervision(host: Weak) -> IdeResult<()> { + std::thread::Builder::new().name("remote-plugins".into()).spawn(move || { + for n in 1u32.. { + std::thread::sleep(EVENT_FLUSH); + let Some(host) = host.upgrade() else { return }; + host.flush_events(); + if n % TICKS_PER_SUPERVISION == 0 { + host.tick(); + } + } + })?; + Ok(()) +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 128 + && !id.starts_with('.') + && id.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) +} + +/// `path` as a relative path inside a package (no `..`, no root). +fn safe_relative(path: &str) -> IdeResult { + let p = Path::new(path); + if path.is_empty() || !p.components().all(|c| matches!(c, Component::Normal(_))) { + return Err(IdeError::invalid(format!("invalid file path {path:?} in package"))); + } + Ok(p.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_paths_stay_inside_the_package() { + assert!(safe_relative("dist/plugin.wasm").is_ok()); + for bad in ["", "../x", "/etc/passwd", "a/../../b", "./x"] { + assert!(safe_relative(bad).is_err(), "{bad}"); + } + assert!(valid_id("ai-completion")); + assert!(!valid_id("../x") && !valid_id(".data") && !valid_id("")); + } +} diff --git a/crates/ide-host/src/plugins/run_detectors.rs b/crates/ide-host/src/plugins/run_detectors.rs new file mode 100644 index 000000000..0fdb92c91 --- /dev/null +++ b/crates/ide-host/src/plugins/run_detectors.rs @@ -0,0 +1,168 @@ +//! The `runDetectors` extension point: run configurations a plugin offers in +//! projects that contain a marker file (docs/plugin_plan.md P3, "open +//! `ide-run` detection"). +//! +//! ```toml +//! [[contributes.runDetectors]] +//! id = "forge-test" +//! name = "forge test" +//! files = ["foundry.toml"] # any of these at the project root +//! command = "forge" +//! args = ["test"] +//! test = true # results go to the test tree +//! ``` +//! +//! The command is a `process:` capability like a language server's: the +//! configuration is offered only while it is granted, and starts the approved +//! file (checked right before it starts). + +use ide_extension::{ + Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, check_relative, decode, validate_item_id, +}; +use ide_language::ProgramPin; +use ide_run::{EXTERNAL_PREFIX, ExternalDetection, RunConfig}; +use parking_lot::RwLock; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub const POINT_ID: &str = "runDetectors"; +const MAX_FILES: usize = 16; +const MAX_ARGS: usize = 64; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Detector { + pub id: String, + pub name: String, + pub files: Vec, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub test: bool, +} + +/// Detectors of enabled plugins. +#[derive(Default)] +pub struct RunDetectors { + items: RwLock>, + seq: AtomicU64, +} + +impl RunDetectors { + fn add(self: &Arc, plugin: String, detector: Detector) -> Disposable { + let seq = self.seq.fetch_add(1, Ordering::Relaxed); + self.items.write().push((seq, plugin, detector)); + let weak = Arc::downgrade(self); + Disposable::new(move || { + if let Some(this) = weak.upgrade() { + this.items.write().retain(|(s, _, _)| *s != seq); + } + }) + } +} + +pub struct RunDetectorsPoint { + store: Arc, +} + +impl RunDetectorsPoint { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +impl ExtensionPoint for RunDetectorsPoint { + fn id(&self) -> &'static str { + POINT_ID + } + + fn placement(&self) -> Placement { + Placement::Workspace + } + + fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result { + let d: Detector = decode(POINT_ID, decl)?; + validate_item_id(&d.id, "run detector id")?; + if d.name.trim().is_empty() || d.name.len() > 128 { + return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id)); + } + if d.files.is_empty() || d.files.len() > MAX_FILES { + return Err(format!("run detector {:?}: give 1–{MAX_FILES} marker `files`", d.id)); + } + for f in &d.files { + check_relative(f, "files")?; + } + let program = d.command.trim(); + if program.is_empty() || program.contains(char::is_whitespace) { + return Err(format!("run detector {:?}: `command` is one program name (arguments go in `args`)", d.id)); + } + if d.args.len() > MAX_ARGS { + return Err(format!("run detector {:?}: at most {MAX_ARGS} arguments", d.id)); + } + let cap = Capability::Process(program.to_owned()); + let (store, plugin, label) = (self.store.clone(), pkg.id().to_owned(), d.name.clone()); + let key = d.id.clone(); + Ok(Prepared::new(label, move || Ok(store.add(plugin, d))).key(key).capability(cap)) + } +} + +type Pins = Arc>>; + +/// [`ExternalDetection`] over the detectors and the approved programs. +pub struct PluginRunDetection { + pub(crate) store: Arc, + pub(crate) pins: Pins, +} + +fn config_id(plugin: &str, detector: &str) -> String { + format!("{EXTERNAL_PREFIX}{plugin}:{detector}") +} + +impl PluginRunDetection { + fn pin(&self, plugin: &str, d: &Detector) -> Option { + let cap = Capability::Process(d.command.trim().to_owned()); + self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned() + } +} + +impl ExternalDetection for PluginRunDetection { + fn detect(&self, root: &Path) -> Vec { + let items = self.store.items.read().clone(); + items + .into_iter() + .filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists())) + .map(|(_, plugin, d)| RunConfig { + id: config_id(&plugin, &d.id), + name: d.name.clone(), + kind: "plugin".into(), + command: d.command.trim().to_owned(), + args: d.args.clone(), + cwd: None, + env: Default::default(), + detected: true, + is_test: d.test, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }) + .collect() + } + + fn launch(&self, config: &RunConfig) -> Result { + let items = self.store.items.read(); + let (plugin, d) = items + .iter() + .find(|(_, plugin, d)| config_id(plugin, &d.id) == config.id) + .map(|(_, p, d)| (p.clone(), d.clone())) + .ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?; + drop(items); + let pin = self.pin(&plugin, &d).ok_or_else(|| { + format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command) + })?; + pin.verify().map(Path::to_path_buf) + } +} diff --git a/crates/ide-plugin/src/theme.rs b/crates/ide-host/src/plugins/theme.rs similarity index 73% rename from crates/ide-plugin/src/theme.rs rename to crates/ide-host/src/plugins/theme.rs index 0d4e65e7a..2de8eabdb 100644 --- a/crates/ide-plugin/src/theme.rs +++ b/crates/ide-host/src/plugins/theme.rs @@ -1,9 +1,21 @@ -//! Theme contributions: design-token overrides applied as CSS variables on `:root`. -//! Theme and editor-scheme files are JSON that may contain `//` and `/* */` comments. +//! The `themes` extension point: design-token overrides applied as CSS +//! variables on `:root`. Theme and editor-scheme files are JSON that may +//! contain `//` and `/* */` comments. +//! +//! ```toml +//! [[contributes.themes]] +//! path = "themes/harbor-night.json" +//! editor-scheme = "themes/harbor-night.scheme.json" # optional +//! ``` +use ide_extension::{ExtensionPoint, Package, Placement, Prepared, UiContribution, UiStore, decode}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +pub const POINT_ID: &str = "themes"; const MAX_TOKENS: usize = 512; const MAX_VALUE_LEN: usize = 256; @@ -48,7 +60,7 @@ impl ThemeDef { } pub fn validate(&self) -> Result<(), String> { - crate::manifest::validate_command_id(&self.id).map_err(|e| e.replace("command id", "theme id"))?; + ide_extension::validate_item_id(&self.id, "theme id")?; if self.name.trim().is_empty() { return Err("theme name must not be empty".into()); } @@ -71,6 +83,63 @@ impl ThemeDef { } } +pub struct ThemesPoint { + store: Arc, +} + +impl ThemesPoint { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct Decl { + path: Option, + #[serde(default)] + editor_scheme: Option, + /// The theme JSON inline (runtime `register-theme`). + definition: Option, +} + +impl ExtensionPoint for ThemesPoint { + fn id(&self) -> &'static str { + POINT_ID + } + + fn placement(&self) -> Placement { + Placement::Ui + } + + fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result { + let decl: Decl = decode(POINT_ID, decl)?; + let mut files = Vec::new(); + let mut theme = match (&decl.path, &decl.definition) { + (Some(path), None) => { + files.push(path.clone()); + ThemeDef::parse(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))? + } + (None, Some(def)) => ThemeDef::parse(def)?, + _ => return Err("give either `path` or `definition`".into()), + }; + if let Some(scheme) = &decl.editor_scheme { + files.push(scheme.clone()); + theme.merge_editor_scheme(&pkg.read_text(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?; + } + let item = UiContribution { + plugin_id: pkg.id().to_owned(), + point: POINT_ID.into(), + key: theme.id.clone(), + decl: serde_json::to_value(&theme).map_err(|e| e.to_string())?, + }; + let store = self.store.clone(); + let mut prepared = Prepared::new(theme.name.clone(), move || Ok(store.insert(item))).key(theme.id); + prepared.files = files; + Ok(prepared) + } +} + /// Blanks out `//` and `/* */` comments outside strings, keeping newlines so /// parse errors still point at the right line. fn strip_json_comments(src: &str) -> String { diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs new file mode 100644 index 000000000..c9cac4309 --- /dev/null +++ b/crates/ide-host/src/plugins/types.rs @@ -0,0 +1,236 @@ +//! UI-facing plugin types and the event stream. + +use super::theme::ThemeDef; +use ide_extension::{CapabilityInfo, ContributionInfo, UiContribution}; +pub use ide_plugin::{ActionPerformed, ActionSource, EventKind, NotificationLevel, StatusItem, WorkbenchEvent}; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum PluginSource { + /// Installed under `/plugins//`. + Installed, + /// Loaded from `/.openidea/plugins//` while that project is open. + Dev, + /// A development plugin linked from any folder (`openidea-plugin link`). + Linked, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum PluginStatus { + /// Code instantiated and activated. + Active, + /// Has code that has not been needed yet: contributions are live, no Wasm instance. + Inactive, + /// No code; contributions only. + Static, + Disabled, + /// Waiting for the user to confirm its permissions (project and new plugins). + NeedsConsent, + /// Failed to load, or failed repeatedly and was stopped for this session (see `error`). + Failed, +} + +impl PluginStatus { + /// Contributions are in effect. + pub fn is_live(self) -> bool { + matches!(self, Self::Active | Self::Inactive | Self::Static) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginInfo { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub authors: Vec, + pub homepage: Option, + pub change_notes: Option, + pub source: PluginSource, + /// Package directory. + pub path: ide_core::IpcPath, + pub enabled: bool, + pub status: PluginStatus, + pub error: Option, + pub has_code: bool, + /// Declared capabilities with risk and grant state. + pub capabilities: Vec, + /// What it contributes while live (point + label). + pub contributions: Vec, + /// `activation` events of the manifest. + pub activation: Vec, + /// Non-fatal problems (unsupported contribution points). + pub warnings: Vec, + /// Statistics of this session. + pub stats: PluginStats, + /// The marketplace publisher whose signature the package carries (none: unsigned or untrusted). + pub publisher: Option, + /// The version an update replaced, to roll back to. + pub previous_version: Option, +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginStats { + pub activations: u32, + /// Last activation (instantiate + `activate`), ms. + pub activation_ms: f64, + pub calls: u32, + pub errors: u32, + /// Slowest call, ms. + pub max_call_ms: f64, + /// 95th percentile of the recent calls, ms. + pub p95_call_ms: f64, + /// Edit-path calls whose result came too late and was dropped. + pub slow_calls: u32, + /// Traps (panics, budgets, permission violations); each drops the instance. + pub crashes: u32, + /// Instances reclaimed after being idle. + pub reclaims: u32, +} + +/// Number of declarations of one extension point. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ContributionCount { + pub point: String, + pub count: u32, +} + +/// A plugin package available for installation from a local catalog +/// directory (the samples shipped with the IDE). Not installed by listing it. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CatalogPlugin { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub authors: Vec, + pub homepage: Option, + pub change_notes: Option, + /// Package directory; install with `install_from_dir(path)`. + pub path: ide_core::IpcPath, + pub has_code: bool, + pub capabilities: Vec, + pub contributions: Vec, + /// Why the package cannot be installed as is (e.g. its WASM is not built). + pub problem: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ActiveTheme { + pub plugin_id: String, + pub theme: ThemeDef, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginsSnapshot { + pub plugins: Vec, + pub active_theme: Option, + /// UI contributions of live plugins, projected by the client. + pub ui: Vec, +} + +/// Backend → UI notifications (Tauri event `ide://plugin`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PluginEvent { + /// Plugins, their status or their contributions changed: re-fetch `plugin_list`. + CommandsChanged, + #[serde(rename_all = "camelCase")] + Notification { plugin_id: String, level: NotificationLevel, title: String, body: String }, + /// A plugin's status bar item was set (`item`) or removed. + #[serde(rename_all = "camelCase")] + StatusItemChanged { plugin_id: String, id: String, item: Option }, + /// A plugin began a task: show it with the background tasks. + #[serde(rename_all = "camelCase")] + TaskStarted { plugin_id: String, task_id: String, title: String, cancellable: bool }, + #[serde(rename_all = "camelCase")] + TaskProgress { plugin_id: String, task_id: String, done: u32, total: Option, message: Option }, + #[serde(rename_all = "camelCase")] + TaskEnded { plugin_id: String, task_id: String }, + /// The kinds of workbench events plugins want changed: re-fetch + /// `plugin_event_kinds` (the UI reports only those). + SubscriptionsChanged, + #[serde(rename_all = "camelCase")] + ThemeChanged { theme: Option }, + /// A plugin needs capabilities the user has not granted yet (first use + /// after migrating from 0.1): show its consent dialog. + #[serde(rename_all = "camelCase")] + ConsentRequested { plugin_id: String, capabilities: Vec }, + /// A view (or the custom editors) of a plugin must be rendered again. + #[serde(rename_all = "camelCase")] + ViewChanged { plugin_id: String, view: String }, + /// For the script of a view's `html` body. + #[serde(rename_all = "camelCase")] + ViewMessage { plugin_id: String, view: String, message: String }, + /// Open a terminal running `program` (already approved and verified). + #[serde(rename_all = "camelCase")] + OpenTerminal { + plugin_id: String, + title: String, + program: ide_core::IpcPath, + args: Vec, + cwd: ide_core::IpcPath, + }, + /// Plugin languages, or the servers they may start, changed: re-fetch + /// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]). + LanguagesChanged, +} + +/// Delivers events to the UI. `client` is the window that invoked the plugin +/// (`None` = broadcast, e.g. during activation). +pub trait PluginEventSink: Send + Sync { + fn emit(&self, client: Option<&str>, event: PluginEvent); +} + +impl, PluginEvent) + Send + Sync> PluginEventSink for F { + fn emit(&self, client: Option<&str>, event: PluginEvent) { + self(client, event) + } +} + +/// The focused editor when a plugin command is invoked. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginEditor { + pub path: String, + pub language: String, + pub selections: Vec, +} + +impl PluginEditor { + pub fn context(self) -> ide_plugin::EditorContext { + ide_plugin::EditorContext { path: self.path.into(), language: self.language, selections: self.selections } + } +} + +/// A plugin's status bar item. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginStatusItem { + pub plugin_id: String, + pub id: String, + pub item: StatusItem, +} + +/// A gutter marker from a plugin's `line-markers` provider. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct LineMarkerInfo { + pub plugin_id: String, + /// 0-based. + pub line: u32, + /// `navigate` (default), `implemented`, `implementing`, `run`. + pub icon: Option, + pub tooltip: String, + /// Opened on click. + pub target: Option, +} diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs new file mode 100644 index 000000000..538e402e4 --- /dev/null +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -0,0 +1,420 @@ +//! Declarative UI contributions (docs/plugin_plan.md P7): menus, key +//! bindings, status bar items and settings (L0, shown before any code runs), +//! and the views and custom editors whose content plugin code renders (L2). +//! Everything is validated here; the client only projects the JSON. +//! +//! ```toml +//! [[contributes.menus]] +//! menu = "editor.context" # projectView.context, editorTab.context, tools +//! command = "check" +//! when = "editorLang == java" +//! +//! [[contributes.keymaps]] +//! command = "check" +//! key = "Ctrl+Alt+C" # `mac` for a different binding on macOS (`Meta+Alt+C`) +//! +//! [[contributes.statusBar]] +//! id = "branch" +//! text = "…" # code replaces it with `set-status-item("branch", …)` +//! command = "show" +//! +//! [contributes.settings] +//! schema = "settings.schema.json" # {"properties": {"key": {"type": "boolean", "default": true, …}}} +//! +//! [[contributes.views]] +//! id = "clusters" +//! title = "Kubernetes" +//! icon = "server" +//! location = "right" # left, right, bottom +//! +//! [[contributes.customEditors]] +//! id = "table" +//! title = "Table" +//! patterns = ["*.csv", "*.tsv"] +//! priority = "default" # or "option": only through Open With +//! ``` + +use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::path::PathBuf; + +pub const MENUS: &str = "menus"; +pub const KEYMAPS: &str = "keymaps"; +pub const STATUS_BAR: &str = "statusBar"; +pub const SETTINGS: &str = "settings"; +pub const VIEWS: &str = "views"; +pub const CUSTOM_EDITORS: &str = "customEditors"; + +const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"]; +const MAX_TITLE: usize = 256; +const MAX_SETTINGS: usize = 200; +const MAX_PATTERNS: usize = 16; + +/// Command ids the package declares. +fn commands(pkg: &Package) -> BTreeSet { + pkg.manifest + .declarations(super::COMMANDS) + .iter() + .filter_map(|d| d.get("id").and_then(|v| v.as_str()).map(str::to_owned)) + .collect() +} + +fn declared_command(pkg: &Package, point: &str, command: &str) -> Result<(), String> { + if commands(pkg).contains(command) { + Ok(()) + } else { + Err(format!("contributes.{point}: command {command:?} is not declared under [[contributes.commands]]")) + } +} + +fn title(point: &str, t: &str) -> Result<(), String> { + if t.trim().is_empty() || t.len() > MAX_TITLE { + return Err(format!("contributes.{point}: titles are 1–{MAX_TITLE} bytes")); + } + Ok(()) +} + +fn json(v: &T) -> Result { + serde_json::to_value(v).map_err(|e| e.to_string()) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct MenuDecl { + menu: String, + command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + when: Option, +} + +pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result { + let d: MenuDecl = decode(MENUS, decl)?; + if !MENU_IDS.contains(&d.menu.as_str()) { + return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", "))); + } + declared_command(pkg, MENUS, &d.command)?; + if let Some(w) = &d.when { + validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?; + } + Ok(UiDecl::new(format!("{}/{}", d.menu, d.command), d.command.clone(), json(&d)?)) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct KeymapDecl { + command: String, + key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + mac: Option, +} + +const MODIFIERS: [&str; 4] = ["Meta", "Ctrl", "Alt", "Shift"]; +const NAMED_KEYS: [&str; 22] = [ + "Enter", + "Escape", + "Tab", + "Space", + "Backspace", + "Delete", + "Insert", + "Home", + "End", + "PageUp", + "PageDown", + "Up", + "Down", + "Left", + "Right", + "Minus", + "Equal", + "Comma", + "Period", + "Slash", + "Semicolon", + "Quote", +]; + +/// `Ctrl+Alt+C`, `Meta+K Meta+C` (a chord): at least one modifier per +/// stroke but for function keys, so plugins can't take plain typing. +pub fn validate_binding(binding: &str) -> Result<(), String> { + let strokes: Vec<&str> = binding.split(' ').collect(); + if strokes.is_empty() || strokes.len() > 2 { + return Err(format!("{binding:?}: one stroke, or two for a chord")); + } + for stroke in strokes { + let parts: Vec<&str> = stroke.split('+').collect(); + let (key, mods) = parts.split_last().ok_or_else(|| format!("{binding:?}: empty stroke"))?; + let mut seen = BTreeSet::new(); + for m in mods { + if !MODIFIERS.contains(m) || !seen.insert(*m) { + return Err(format!("{binding:?}: unknown or repeated modifier {m:?}")); + } + } + let function = key.strip_prefix('F').and_then(|n| n.parse::().ok()).is_some_and(|n| (1..=24).contains(&n)); + let plain = key.len() == 1 && key.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()); + if !(function || plain || NAMED_KEYS.contains(key)) { + return Err(format!("{binding:?}: unknown key {key:?}")); + } + if !function && seen.iter().all(|m| *m == "Shift") { + return Err(format!("{binding:?}: needs Ctrl, Alt or Meta (plain keys are for typing)")); + } + } + Ok(()) +} + +pub(super) fn keymap(pkg: &Package, decl: toml::Value) -> Result { + let d: KeymapDecl = decode(KEYMAPS, decl)?; + declared_command(pkg, KEYMAPS, &d.command)?; + validate_binding(&d.key).map_err(|e| format!("contributes.keymaps: {e}"))?; + if let Some(m) = &d.mac { + validate_binding(m).map_err(|e| format!("contributes.keymaps: {e}"))?; + } + Ok(UiDecl::new(d.command.clone(), d.key.clone(), json(&d)?)) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct StatusDecl { + id: String, + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + tooltip: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option, + #[serde(default = "right")] + align: String, + #[serde(default)] + priority: u32, +} + +fn right() -> String { + "right".into() +} + +pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result { + let d: StatusDecl = decode(STATUS_BAR, decl)?; + validate_item_id(&d.id, "status bar item id")?; + if d.text.is_empty() || d.text.len() > 128 || d.tooltip.as_ref().is_some_and(|t| t.len() > 1024) { + return Err(format!("status bar item {:?}: `text` 1–128 bytes, `tooltip` up to 1 KiB", d.id)); + } + if d.align != "left" && d.align != "right" { + return Err(format!("status bar item {:?}: `align` is left or right", d.id)); + } + if d.priority > 1000 { + return Err(format!("status bar item {:?}: `priority` is 0–1000", d.id)); + } + if let Some(c) = &d.command { + declared_command(pkg, STATUS_BAR, c)?; + } + Ok(UiDecl::new(d.id.clone(), d.text.clone(), json(&d)?)) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SettingsDecl { + #[serde(default)] + title: Option, + schema: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct SchemaFile { + properties: serde_json::Map, +} + +/// One setting of a plugin. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingSchema { + pub key: String, + /// `boolean`, `string`, `number` or `integer`. + #[serde(rename = "type")] + pub kind: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub default: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option>, + #[serde(default)] + pub multiline: bool, +} + +impl SettingSchema { + /// Whether `v` is a valid value of this setting. + pub fn accepts(&self, v: &Value) -> bool { + match self.kind.as_str() { + "boolean" => v.is_boolean(), + "integer" => v.is_i64() || v.is_u64(), + "number" => v.is_number(), + _ => v + .as_str() + .is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))), + } + } +} + +fn setting(key: &str, raw: &Value) -> Result { + if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) { + return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]")); + } + let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?; + let kind = o.get("type").and_then(Value::as_str).unwrap_or(""); + let default = match kind { + "boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)), + "string" => o.get("default").cloned().unwrap_or(Value::String(String::new())), + "number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)), + _ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")), + }; + let options = match o.get("enum") { + None => None, + Some(Value::Array(a)) if kind == "string" && !a.is_empty() && a.len() <= 100 => Some( + a.iter() + .map(|v| v.as_str().map(str::to_owned).ok_or_else(|| format!("setting {key:?}: `enum` lists strings"))) + .collect::, _>>()?, + ), + Some(_) => return Err(format!("setting {key:?}: `enum` is 1–100 strings of a string setting")), + }; + let text = |name: &str, max: usize| -> Result, String> { + match o.get(name) { + None => Ok(None), + Some(Value::String(s)) if s.len() <= max => Ok(Some(s.clone())), + Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")), + } + }; + let s = SettingSchema { + key: key.to_owned(), + kind: kind.to_owned(), + title: text("title", MAX_TITLE)?.unwrap_or_else(|| key.to_owned()), + description: text("description", 4096)?, + default, + options, + multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false), + }; + if !s.accepts(&s.default) { + return Err(format!("setting {key:?}: the default does not match its type")); + } + Ok(s) +} + +/// The validated settings of a `[contributes.settings]` schema file. +pub fn parse_settings_schema(text: &str) -> Result, String> { + let file: SchemaFile = serde_json::from_str(text).map_err(|e| e.to_string())?; + if file.properties.len() > MAX_SETTINGS { + return Err(format!("more than {MAX_SETTINGS} settings")); + } + file.properties.iter().map(|(k, v)| setting(k, v)).collect() +} + +pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result { + let d: SettingsDecl = decode(SETTINGS, decl)?; + let text = pkg.read_text(&d.schema)?; + let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?; + let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone()); + title_ok(&title)?; + let mut decl = + UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties })); + decl.files.push(d.schema); + Ok(decl) +} + +fn title_ok(t: &str) -> Result<(), String> { + title(SETTINGS, t) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ViewDecl { + id: String, + title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + icon: Option, + #[serde(default = "right")] + location: String, +} + +pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { + let d: ViewDecl = decode(VIEWS, decl)?; + validate_item_id(&d.id, "view id")?; + title(VIEWS, &d.title)?; + if pkg.manifest.wasm.is_none() { + return Err(format!("view {:?}: views are rendered by the plugin's code (`wasm`)", d.id)); + } + if !["left", "right", "bottom"].contains(&d.location.as_str()) { + return Err(format!("view {:?}: `location` is left, right or bottom", d.id)); + } + if d.icon.as_ref().is_some_and(|i| i.len() > 64) { + return Err(format!("view {:?}: icon names are up to 64 bytes", d.id)); + } + Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?)) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct CustomEditorDecl { + id: String, + title: String, + patterns: Vec, + #[serde(default = "default_priority")] + priority: String, +} + +fn default_priority() -> String { + "default".into() +} + +pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result { + let d: CustomEditorDecl = decode(CUSTOM_EDITORS, decl)?; + validate_item_id(&d.id, "custom editor id")?; + title(CUSTOM_EDITORS, &d.title)?; + if pkg.manifest.wasm.is_none() { + return Err(format!("custom editor {:?}: needs the plugin's code (`wasm`)", d.id)); + } + if d.patterns.is_empty() || d.patterns.len() > MAX_PATTERNS { + return Err(format!("custom editor {:?}: 1–{MAX_PATTERNS} file name `patterns`", d.id)); + } + for p in &d.patterns { + if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() { + return Err(format!("custom editor {:?}: invalid file name pattern {p:?}", d.id)); + } + } + if d.priority != "default" && d.priority != "option" { + return Err(format!("custom editor {:?}: `priority` is default or option", d.id)); + } + Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bindings_need_a_modifier() { + for ok in ["Ctrl+Alt+C", "Meta+Shift+K", "F5", "Shift+F6", "Meta+K Meta+C", "Alt+Enter"] { + assert!(validate_binding(ok).is_ok(), "{ok}"); + } + for bad in ["C", "Shift+C", "Hyper+C", "Ctrl+Ctrl+C", "Ctrl+é", "Ctrl+A B C", ""] { + assert!(validate_binding(bad).is_err(), "{bad}"); + } + } + + #[test] + fn settings_schemas_are_typed() { + let s = parse_settings_schema( + r#"{"properties": { + "context": {"type": "string", "title": "Context", "default": ""}, + "watch": {"type": "boolean", "default": true}, + "mode": {"type": "string", "enum": ["a", "b"], "default": "b"}, + "limit": {"type": "integer", "default": 100} + }}"#, + ) + .unwrap(); + assert_eq!(s.len(), 4); + assert!(s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("a"))); + assert!(!s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("c"))); + assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err()); + assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err()); + } +} diff --git a/crates/ide-host/src/plugins/views.rs b/crates/ide-host/src/plugins/views.rs new file mode 100644 index 000000000..e096ddb30 --- /dev/null +++ b/crates/ide-host/src/plugins/views.rs @@ -0,0 +1,53 @@ +//! Declarative views and custom editors (docs/plugin_plan.md P7) as the +//! window commands `plugin_view_render` / `plugin_view_event` serve them — +//! the desktop for local projects, `openidea-server` for remote ones. + +use super::PluginHost; +use ide_core::{DocId, IdeError, IdeResult}; +use ide_plugin::{EditorContext, Invocation, View, ViewEvent, Viewport}; +use std::sync::Arc; + +/// The window's project, plus the document a custom editor shows. +pub fn view_invocation(host: &crate::Host, window: &str, doc: Option) -> IdeResult { + let project = host.project(window).ok(); + let editor = match doc { + None => None, + Some(doc) => { + let project = project.as_ref().ok_or_else(|| IdeError::not_found("no project is open in this window"))?; + let info = project.documents.snapshot(doc)?; + Some(EditorContext { path: info.path.0, language: info.language.0, selections: Vec::new() }) + } + }; + Ok(Invocation { client: Some(window.to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor }) +} + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} + +#[allow(clippy::too_many_arguments)] +pub async fn render( + host: &crate::Host, + plugins: Arc, + window: &str, + plugin_id: String, + view: String, + viewport: Option, + doc: Option, +) -> IdeResult { + let invocation = view_invocation(host, window, doc)?; + blocking(move || plugins.render_view(&plugin_id, &view, viewport, invocation)).await +} + +pub async fn event( + host: &crate::Host, + plugins: Arc, + window: &str, + plugin_id: String, + view: String, + event: ViewEvent, + doc: Option, +) -> IdeResult<()> { + let invocation = view_invocation(host, window, doc)?; + blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await +} diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 2ab5bd7f6..38795f7d0 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -9,7 +9,10 @@ //! * server → client notification `event` `{ window, event }` (an `IdeEvent` //! for that window, or every window when `window` is null); //! * server → client notification `channel` `{ id, data }`: one value of a -//! streaming argument. +//! streaming argument; +//! * server → client notification `plugin` `{ window, event }` and request +//! `plugin_secret` `{ plugin, name }` → `string | null`: code plugins of the +//! remote project (`plugins::remote_host`). use crate::Host; use crate::ops::{self, Emit}; diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index d0e0731dd..ea9b96042 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -273,6 +273,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "plugin_set_theme" | "plugin_set_theme_frame" | "plugin_catalog" + | "plugin_assets" | "notebook_open" | "notebook_get" | "notebook_edit" diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs new file mode 100644 index 000000000..f7a1ac64a --- /dev/null +++ b/crates/ide-host/tests/distribution.rs @@ -0,0 +1,262 @@ +//! Plan P8: versioned installs with automatic rollback, signed packages from +//! a static marketplace index, and revocation. + +use ide_extension::package::{self, Index, IndexEntry, KeyEntry, Publisher, PublisherKey, Revocation}; +use ide_host::plugins::*; +use ide_plugin::{Invocation, Limits}; +use parking_lot::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +type Events = Arc>>; + +fn repo() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap().flatten() { + let (src, dst) = (entry.path(), to.join(entry.file_name())); + if entry.file_name() == "target" { + continue; + } + if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() } + } +} + +/// A copy of `from` (under `dir/name`) whose manifest is rewritten by `f`. +fn variant(from: &Path, dir: &Path, name: &str, f: impl Fn(&str) -> String) -> PathBuf { + let pkg = dir.join(name); + copy_dir(from, &pkg); + let manifest = pkg.join("plugin.toml"); + std::fs::write(&manifest, f(&std::fs::read_to_string(&manifest).unwrap())).unwrap(); + pkg +} + +struct Fixture { + host: PluginHost, + plugins_dir: PathBuf, + events: Events, + _tmp: tempfile::TempDir, + work: PathBuf, +} + +fn fixture() -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + let events: Events = Arc::default(); + let sink = { + let events = events.clone(); + move |_: Option<&str>, e: PluginEvent| events.lock().push(e) + }; + let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), Limits::default()).unwrap(); + host.load_all(); + let work = tmp.path().join("work"); + std::fs::create_dir_all(&work).unwrap(); + Fixture { host, plugins_dir, events, work, _tmp: tmp } +} + +fn inv() -> Invocation { + Invocation { client: Some("main".into()), workspace_root: None, editor: None } +} + +fn misbehave() -> PathBuf { + repo().join("crates/ide-plugin/tests/fixtures/misbehave") +} + +/// P8 acceptance: an update that fails to start rolls back to the version it replaced. +#[test] +fn a_failing_update_rolls_back_automatically() { + let f = fixture(); + let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\"")); + f.host.install_from_dir(&v1, &Consent::all()).unwrap(); + assert_eq!(f.host.run_command("misbehave", "echo", Some("hi"), inv()).unwrap().as_deref(), Some("hi")); + + let broken = + variant(&misbehave(), &f.work, "v2", |t| t.replace("version = \"0.0.2\"", "version = \"2.0.0-broken\"")); + let info = f.host.install_from_dir(&broken, &Consent::all()).unwrap(); + assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("2.0.0-broken", Some("1.0.0"))); + let root = f.plugins_dir.join("misbehave"); + assert!(root.join("2.0.0-broken").is_dir() && root.join("1.0.0").is_dir(), "versions side by side"); + + // Its first start fails: back to 1.0.0, without the user doing anything. + f.events.lock().clear(); + let err = f.host.run_command("misbehave", "echo", Some("x"), inv()).unwrap_err(); + assert!(err.message.contains("version 1.0.0 was restored"), "{}", err.message); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.version.as_str(), info.status), ("1.0.0", PluginStatus::Inactive)); + assert!(!root.join("2.0.0-broken").exists(), "the failed version is removed"); + assert!( + f.events + .lock() + .iter() + .any(|e| matches!(e, PluginEvent::Notification { title, .. } if title.contains("rolled back"))) + ); + assert_eq!(f.host.run_command("misbehave", "echo", Some("ok"), inv()).unwrap().as_deref(), Some("ok")); + + // A working update is confirmed by its first start; the user can still roll it back. + let v3 = variant(&misbehave(), &f.work, "v3", |t| t.replace("version = \"0.0.2\"", "version = \"3.0.0\"")); + f.host.install_from_dir(&v3, &Consent::all()).unwrap(); + f.host.run_command("misbehave", "echo", None, inv()).unwrap(); + assert_eq!(f.host.info_of("misbehave").unwrap().version, "3.0.0"); + let info = f.host.roll_back_update("misbehave").unwrap(); + assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", None)); + assert!(f.host.roll_back_update("misbehave").is_err(), "nothing older to go back to"); + + // A restart loads the active version. + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.info_of("misbehave").unwrap().version, "1.0.0"); + f.host.uninstall("misbehave").unwrap(); + assert!(!root.exists(), "every version goes"); +} + +#[test] +fn installs_from_before_versioning_keep_working_and_move_on_update() { + let f = fixture(); + // The flat layout of earlier releases. + copy_dir(&misbehave(), &f.plugins_dir.join("misbehave")); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.info_of("misbehave").unwrap().version, "0.0.2"); + let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\"")); + let info = again.install_from_dir(&v1, &Consent::all()).unwrap(); + assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", Some("0.0.2"))); + assert!(f.plugins_dir.join("misbehave/0.0.2/plugin.toml").is_file()); + assert!(f.plugins_dir.join("misbehave/1.0.0/plugin.toml").is_file()); +} + +// ---- marketplace ------------------------------------------------------------------ + +/// `hello` as `acme.hello` at `version`, packed and signed with `key`. +fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost) -> (PathBuf, IndexEntry) { + let src = variant(&repo().join("plugins/hello"), dir, &format!("src-{version}"), |t| { + t.replace("id = \"hello\"", "id = \"acme.hello\"") + .replace("version = \"0.3.0\"", &format!("version = \"{version}\"")) + }); + let files = host.package_files_of(&src).unwrap(); + let digest = package::package_digest(&src, &files).unwrap(); + let sig = key.sign("acme", "acme.hello", version, &digest); + let out = dir.join(format!("acme.hello-{version}.oiplugin")); + package::pack(&src, &files, &out, Some(&sig)).unwrap(); + let entry = IndexEntry { + id: "acme.hello".into(), + name: "Hello".into(), + version: version.into(), + description: String::new(), + authors: vec![], + publisher: "acme".into(), + download: out.file_name().unwrap().to_string_lossy().into_owned(), + digest, + capabilities: vec![], + }; + (out, entry) +} + +fn write_index( + dir: &Path, + key: &PublisherKey, + plugins: Vec, + revoked: Vec, + key_revoked: bool, +) -> String { + let index = Index { + format: package::INDEX_FORMAT, + publishers: vec![Publisher { + id: "acme".into(), + name: "ACME".into(), + keys: vec![KeyEntry { key: key.public_base64(), revoked: key_revoked }], + }], + plugins, + revoked, + }; + let path = dir.join("index.json"); + std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap(); + path.to_string_lossy().into_owned() +} + +#[test] +fn signed_marketplace_plugins_install_and_revocations_disable_them() { + let f = fixture(); + let market = f.work.join("market"); + std::fs::create_dir_all(&market).unwrap(); + let key = PublisherKey::from_seed([3; 32]); + let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host); + let url = write_index(&market, &key, vec![entry.clone()], vec![], false); + + assert!(f.host.refresh_market().is_err(), "no marketplace configured yet"); + f.host.set_market_url(Some(url.clone())); + let listed = f.host.refresh_market().unwrap(); + assert_eq!((listed[0].entry.id.as_str(), listed[0].installed.as_deref()), ("acme.hello", None)); + + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + let preview = f.host.preview_install(&dir).unwrap(); + assert_eq!((preview.unsigned, preview.publisher.as_deref()), (false, Some("acme"))); + let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap(); + assert_eq!(info.publisher.as_deref(), Some("acme")); + assert_eq!(f.host.market_plugins()[0].installed.as_deref(), Some("1.0.0")); + + // A changed file breaks the signature: refused. + let tampered = f.host.download_market_plugin("acme.hello").unwrap(); + let manifest = std::fs::read_to_string(tampered.join("plugin.toml")).unwrap(); + std::fs::write(tampered.join("plugin.toml"), manifest.replace("Sample plugin", "Totally safe plugin")).unwrap(); + let err = f.host.preview_install(&tampered).unwrap_err(); + assert!(err.message.contains("changed after signing"), "{}", err.message); + + // Someone else's key, or another publisher's prefix: not trusted. + let stranger = PublisherKey::from_seed([4; 32]); + let (foreign, _) = signed_hello(&f.work, "1.0.1", &stranger, &f.host); + let unpacked = f.host.unpack_package(&foreign).unwrap(); + assert!(f.host.preview_install(&unpacked).unwrap_err().message.contains("not one of")); + + // The index revokes the installed version: disabled, the user told. + f.events.lock().clear(); + let revoked = + vec![Revocation { id: "acme.hello".into(), versions: vec!["1.0.0".into()], reason: "steals tokens".into() }]; + write_index(&market, &key, vec![entry.clone()], revoked, false); + f.host.refresh_market().unwrap(); + let info = f.host.info_of("acme.hello").unwrap(); + assert_eq!((info.status, info.error.as_deref()), (PluginStatus::Failed, Some("revoked: steals tokens"))); + assert!( + f.events + .lock() + .iter() + .any(|e| matches!(e, PluginEvent::Notification { body, .. } if body.contains("steals tokens"))) + ); + assert!(f.host.market_plugins().is_empty(), "revoked versions are not offered"); + assert!(f.host.install_from_dir(&dir, &Consent::all()).is_err(), "nor installable"); + f.host.set_enabled("acme.hello", true).unwrap(); + assert_eq!(f.host.info_of("acme.hello").unwrap().status, PluginStatus::Failed, "enabling doesn't bring it back"); + + // Revocations hold across restarts (the index is kept). + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.info_of("acme.hello").unwrap().status, PluginStatus::Failed); + + // A revoked signing key disables what it signed. + write_index(&market, &key, vec![entry.clone()], vec![], true); + f.host.refresh_market().unwrap(); + let info = f.host.info_of("acme.hello").unwrap(); + assert!(info.error.as_deref().unwrap_or("").contains("signing key was revoked"), "{:?}", info.error); + + // Lifted: it runs again. + write_index(&market, &key, vec![entry], vec![], false); + f.host.refresh_market().unwrap(); + let info = f.host.info_of("acme.hello").unwrap(); + assert_eq!((info.status, info.error), (PluginStatus::Inactive, None)); +} + +#[test] +fn package_files_install_from_disk_unsigned() { + let f = fixture(); + let src = variant(&misbehave(), &f.work, "pkg", |t| t.to_owned()); + let files = f.host.package_files_of(&src).unwrap(); + let out = f.work.join("misbehave-0.0.2.oiplugin"); + package::pack(&src, &files, &out, None).unwrap(); + let dir = f.host.unpack_package(&out).unwrap(); + let preview = f.host.preview_install(&dir).unwrap(); + assert_eq!((preview.unsigned, preview.publisher), (true, None)); + let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap(); + assert_eq!((info.status, info.publisher), (PluginStatus::Inactive, None)); +} diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs new file mode 100644 index 000000000..80611eb51 --- /dev/null +++ b/crates/ide-host/tests/plugins.rs @@ -0,0 +1,1867 @@ +//! End-to-end tests of the plugin host against real components. +//! +//! The components are checked in (`plugins/hello/dist/plugin.wasm`, +//! `crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm`) so tests need no +//! wasm toolchain. Rebuild them with `plugins/build.sh hello` and +//! `plugins/build.sh crates/ide-plugin/tests/fixtures/misbehave` after +//! changing their source or `wit/`. + +use ide_host::plugins::*; +use ide_plugin::{EditorContext, HostError, Invocation, Limits}; +use parking_lot::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +type Events = Arc, PluginEvent)>>>; + +/// What plugins see of the test project: real `Documents`, as in the IDE. +struct TestWorkspace { + root: PathBuf, + documents: Arc, + trusted: bool, + /// (producer, path) → published diagnostics. + diagnostics: Mutex>>, + /// plugin → its settings as the user set them. + settings: Mutex>>, +} + +impl TestWorkspace { + fn new(root: &Path) -> Arc { + let documents = ide_workspace::Documents::new( + Arc::new(ide_core::NullSink), + Box::new(|_: &Path, _: Option<&str>| ide_core::LanguageId::new("text")), + ); + Arc::new(Self { + root: root.to_path_buf(), + documents: Arc::new(documents), + trusted: true, + diagnostics: Mutex::default(), + settings: Mutex::default(), + }) + } + + /// Opens `rel` in an "editor": the invocation's focused editor. + fn open(&self, rel: &str, selections: &[(u32, u32, u32, u32)]) -> EditorContext { + let path = self.root.join(rel); + self.documents.open(&path).unwrap(); + let selections = selections + .iter() + .map(|&(a, b, c, d)| ide_core::Range::new(ide_core::Pos::new(a, b), ide_core::Pos::new(c, d))) + .collect(); + EditorContext { path, language: "text".into(), selections } + } + + fn text(&self, rel: &str) -> String { + self.documents.text_of(&self.root.join(rel)).unwrap() + } + + /// The user types at the start of the document. + fn type_text(&self, rel: &str, text: &str) { + let doc = self.documents.by_path(&self.root.join(rel)).unwrap(); + let edit = ide_core::TextEdit { range: ide_core::Range::default(), new_text: text.into() }; + self.documents.apply_edits(doc, &[edit]).unwrap(); + } +} + +impl PluginWorkspace for TestWorkspace { + fn projects(&self) -> Vec { + vec![self.root.clone()] + } + fn buffer_text(&self, path: &Path) -> Option { + self.documents.text_of(path) + } + fn document(&self, path: &Path) -> Option<(u64, String)> { + let snap = self.documents.snapshot(self.documents.by_path(path)?).ok()?; + Some((u64::from(snap.version), snap.text)) + } + fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result { + let doc = self.documents.by_path(path).ok_or_else(|| HostError::NotFound("not open".into()))?; + match self.documents.apply_edits_at(doc, version as u32, edits) { + Ok(Some(v)) => Ok(u64::from(v)), + Ok(None) => Err(HostError::Stale), + Err(e) => Err(HostError::Failed(e.message)), + } + } + fn trusted(&self, _root: &Path) -> bool { + self.trusted + } + fn project_of(&self, _window: &str) -> Option { + Some(self.root.clone()) + } + fn publish_diagnostics( + &self, + source: &str, + path: &Path, + items: Vec, + ) -> Result<(), HostError> { + if !path.starts_with(&self.root) { + return Err(HostError::Invalid("not in the project".into())); + } + self.diagnostics.lock().insert((source.to_owned(), path.to_path_buf()), items); + Ok(()) + } + fn plugin_settings(&self, plugin: &str, _root: Option<&Path>) -> serde_json::Map { + self.settings.lock().get(plugin).cloned().unwrap_or_default() + } +} + +fn repo() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap() +} + +fn sample(name: &str) -> PathBuf { + repo().join("plugins").join(name) +} + +fn theme_template() -> PathBuf { + repo().join("templates/theme-plugin") +} + +fn misbehave_pkg() -> PathBuf { + repo().join("crates/ide-plugin/tests/fixtures/misbehave") +} + +/// A theme package with a light and a dark theme, plus licence files, in `dir`. +fn write_theme_pair(dir: &Path) { + std::fs::create_dir_all(dir.join("t")).unwrap(); + std::fs::write( + dir.join("plugin.toml"), + "id = \"theme-pair\"\nname = \"Pair\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.themes]]\npath = \"t/day.json\"\n[[contributes.themes]]\npath = \"t/night.json\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("t/day.json"), + r##"{"id":"day","name":"Day","kind":"light","tokens":{"--bg-frame":"#eeeeee"}}"##, + ) + .unwrap(); + std::fs::write( + dir.join("t/night.json"), + r##"{"id":"night","name":"Night","kind":"dark","tokens":{"--bg-frame":"#111111"}}"##, + ) + .unwrap(); + std::fs::write(dir.join("LICENSE"), "MIT License\n").unwrap(); + std::fs::write(dir.join("NOTICE"), "Palette notice\n").unwrap(); +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap().flatten() { + let (src, dst) = (entry.path(), to.join(entry.file_name())); + if entry.file_name() == "target" { + continue; + } + if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() } + } +} + +/// A copy of `hello` whose manifest is rewritten by `f`. +fn hello_variant(dir: &Path, f: impl Fn(&str) -> String) -> PathBuf { + let pkg = dir.join("hello"); + copy_dir(&sample("hello"), &pkg); + let manifest = pkg.join("plugin.toml"); + let text = std::fs::read_to_string(&manifest).unwrap(); + std::fs::write(&manifest, f(&text)).unwrap(); + pkg +} + +/// `hello` requiring `required`, activated by `insert-date` only. +fn hello_02(text: &str, required: &str) -> String { + text.replace("activation = [\"on-command:*\", \"on-view:greetings\"]", "activation = [\"on-command:insert-date\"]") + .replace("required = [\"editor.read\", \"editor.write\"]", &format!("required = [{required}]")) +} + +/// `hello` with a 0.1 manifest (`openidea-api`, `permissions`, no activation). +fn hello_01(text: &str) -> String { + text.replace("engines.openidea = \"^1.0\"", "openidea-api = \"0.1\"") + .replace("activation = [\"on-command:*\", \"on-view:greetings\"]\n", "") + .replace( + "[capabilities]\nrequired = [\"editor.read\", \"editor.write\"]", + "permissions = [\"project-read\", \"editor-read\", \"editor-write\"]", + ) +} + +struct Fixture { + host: PluginHost, + workspace: Arc, + events: Events, + _tmp: tempfile::TempDir, + plugins_dir: PathBuf, + project: PathBuf, +} + +fn fixture(limits: Limits) -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + let project = tmp.path().join("project"); + std::fs::create_dir_all(project.join("src")).unwrap(); + std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap(); + std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap(); + let project = project.canonicalize().unwrap(); + let events: Events = Arc::default(); + let sink = { + let events = events.clone(); + move |client: Option<&str>, e: PluginEvent| events.lock().push((client.map(str::to_owned), e)) + }; + let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), limits).unwrap(); + let workspace = TestWorkspace::new(&project); + host.set_workspace(Some(workspace.clone())); + host.load_all(); + host.startup_finished(); + Fixture { host, workspace, events, _tmp: tmp, plugins_dir, project } +} + +impl Fixture { + fn inv(&self) -> Invocation { + Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), editor: None } + } + + fn install(&self, dir: &Path) -> PluginInfo { + self.host.install_from_dir(dir, &Consent::all()).unwrap() + } + + /// The installed copy of a plugin (its active version's directory). + fn installed(&self, id: &str) -> PathBuf { + self.host.info_of(id).unwrap().path.0 + } + + fn run(&self, plugin: &str, cmd: &str, arg: Option<&str>) -> Result, String> { + self.host.run_command(plugin, cmd, arg, self.inv()).map_err(|e| e.message) + } + + /// Runs a command with `editor` focused. + fn run_in(&self, editor: &EditorContext, plugin: &str, cmd: &str) -> Result, String> { + let inv = Invocation { editor: Some(editor.clone()), ..self.inv() }; + self.host.run_command(plugin, cmd, None, inv).map_err(|e| e.message) + } + + fn status(&self, id: &str) -> PluginStatus { + self.host.info_of(id).unwrap().status + } + + fn take_events(&self) -> Vec<(Option, PluginEvent)> { + std::mem::take(&mut *self.events.lock()) + } + + /// Titles of the `commands` UI contributions of `plugin`. + fn command_titles(&self, plugin: &str) -> Vec { + self.host + .ui_items(COMMANDS) + .into_iter() + .filter(|c| c.plugin_id == plugin) + .map(|c| c.decl["title"].as_str().unwrap().to_owned()) + .collect() + } + + fn theme_kinds(&self, plugin: &str) -> Vec<(String, ThemeKind)> { + self.host + .ui_items("themes") + .into_iter() + .filter(|c| c.plugin_id == plugin) + .map(|c| { + let t: ThemeDef = serde_json::from_value(c.decl).unwrap(); + (t.id, t.kind) + }) + .collect() + } +} + +#[test] +fn hello_plugin_commands() { + let f = fixture(Limits::default()); + let info = f.install(&sample("hello")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + assert!(f.installed("hello").join("plugin.toml").is_file()); + assert!(f.installed("hello").join("dist/plugin.wasm").is_file()); + assert_eq!(f.command_titles("hello"), ["Hello: Insert Date", "Hello: Word Count of Active File"]); + + // Insert Date: one versioned edit replacing every selection (one undo step). + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 0), (1, 0, 1, 4)]); + let date = f.run_in(&editor, "hello", "insert-date").unwrap().unwrap(); + assert!(date.len() == 10 && date.as_bytes()[4] == b'-' && date.starts_with("20"), "{date}"); + assert_eq!(f.workspace.text("src/notes.txt"), format!("{date}one two three\n{date} five\n")); + assert_eq!(f.status("hello"), PluginStatus::Active, "activated by its command"); + + // Word Count reads the document, unsaved changes included. + let summary = f.run_in(&editor, "hello", "word-count").unwrap().unwrap(); + assert!(summary.starts_with("5 words, 2 lines"), "{summary}"); + assert!(f.take_events().iter().any(|(client, e)| client.as_deref() == Some("main") + && matches!(e, + PluginEvent::Notification { title, level: NotificationLevel::Info, .. } if title == "Word count: notes.txt"))); + + // Without an editor it fails cleanly; guest errors don't disable the plugin. + assert!(f.run("hello", "word-count", None).unwrap_err().contains("no file is open")); + assert!(f.run_in(&editor, "hello", "nope").unwrap_err().contains("unknown command")); + assert_eq!(f.status("hello"), PluginStatus::Active); + let stats = f.host.info_of("hello").unwrap().stats; + assert_eq!(stats.activations, 1); + assert!(stats.calls >= 4 && stats.errors >= 2, "{stats:?}"); +} + +/// Plan P5: edits name the document version they were computed for. +#[test] +fn edits_are_refused_when_the_document_changed() { + use ide_plugin::{HostApi, Runtime, RuntimeSetup}; + let f = fixture(Limits::default()); + f.install(&sample("hello")); + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 3)]); + let doc = f.workspace.document(&editor.path).unwrap(); + f.workspace.type_text("src/notes.txt", "typed "); + let edit = ide_core::TextEdit { + range: ide_core::Range::new(ide_core::Pos::new(0, 0), ide_core::Pos::new(0, 3)), + new_text: "ONE".into(), + }; + assert_eq!(f.workspace.apply_edits(&editor.path, doc.0, std::slice::from_ref(&edit)), Err(HostError::Stale)); + assert_eq!(f.workspace.text("src/notes.txt"), "typed one two three\nfour five\n"); + let now = f.workspace.document(&editor.path).unwrap().0; + assert_eq!(f.workspace.apply_edits(&editor.path, now, &[edit]), Ok(now + 1)); + assert_eq!(f.workspace.text("src/notes.txt"), "ONEed one two three\nfour five\n"); + let _ = ( + std::any::type_name::(), + std::any::type_name::(), + std::any::type_name::<&dyn HostApi>(), + ); +} + +#[test] +fn read_file_is_confined_to_the_project() { + let f = fixture(Limits::default()); + f.install(&misbehave_pkg()); + assert_eq!(f.run("misbehave", "read", Some("src/notes.txt")).unwrap().unwrap(), "one two three\nfour five\n"); + let abs = f.project.join("src/notes.txt"); + assert!(f.run("misbehave", "read", Some(abs.to_str().unwrap())).is_ok()); + let err = f.run("misbehave", "read", Some("../secret.txt")).unwrap_err(); + assert!(err.contains("outside the project root"), "{err}"); + let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err(); + assert!(err.contains("outside the project root"), "{err}"); + assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err()); + // Without a project there is nothing to read. + let inv = Invocation { client: None, workspace_root: None, editor: None }; + let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message; + assert!(err.contains("no project is open"), "{err}"); + assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy()); + assert_eq!(f.status("misbehave"), PluginStatus::Active); +} + +/// Plan P5: a missing capability is an error the plugin can handle, not a trap. +#[test] +fn ungranted_capabilities_are_denied_not_trapped() { + let f = fixture(Limits::default()); + f.install(&misbehave_pkg()); + let editor = f.workspace.open("src/notes.txt", &[]); + let err = f.run_in(&editor, "misbehave", "insert").unwrap_err(); + assert!(err.contains("Denied") && err.contains("editor.write"), "{err}"); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.crashes), (PluginStatus::Active, 0), "the instance lives on"); + assert_eq!(f.workspace.text("src/notes.txt"), "one two three\nfour five\n"); +} + +/// Plan P4: a trap drops the instance, which restarts on next use after a +/// backoff that doubles; only consecutive crashes stop the plugin. +#[test] +fn crashes_restart_with_backoff_until_the_circuit_opens() { + let backoff = Duration::from_millis(150); + let f = fixture(Limits { restart_backoff: backoff, max_failures: 3, ..Limits::default() }); + f.install(&sample("hello")); + f.install(&misbehave_pkg()); + f.take_events(); + + let err = f.run("misbehave", "panic", None).unwrap_err(); + assert!(err.contains("crashed"), "{err}"); + assert!(err.contains("boom"), "stderr panic message is surfaced: {err}"); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!(info.status, PluginStatus::Inactive); + assert!(info.error.as_deref().is_some_and(|e| e.contains("boom"))); + assert!(!info.contributions.is_empty(), "contributions stay while it can restart"); + let events = f.take_events(); + assert!(events.iter().any(|(client, e)| client.as_deref() == Some("main") + && matches!(e, PluginEvent::Notification { level: NotificationLevel::Warning, title, .. } if title.contains("crashed")))); + + // Backing off, then a fresh instance; a call that returns resets the count. + assert!(f.run("misbehave", "echo", Some("x")).unwrap_err().contains("restarts in")); + std::thread::sleep(backoff + Duration::from_millis(50)); + assert_eq!(f.run("misbehave", "echo", Some("back")).unwrap().as_deref(), Some("back")); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.activations, info.error), (PluginStatus::Active, 2, None)); + + // Three in a row: stopped for the session, with growing waits between. + f.take_events(); + let mut waits = Vec::new(); + for _ in 0..3 { + let started = Instant::now(); + let err = loop { + match f.run("misbehave", "panic", None) { + Err(e) if e.contains("restarts in") => std::thread::sleep(Duration::from_millis(20)), + other => break other.unwrap_err(), + } + }; + assert!(err.contains("crashed"), "{err}"); + waits.push(started.elapsed()); + } + assert!(waits[2] >= backoff * 2 && waits[2] > waits[1], "{waits:?}"); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!(info.status, PluginStatus::Failed); + assert!(info.error.as_deref().is_some_and(|e| e.contains("3 times in a row")), "{:?}", info.error); + assert!(info.enabled, "failure is per session, not persisted"); + assert!(info.contributions.is_empty(), "a failed plugin's contributions are withdrawn"); + let events = f.take_events(); + assert!(events.iter().any(|(_, e)| matches!(e, + PluginEvent::Notification { level: NotificationLevel::Error, title, .. } if title.contains("was disabled")))); + assert_eq!( + events + .iter() + .filter(|(_, e)| matches!(e, PluginEvent::Notification { level: NotificationLevel::Warning, .. })) + .count(), + 1, + "one crash notice per streak" + ); + assert!(f.run("misbehave", "echo", Some("x")).unwrap_err().contains("not running")); + // Other plugins are unaffected. + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 0)]); + assert!(f.run_in(&editor, "hello", "insert-date").is_ok()); + + // Toggling restarts it with a fresh instance. + f.host.set_enabled("misbehave", true).unwrap(); + assert_eq!(f.run("misbehave", "echo", Some("again")).unwrap().as_deref(), Some("again")); + assert_eq!(f.status("misbehave"), PluginStatus::Active); +} + +#[test] +fn fuel_exhaustion_traps() { + let f = + fixture(Limits { fuel: 50_000_000, timeout: Duration::from_secs(30), max_failures: 1, ..Limits::default() }); + f.install(&misbehave_pkg()); + let err = f.run("misbehave", "spin", None).unwrap_err(); + assert!(err.contains("CPU budget"), "{err}"); + assert_eq!(f.status("misbehave"), PluginStatus::Failed, "max_failures 1: the first crash stops it"); +} + +#[test] +fn wall_clock_timeout_traps() { + let f = fixture(Limits { fuel: u64::MAX, timeout: Duration::from_millis(200), ..Limits::default() }); + f.install(&misbehave_pkg()); + let started = std::time::Instant::now(); + let err = f.run("misbehave", "spin", None).unwrap_err(); + assert!(err.contains("timed out after 200ms"), "{err}"); + assert!(started.elapsed() < Duration::from_secs(5)); + assert_eq!(f.host.info_of("misbehave").unwrap().stats.crashes, 1); +} + +#[test] +fn memory_limit_traps() { + let f = fixture(Limits { memory_bytes: 32 << 20, ..Limits::default() }); + f.install(&misbehave_pkg()); + let err = f.run("misbehave", "alloc", None).unwrap_err(); + assert!(err.contains("memory"), "{err}"); + assert_eq!(f.host.info_of("misbehave").unwrap().stats.crashes, 1); +} + +/// Plan P4: a running call can be interrupted from another thread; the +/// instance is rebuilt on next use without counting as a crash. +#[test] +fn running_calls_can_be_cancelled() { + let f = Arc::new(fixture(Limits { fuel: u64::MAX, timeout: Duration::from_secs(30), ..Limits::default() })); + f.install(&misbehave_pkg()); + let spinning = { + let f = f.clone(); + std::thread::spawn(move || f.run("misbehave", "spin", None)) + }; + std::thread::sleep(Duration::from_millis(100)); + let started = Instant::now(); + f.host.cancel("misbehave"); + let err = spinning.join().unwrap().unwrap_err(); + assert!(err.contains("was cancelled"), "{err}"); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x"), "no backoff"); + let stats = f.host.info_of("misbehave").unwrap().stats; + assert_eq!((stats.crashes, stats.activations), (0, 2)); +} + +/// `misbehave` contributing a language, so its `syntax-provider` serves it. +fn misbehave_with_language(dir: &Path) -> PathBuf { + let pkg = dir.join("misbehave"); + copy_dir(&misbehave_pkg(), &pkg); + std::fs::write( + pkg.join("plugin.toml"), + "id = \"misbehave\"\nname = \"Misbehave\"\nversion = \"0.0.1\"\nengines.openidea = \"^1.0\"\n\ + wasm = \"dist/plugin.wasm\"\nactivation = [\"on-language:mis\"]\n\ + [[contributes.languages]]\npath = \"mis.toml\"\n", + ) + .unwrap(); + std::fs::write(pkg.join("mis.toml"), "id = \"mis\"\nname = \"Mis\"\nextensions = [\"mis\"]\n").unwrap(); + pkg +} + +/// Plan P4: edit-path calls stop waiting at the soft deadline (the result is +/// dropped, the instance kept) and are interrupted at the hard one. +#[test] +fn edit_path_calls_have_soft_and_hard_deadlines() { + use ide_core::LanguageId; + let tmp = tempfile::tempdir().unwrap(); + let limits = Limits { + edit_soft: Duration::from_millis(50), + edit_hard: Duration::from_millis(400), + fuel: u64::MAX, + restart_backoff: Duration::ZERO, + ..Limits::default() + }; + let host = + Arc::new(PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()); + host.load_all(); + host.startup_finished(); + host.install_from_dir(&misbehave_with_language(tmp.path()), &Consent::all()).unwrap(); + let provider = host.syntax_provider(); + let mis = LanguageId::new("mis"); + + let tokens = provider.highlights(&mis, "fast").unwrap(); + assert_eq!((tokens[0].start, tokens[0].end, tokens[0].kind.as_str()), (0, 4, "keyword")); + + // Slower than the soft deadline: dropped, but the instance lives on. + let started = Instant::now(); + assert!(provider.highlights(&mis, "sleep:150").is_none()); + assert!(started.elapsed() < Duration::from_millis(120), "{:?}", started.elapsed()); + std::thread::sleep(Duration::from_millis(150)); + assert!(provider.highlights(&mis, "fast").is_some()); + let info = host.info_of("misbehave").unwrap(); + assert_eq!((info.stats.slow_calls, info.stats.crashes, info.stats.activations), (1, 0, 1)); + + // Past the hard deadline: interrupted; the next call gets a new instance. + assert!(provider.highlights(&mis, "spin").is_none()); + std::thread::sleep(Duration::from_millis(450)); + host.tick(); + let info = host.info_of("misbehave").unwrap(); + assert_eq!(info.stats.crashes, 1, "{:?}", info.error); + assert!(info.error.as_deref().is_some_and(|e| e.contains("timed out after 400ms")), "{:?}", info.error); + assert!(provider.highlights(&mis, "fast").is_some()); + assert_eq!(host.info_of("misbehave").unwrap().stats.activations, 2); +} + +/// Plan P4: idle instances are reclaimed; the plugin activates again on its +/// next use, and `on-schedule` activations don't keep it alive. +#[test] +fn idle_instances_are_reclaimed_and_schedules_reactivate() { + let tmp = tempfile::tempdir().unwrap(); + let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() }); + f.install(&misbehave_pkg()); + assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x")); + assert_eq!(f.host.instances(), 1); + f.host.tick(); + assert_eq!(f.host.instances(), 1, "not idle yet"); + std::thread::sleep(Duration::from_millis(150)); + f.host.tick(); + assert_eq!(f.host.instances(), 0); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1)); + assert_eq!(f.command_titles("misbehave").len(), 9, "runtime contributions stay"); + assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y")); + assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2); + + let pkg = hello_variant(tmp.path(), |t| { + hello_02(t, "\"editor.read\"") + .replace("activation = [\"on-command:insert-date\"]", "activation = [\"on-schedule:60\"]") + }); + f.install(&pkg); + assert_eq!(f.status("hello"), PluginStatus::Inactive); + let now = Instant::now(); + f.host.tick_at(now); + f.host.tick_at(now + Duration::from_secs(59)); + assert_eq!(f.status("hello"), PluginStatus::Inactive, "not due yet"); + f.host.tick_at(now + Duration::from_secs(61)); + assert!(f.host.wait_events(Duration::from_secs(10))); + assert_eq!(f.status("hello"), PluginStatus::Active, "due"); + std::thread::sleep(Duration::from_millis(150)); + f.host.tick(); + assert_eq!(f.status("hello"), PluginStatus::Inactive, "reclaimed again"); +} + +#[test] +fn runtime_language_and_theme_registration() { + let f = fixture(Limits::default()); + f.install(&misbehave_pkg()); + let lang = "id = \"zz\"\nname = \"ZZ\"\nextensions = [\"zz\"]"; + f.run("misbehave", "language", Some(lang)).unwrap(); + assert!(f.run("misbehave", "language", Some("not toml [")).unwrap_err().contains("invalid language")); + assert_eq!(f.host.languages().iter().map(|l| l.id.to_string()).collect::>(), ["zz"]); + assert_eq!(f.host.languages()[0].extensions, ["zz"]); + + let theme = r##"{"id":"t1","name":"T1","kind":"light","tokens":{"--bg-app":"#fff"}}"##; + f.run("misbehave", "theme", Some(theme)).unwrap(); + assert!( + f.run("misbehave", "theme", Some(r#"{"id":"t2","name":"x","kind":"dark","tokens":{"--a":"url(x)"}}"#)).is_err() + ); + assert_eq!(f.theme_kinds("misbehave"), [("t1".to_owned(), ThemeKind::Light)]); + let active = f.host.set_theme(Some(("misbehave", "t1"))).unwrap().unwrap(); + assert_eq!(active.theme.tokens["--bg-app"], "#fff"); + assert!(f.host.set_theme(Some(("misbehave", "nope"))).is_err()); +} + +#[test] +fn contribution_only_plugins() { + let f = fixture(Limits::default()); + let theme = f.install(&theme_template()); + assert_eq!(theme.status, PluginStatus::Static); + assert!(!theme.has_code); + assert_eq!(f.theme_kinds("theme-harbor-night")[0].0, "harbor-night"); + let lang = f.install(&sample("language-toy")); + assert_eq!(lang.status, PluginStatus::Static); + assert_eq!(f.host.languages()[0].id.as_str(), "toy"); + assert_eq!(f.host.languages()[0].block_comment, Some(("(*".into(), "*)".into()))); + assert!(f.run("language-toy", "x", None).unwrap_err().contains("no code")); + + f.take_events(); + let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap(); + assert_eq!(active.theme.tokens["--bg-app"], "#14171c"); + assert_eq!(active.theme.tokens["--syn-string"], "#9ccc7a"); + assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ThemeChanged { theme: Some(_) }))); + assert_eq!(f.host.list(None).active_theme, Some(active)); + + // Disabling the plugin withdraws its theme and language. + f.host.set_enabled("theme-harbor-night", false).unwrap(); + assert!(f.host.active_theme().is_none()); + assert!(f.take_events().iter().any(|(_, e)| *e == PluginEvent::ThemeChanged { theme: None })); + f.host.set_enabled("language-toy", false).unwrap(); + assert!(f.host.languages().is_empty()); + assert_eq!(f.status("language-toy"), PluginStatus::Disabled); +} + +#[test] +fn theme_template_installs_with_its_editor_scheme() { + let f = fixture(Limits::default()); + let info = f.install(&theme_template()); + assert_eq!(info.status, PluginStatus::Static); + assert_eq!(f.theme_kinds("theme-harbor-night"), [("harbor-night".to_owned(), ThemeKind::Dark)]); + // The scheme file is copied with the package and merged over the UI tokens. + assert!(f.installed("theme-harbor-night").join("themes/harbor-night.scheme.json").is_file()); + let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap(); + let tokens = &active.theme.tokens; + assert!( + tokens.contains_key("--bg-app") && tokens.contains_key("--syn-keyword") && tokens.contains_key("--ansi-red") + ); + assert!(tokens.len() > 150); +} + +#[test] +fn theme_packages_install_with_their_licence() { + let f = fixture(Limits::default()); + let dir = tempfile::tempdir().unwrap(); + write_theme_pair(dir.path()); + let info = f.install(dir.path()); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + let installed = f.installed("theme-pair"); + assert!(std::fs::read_to_string(installed.join("LICENSE")).unwrap().contains("MIT License")); + assert!(installed.join("NOTICE").is_file()); + for (theme, kind) in [("day", ThemeKind::Light), ("night", ThemeKind::Dark)] { + let active = f.host.set_theme(Some(("theme-pair", theme))).unwrap().unwrap(); + assert_eq!(active.theme.kind, kind); + } +} + +#[test] +fn theme_frame_follows_the_selection() { + let f = fixture(Limits::default()); + let dir = tempfile::tempdir().unwrap(); + write_theme_pair(dir.path()); + f.install(dir.path()); + // Nothing to attach it to without a selected theme. + f.host.set_theme_frame(Some("#111111")).unwrap(); + assert_eq!(f.host.startup_frame(), None); + f.host.set_theme(Some(("theme-pair", "night"))).unwrap(); + assert!(f.host.set_theme_frame(Some("hsl(220, 13%, 15%)")).is_err()); + f.host.set_theme_frame(Some("#111111")).unwrap(); + assert_eq!(f.host.startup_frame().as_deref(), Some("#111111")); + f.host.set_enabled("theme-pair", false).unwrap(); + assert_eq!(f.host.startup_frame(), None); + f.host.set_enabled("theme-pair", true).unwrap(); + assert_eq!(f.host.startup_frame().as_deref(), Some("#111111")); + // A new selection drops the old theme's colour until the UI reports the new one. + f.host.set_theme(Some(("theme-pair", "day"))).unwrap(); + assert_eq!(f.host.startup_frame(), None); + f.host.set_theme_frame(Some("#eeeeee")).unwrap(); + f.host.set_theme(None).unwrap(); + assert_eq!(f.host.startup_frame(), None); +} + +#[test] +fn theme_with_broken_editor_scheme_is_rejected() { + let f = fixture(Limits::default()); + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("t")).unwrap(); + std::fs::write( + dir.path().join("plugin.toml"), + "id = \"bad-scheme\"\nname = \"B\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.themes]]\npath = \"t/a.json\"\neditor-scheme = \"t/a.scheme.json\"\n", + ) + .unwrap(); + std::fs::write(dir.path().join("t/a.json"), r##"{"id":"a","name":"A","kind":"light","tokens":{}}"##).unwrap(); + std::fs::write(dir.path().join("t/a.scheme.json"), "{\n \"tokens\": { \"--caret\": \"url(x)\" }\n}").unwrap(); + // Validation happens before anything is copied. + let err = f.host.install_from_dir(dir.path(), &Consent::all()).unwrap_err().message; + assert!(err.contains("a.scheme.json"), "{err}"); + assert!(!f.plugins_dir.join("bad-scheme").exists()); + // Already on disk (copied by hand): listed as failed. + copy_dir(dir.path(), &f.plugins_dir.join("bad-scheme")); + f.host.load_all(); + let info = f.host.info_of("bad-scheme").unwrap(); + assert_eq!(info.status, PluginStatus::Failed); + assert!(info.error.unwrap_or_default().contains("a.scheme.json")); +} + +#[test] +fn state_persists_across_restarts() { + let f = fixture(Limits::default()); + f.install(&sample("hello")); + f.install(&theme_template()); + f.host.set_enabled("hello", false).unwrap(); + f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap(); + f.host.set_theme_frame(Some("#1f252e")).unwrap(); + + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + // Known before the plugins are loaded: new windows open in the theme's frame colour. + assert_eq!(again.startup_frame().as_deref(), Some("#1f252e")); + assert!(!again.wait_initial_load(Duration::from_millis(1))); + again.load_all(); + assert!(again.wait_initial_load(Duration::ZERO)); + let list = again.list(None); + let hello = list.plugins.iter().find(|p| p.id == "hello").unwrap(); + assert!(!hello.enabled); + assert_eq!(hello.status, PluginStatus::Disabled); + assert_eq!(list.active_theme.unwrap().theme.id, "harbor-night"); + assert!( + again + .run_command("hello", "insert-date", None, Invocation::default()) + .unwrap_err() + .message + .contains("disabled") + ); +} + +#[test] +fn startup_loads_without_instantiating_wasm() { + let f = fixture(Limits::default()); + f.install(&sample("hello")); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + let hello = again.info_of("hello").unwrap(); + assert_eq!(hello.status, PluginStatus::Inactive, "no instance before start-up finished"); + assert_eq!(hello.stats.activations, 0); + assert_eq!(again.ui_items(COMMANDS).len(), 2, "commands are shown without running code"); + again.startup_finished(); + assert_eq!(again.instances(), 0, "hello waits for its commands"); + + // `on-startup-finished` plugins are activated then, not before. + let tmp = tempfile::tempdir().unwrap(); + let eager = hello_variant(tmp.path(), |t| t.replace("on-command:*", "on-startup-finished")); + f.install(&eager); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.instances(), 0); + again.startup_finished(); + assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Active); +} + +#[test] +fn lazy_activation_on_command() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = hello_variant(tmp.path(), |t| hello_02(t, "\"project.read\", \"editor.read\", \"editor.write\"")); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + assert_eq!(info.activation, ["on-command:insert-date"]); + assert_eq!(f.command_titles("hello").len(), 2); + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 0)]); + assert!(f.run_in(&editor, "hello", "insert-date").unwrap().is_some()); + assert_eq!(f.status("hello"), PluginStatus::Active); +} + +#[test] +fn project_plugins_need_consent_even_in_trusted_projects() { + let f = fixture(Limits::default()); + f.host.set_project_policy(Arc::new(|_| true)); + let dev = f.project.join(DEV_PLUGINS_DIR).join("toy"); + copy_dir(&sample("language-toy"), &dev); + f.host.sync_project(&f.project); + let mine = f.host.list(Some(&f.project)); + assert_eq!(mine.plugins.len(), 1); + assert_eq!(mine.plugins[0].source, PluginSource::Dev); + assert_eq!(mine.plugins[0].status, PluginStatus::NeedsConsent); + assert!(f.host.languages().is_empty(), "nothing takes effect before consent"); + assert!(f.host.list(None).plugins.is_empty(), "project plugins only show for their project"); + + f.host.grant("language-toy", &Consent::default()).unwrap(); + assert_eq!(f.status("language-toy"), PluginStatus::Static); + assert_eq!(f.host.languages().len(), 1); + assert!(f.host.uninstall("language-toy").is_err()); + assert!(f.host.install_from_dir(&sample("language-toy"), &Consent::all()).is_err(), "id clash with a dev plugin"); + + // Edit + reload picks up changes; the grant is by path, so no new prompt. + let manifest = dev.join("plugin.toml"); + let src = std::fs::read_to_string(&manifest).unwrap().replace("Toy Language", "Toy Language (dev)"); + std::fs::write(&manifest, src).unwrap(); + let info = f.host.reload("language-toy").unwrap(); + assert_eq!(info.name, "Toy Language (dev)"); + assert_eq!(info.status, PluginStatus::Static); + + std::fs::remove_dir_all(&dev).unwrap(); + f.host.sync_project(&f.project); + assert!(f.host.list(Some(&f.project)).plugins.is_empty()); +} + +#[test] +fn untrusted_or_unconfirmed_project_plugins_never_run() { + let f = fixture(Limits::default()); + // Its commands are registered by `activate` only. + let dev = f.project.join(DEV_PLUGINS_DIR).join("misbehave"); + copy_dir(&misbehave_pkg(), &dev); + let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let policy = trusted.clone(); + f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire))); + f.host.sync_project(&f.project); + assert!(f.host.list(Some(&f.project)).plugins.is_empty()); + assert!(f.host.info_of("misbehave").is_err()); + + // Trusted but not confirmed: listed, nothing runs, activation registers nothing. + trusted.store(true, std::sync::atomic::Ordering::Release); + f.host.sync_project(&f.project); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!(info.status, PluginStatus::NeedsConsent); + assert!(f.command_titles("misbehave").is_empty(), "unconfirmed plugin registered its activation witness"); + assert!(f.run("misbehave", "echo", None).unwrap_err().contains("permission")); + + // Positive control: after consent, activation itself registers the commands. + let info = f.host.grant("misbehave", &Consent::default()).unwrap(); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x")); + assert!(f.command_titles("misbehave").iter().any(|t| t == "echo")); + + trusted.store(false, std::sync::atomic::Ordering::Release); + assert!(f.host.reload("misbehave").is_err()); + assert!(f.host.set_enabled("misbehave", true).is_err()); + assert!(f.run("misbehave", "echo", None).is_err()); + f.host.sync_project(&f.project); + assert!(f.host.list(Some(&f.project)).plugins.is_empty()); +} + +#[test] +fn new_capabilities_require_consent_again() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let v1 = hello_variant(&tmp.path().join("v1"), |t| hello_02(t, "\"project.read\"")); + let info = f.install(&v1); + assert_eq!(info.status, PluginStatus::Inactive); + assert!(info.capabilities.iter().all(|c| c.granted)); + + // Same capabilities, new version: accepted silently. + let v2 = hello_variant(&tmp.path().join("v2"), |t| { + hello_02(t, "\"project.read\"").replace("version = \"0.3.0\"", "version = \"0.3.1\"") + }); + f.host.load_all(); + std::fs::remove_dir_all(f.plugins_dir.join("hello")).unwrap(); + copy_dir(&v2, &f.plugins_dir.join("hello")); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Inactive); + drop(again); + + // A new required capability: waits for consent, nothing contributed. + let v3 = hello_variant(&tmp.path().join("v3"), |t| { + hello_02(t, "\"project.read\", \"editor.write\"").replace("version = \"0.3.0\"", "version = \"0.4.0\"") + }); + std::fs::remove_dir_all(f.plugins_dir.join("hello")).unwrap(); + copy_dir(&v3, &f.plugins_dir.join("hello")); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + let info = again.info_of("hello").unwrap(); + assert_eq!(info.status, PluginStatus::NeedsConsent); + let write = info.capabilities.iter().find(|c| c.id == "editor.write").unwrap(); + assert!(!write.granted); + assert!(again.ui_items(COMMANDS).is_empty()); + again.grant("hello", &Consent::default()).unwrap(); + assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Inactive); + + // Revoking a required capability stops it again. + let info = again.set_capability("hello", "editor.write", false).unwrap(); + assert_eq!(info.status, PluginStatus::NeedsConsent); +} + +#[test] +fn migrated_plugins_get_low_risk_capabilities_and_ask_on_first_use() { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + // Installed by a version without the grant ledger (a 0.1 manifest). + copy_dir(&sample("hello"), &plugins_dir.join("hello")); + let manifest = plugins_dir.join("hello/plugin.toml"); + std::fs::write(&manifest, hello_01(&std::fs::read_to_string(&manifest).unwrap())).unwrap(); + let events: Events = Arc::default(); + let sink = { + let events = events.clone(); + move |client: Option<&str>, e: PluginEvent| events.lock().push((client.map(str::to_owned), e)) + }; + let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), Limits::default()).unwrap(); + host.load_all(); + let info = host.info_of("hello").unwrap(); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let granted: Vec<_> = info.capabilities.iter().filter(|c| c.granted).map(|c| c.id.as_str()).collect(); + assert_eq!(granted, ["project.read", "editor.read"]); + let inv = Invocation { client: Some("w".into()), ..Invocation::default() }; + let err = host.run_command("hello", "insert-date", None, inv.clone()).unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied); + assert!(events.lock().iter().any(|(c, e)| c.as_deref() == Some("w") + && matches!(e, PluginEvent::ConsentRequested { capabilities, .. } if capabilities[0].id == "editor.write"))); + host.grant("hello", &Consent::default()).unwrap(); + // Runs now (and fails only for want of an editor). + let err = host.run_command("hello", "insert-date", None, inv).unwrap_err(); + assert!(err.message.contains("no file is open"), "{err:?}"); +} + +#[cfg(unix)] +#[test] +fn replaced_binaries_are_refused() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + std::fs::write(bin.join("toylsp"), "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(bin.join("toylsp"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + + let pkg = tmp.path().join("pkg"); + copy_dir(&sample("language-toy"), &pkg); + let lang = pkg.join("languages/toy.toml"); + let text = std::fs::read_to_string(&lang).unwrap(); + std::fs::write(&lang, format!("{text}\n[[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n")).unwrap(); + + let preview = f.host.preview_install(&pkg).unwrap(); + let lsp = preview.capabilities.iter().find(|c| c.id == "process:toylsp").unwrap(); + assert_eq!(lsp.risk, ide_extension::Risk::High); + assert!(lsp.optional, "declaration-derived capabilities can be refused"); + + // Refused: the language still works. + f.host.install_from_dir(&pkg, &Consent::default()).unwrap(); + assert_eq!(f.host.languages().len(), 1); + assert!(f.host.program_for("language-toy", "toylsp").is_err()); + + // Accepted: pinned to the resolved file. + f.host.set_capability("language-toy", "process:toylsp", true).unwrap(); + let resolved = f.host.program_for("language-toy", "toylsp").unwrap(); + assert!(resolved.ends_with("toylsp")); + std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho replaced\n").unwrap(); + let err = f.host.program_for("language-toy", "toylsp").unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied); + assert!(err.message.contains("confirm"), "{}", err.message); +} + +/// Plan P3: a declaration-only language plugin reaches detection and LSP of +/// every project; its server starts only while `process:` is granted, from +/// the approved file. +#[cfg(unix)] +#[test] +fn plugin_languages_join_the_registry_and_start_only_granted_servers() { + use ide_core::LanguageId; + use ide_language::LanguageRegistry; + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + std::fs::write(bin.join("toylsp"), "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(bin.join("toylsp"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + + let registry = LanguageRegistry::load(None).unwrap(); + registry.attach_plugins(f.host.language_store().clone()); + let project = registry.fork(); + let toy = LanguageId::new("toy"); + assert_eq!(project.detect(Path::new("/p/a.toy"), None), LanguageId::plain_text()); + + let pkg = tmp.path().join("pkg"); + copy_dir(&sample("language-toy"), &pkg); + let lang = pkg.join("languages/toy.toml"); + let text = std::fs::read_to_string(&lang).unwrap(); + std::fs::write(&lang, format!("{text}\n[[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n")).unwrap(); + + // Refused: detected and listed, no server. + f.host.install_from_dir(&pkg, &Consent::default()).unwrap(); + assert_eq!(project.detect(Path::new("/p/a.toy"), None), toy); + assert!(registry.all().iter().any(|l| l.id == toy), "the UI language list includes it"); + assert!(project.get(&toy).unwrap().language_servers.is_empty()); + + // Granted: the server is pinned to the approved file. + f.host.set_capability("language-toy", "process:toylsp", true).unwrap(); + let server = project.get(&toy).unwrap().language_servers[0].clone(); + let pin = server.pinned.expect("pinned"); + assert_eq!(pin.verify().unwrap(), dunce::canonicalize(bin.join("toylsp")).unwrap()); + // Replaced afterwards: refused when it would start. + std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho replaced\n").unwrap(); + assert!(pin.verify().unwrap_err().contains("changed since it was approved")); + + // Revoked, then disabled. + f.host.set_capability("language-toy", "process:toylsp", false).unwrap(); + assert!(project.get(&toy).unwrap().language_servers.is_empty()); + f.host.set_enabled("language-toy", false).unwrap(); + assert_eq!(project.detect(Path::new("/p/a.toy"), None), LanguageId::plain_text()); +} + +/// Plan P3: a plugin provides the syntax of a language the IDE has no grammar +/// for (the official Solidity plugin, built with the SDK), activated on first use. +#[test] +fn plugin_syntax_for_languages_without_a_grammar() { + use ide_core::{DocId, LanguageId, SymbolKind}; + use ide_language::{Syntax, SyntaxCache, SyntaxHighlightKind}; + let tmp = tempfile::tempdir().unwrap(); + let host = Arc::new( + PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(), + ); + host.load_all(); + host.startup_finished(); + let info = host.install_from_dir(&repo().join("plugins/official/solidity"), &Consent::default()).unwrap(); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + assert!(info.capabilities.iter().all(|c| c.optional && !c.granted), "language servers were refused"); + + let provider = host.syntax_provider(); + let sol = LanguageId::new("solidity"); + let text = "// c\ncontract A {\n uint256 public x;\n function f() public {}\n}\n"; + assert!(provider.highlights(&LanguageId::new("rust"), text).is_none(), "only its own languages"); + let tokens = provider.highlights(&sol, text).unwrap(); + let kind_at = + |s: &str| tokens.iter().find(|t| &text[t.start as usize..t.end as usize] == s).map(|t| t.kind.as_str()); + assert_eq!(kind_at("contract"), Some("keyword")); + assert_eq!(kind_at("uint256"), Some("type")); + assert_eq!(kind_at("// c"), Some("comment")); + assert_eq!(host.info_of("openidea.solidity").unwrap().status, PluginStatus::Active, "activated on first use"); + + // As the editor gets it: through a project's syntax cache. + ide_language::set_external_syntax(Some(provider.clone())); + let cache = SyntaxCache::new(Arc::new(Syntax::new())); + let outline = cache.outline(DocId(1), &sol, 1, text); + assert_eq!(outline.len(), 1); + assert_eq!((outline[0].name.as_str(), outline[0].kind), ("A", SymbolKind::Class)); + let members: Vec<_> = outline[0].children.iter().map(|c| (c.name.as_str(), c.kind)).collect(); + assert_eq!(members, [("x", SymbolKind::Field), ("f", SymbolKind::Method)]); + assert_eq!(outline[0].selection_range.start.line, 1); + let folds = cache.folding(DocId(1), &sol, 1, text); + assert!(folds.iter().any(|f| f.start_line == 1 && f.end_line == 4)); + let highlights = cache.highlights(DocId(1), &sol, 1, text); + let keyword = SyntaxHighlightKind::ALL.iter().position(|k| *k == SyntaxHighlightKind::Keyword).unwrap() as u32; + assert!(highlights.data.chunks(3).any(|t| t == [5, 13, keyword]), "UTF-16 offsets of `contract`"); + ide_language::set_external_syntax(None); +} + +/// Plan P3: plugins add run configurations to `ide-run`'s detection; the +/// command runs only while granted, from the approved file. +#[cfg(unix)] +#[test] +fn run_detectors_offer_granted_commands_in_marked_projects() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + std::fs::write(bin.join("forge"), "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(bin.join("forge"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + + let pkg = tmp.path().join("pkg"); + std::fs::create_dir(&pkg).unwrap(); + std::fs::write( + pkg.join("plugin.toml"), + "id = \"acme.forge\"\nname = \"Forge\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.runDetectors]]\nid = \"test\"\nname = \"forge test\"\nfiles = [\"foundry.toml\"]\n\ + command = \"forge\"\nargs = [\"test\"]\ntest = true\n", + ) + .unwrap(); + let preview = f.host.preview_install(&pkg).unwrap(); + assert!(preview.capabilities.iter().any(|c| c.id == "process:forge" && c.optional)); + f.host.install_from_dir(&pkg, &Consent::default()).unwrap(); + + let detection = f.host.run_detection(); + let project = tmp.path().join("chain"); + std::fs::create_dir(&project).unwrap(); + assert!(detection.detect(&project).is_empty(), "no marker"); + std::fs::write(project.join("foundry.toml"), "").unwrap(); + assert!(detection.detect(&project).is_empty(), "not granted"); + + f.host.set_capability("acme.forge", "process:forge", true).unwrap(); + let configs = detection.detect(&project); + assert_eq!(configs.len(), 1); + let config = &configs[0]; + assert_eq!( + (config.id.as_str(), config.name.as_str(), config.is_test), + ("plugin:acme.forge:test", "forge test", true) + ); + assert_eq!(detection.launch(config).unwrap(), dunce::canonicalize(bin.join("forge")).unwrap()); + + std::fs::write(bin.join("forge"), "#!/bin/sh\necho swapped\n").unwrap(); + assert!(detection.launch(config).unwrap_err().contains("changed since it was approved")); + f.host.set_enabled("acme.forge", false).unwrap(); + assert!(detection.detect(&project).is_empty()); + assert!(detection.launch(config).unwrap_err().contains("no longer enabled")); +} + +/// Plan P4: compiled components are cached across runs (keyed by module and +/// engine), so a cold activation only deserializes. +#[test] +fn precompiled_components_make_cold_activation_cheap() { + let f = fixture(Limits::default()); + f.install(&sample("hello")); + let cache = f.plugins_dir.join(".cache/components"); + let cached = || std::fs::read_dir(&cache).map(|d| d.count()).unwrap_or(0); + assert_eq!(cached(), 1, "install validation compiled it once, into the cache"); + + // Another run of the IDE: activation deserializes. + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + let started = Instant::now(); + let _ = again.run_command("hello", "word-count", None, Invocation::default()); + let cold = started.elapsed(); + let stats = again.info_of("hello").unwrap().stats; + assert_eq!(stats.activations, 1); + eprintln!("cold activation with a precompiled component: {:.1} ms (wall {cold:?})", stats.activation_ms); + assert!(stats.activation_ms < 250.0, "{} ms", stats.activation_ms); + + // A stale entry (other engine or uninstalled plugin) is pruned by the warm-up. + std::fs::write(cache.join("0000-stale.cwasm"), b"junk").unwrap(); + again.warm_component_cache(); + assert_eq!(cached(), 1); + // A corrupt entry is discarded and recompiled. + for e in std::fs::read_dir(&cache).unwrap().flatten() { + std::fs::write(e.path(), b"junk").unwrap(); + } + again.reload("hello").unwrap(); + assert!( + again.run_command("hello", "word-count", None, Invocation::default()).is_err(), + "no active file, but it runs" + ); + assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Active); +} + +#[test] +fn install_validation_atomic_replace_and_uninstall() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + + // Missing component file. + let pkg = tmp.path().join("broken"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("plugin.toml"), + "id = \"b\"\nname = \"B\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\nwasm = \"x.wasm\"", + ) + .unwrap(); + let install = |p: &Path| f.host.install_from_dir(p, &Consent::all()); + assert!(install(&pkg).unwrap_err().message.contains("missing")); + // Not a component. + std::fs::write(pkg.join("x.wasm"), b"\0asm garbage").unwrap(); + assert!(install(&pkg).unwrap_err().message.contains("not a valid plugin component")); + // Bad manifest. + std::fs::write(pkg.join("plugin.toml"), "id = \"B!\"").unwrap(); + assert!(install(&pkg).is_err()); + assert!(f.host.list(None).plugins.is_empty(), "failed installs leave nothing behind"); + + // A broken package already in the plugins dir shows up as failed. + std::fs::create_dir_all(f.plugins_dir.join("junk")).unwrap(); + std::fs::write(f.plugins_dir.join("junk/plugin.toml"), "garbage = ").unwrap(); + f.host.load_all(); + let junk = &f.host.list(None).plugins[0]; + assert_eq!(junk.status, PluginStatus::Failed); + assert!(junk.error.is_some()); + + // Reinstall replaces; a failing reinstall keeps the installed version. + f.install(&sample("hello")); + f.install(&sample("hello")); + assert_eq!(f.status("hello"), PluginStatus::Inactive); + let bad = hello_variant(tmp.path(), |t| t.replace("version = \"0.3.0\"", "version = \"0.9.0\"")); + std::fs::write(bad.join("dist/plugin.wasm"), b"not wasm").unwrap(); + assert!(install(&bad).is_err()); + assert_eq!(f.host.info_of("hello").unwrap().version, "0.3.0"); + assert!(f.installed("hello").join("dist/plugin.wasm").is_file()); + assert!(f.installed("hello").join(".package-hash").is_file()); + let leftovers: Vec<_> = std::fs::read_dir(&f.plugins_dir) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with(".staging")) + .collect(); + assert!(leftovers.is_empty()); + + f.host.uninstall("hello").unwrap(); + assert!(!f.plugins_dir.join("hello").exists()); + assert!(f.host.info_of("hello").is_err()); +} + +#[test] +fn unknown_contribution_points_are_skipped_with_a_warning() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("future"); + copy_dir(&sample("language-toy"), &pkg); + let manifest = pkg.join("plugin.toml"); + let text = std::fs::read_to_string(&manifest).unwrap(); + std::fs::write(&manifest, format!("{text}\n[[contributes.holograms]]\nshape = \"cube\"\n")).unwrap(); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static); + assert!(info.warnings.iter().any(|w| w.contains("holograms"))); + assert_eq!(f.host.languages().len(), 1); +} + +#[test] +fn icon_themes_are_a_contribution_type_like_any_other() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("icons"); + std::fs::create_dir_all(pkg.join("svg")).unwrap(); + std::fs::write(pkg.join("svg/rust.svg"), r#""#).unwrap(); + std::fs::write(pkg.join("svg/file.svg"), r#""#).unwrap(); + std::fs::write( + pkg.join("theme.json"), + r#"{"iconDefinitions":{"rust":"svg/rust.svg","file":"svg/file.svg"},"file":"file","fileExtensions":{"rs":"rust"}}"#, + ) + .unwrap(); + std::fs::write( + pkg.join("plugin.toml"), + "id = \"acme.icons\"\nname = \"Icons\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.iconThemes]]\nid = \"acme\"\nlabel = \"Acme Icons\"\npath = \"theme.json\"\n", + ) + .unwrap(); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + assert!(f.installed("acme.icons").join("svg/rust.svg").is_file(), "referenced files are installed"); + let items = f.host.ui_items("iconThemes"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].decl["label"], "Acme Icons"); + let svgs = f.host.assets("acme.icons", &icon_theme_svg_prefix("acme")).unwrap(); + assert_eq!(svgs.len(), 2); + let map = f.host.assets("acme.icons", &icon_theme_map_asset("acme")).unwrap(); + assert!(map[0].1.contains("\"rs\":\"rust\"")); + f.host.set_enabled("acme.icons", false).unwrap(); + assert!(f.host.ui_items("iconThemes").is_empty()); + assert!(f.host.assets("acme.icons", "").is_err()); +} + +#[test] +fn official_material_icons_install() { + let f = fixture(Limits::default()); + let info = f.install(&repo().join("plugins/official/material-icons")); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + let theme: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(repo().join("plugins/official/material-icons/icons/theme.json")).unwrap(), + ) + .unwrap(); + let defined = theme["iconDefinitions"].as_object().unwrap().len(); + let svgs = f.host.assets("openidea.material-icons", &icon_theme_svg_prefix("material")).unwrap(); + assert_eq!(svgs.len(), defined, "every icon survives the sanitiser"); + let map = &f.host.assets("openidea.material-icons", &icon_theme_map_asset("material")).unwrap()[0].1; + assert!(map.contains("\"src\":\"folder-src\""), "folder names are part of the mapping"); + assert!( + !f.installed("openidea.material-icons").join("generate.py").exists(), + "only referenced files are installed" + ); +} + +/// Plan P1: an icon theme with thousands of SVGs (sanitising, package size, start-up). +#[test] +fn thousands_of_icons_install_and_reload_within_budget() { + const ICONS: usize = 3000; + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("many"); + std::fs::create_dir_all(pkg.join("svg")).unwrap(); + let mut defs = serde_json::Map::new(); + let mut exts = serde_json::Map::new(); + for i in 0..ICONS { + let svg = format!( + r##"{}"##, + i * 2654435 % 0xff_ffff, + i % 100 + ); + std::fs::write(pkg.join(format!("svg/i{i}.svg")), svg).unwrap(); + defs.insert(format!("i{i}"), format!("svg/i{i}.svg").into()); + exts.insert(format!("x{i}"), format!("i{i}").into()); + } + let theme = serde_json::json!({ "iconDefinitions": defs, "file": "i0", "fileExtensions": exts }); + std::fs::write(pkg.join("theme.json"), theme.to_string()).unwrap(); + std::fs::write( + pkg.join("plugin.toml"), + "id = \"acme.many\"\nname = \"Many\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.iconThemes]]\nid = \"many\"\nlabel = \"Many\"\npath = \"theme.json\"\n", + ) + .unwrap(); + + let started = Instant::now(); + let info = f.install(&pkg); + let install = started.elapsed(); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + assert_eq!(f.host.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap().len(), ICONS); + + let started = Instant::now(); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + let reload = started.elapsed(); + assert_eq!(again.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap().len(), ICONS); + eprintln!("{ICONS} icons: install {install:?}, load at start-up {reload:?} (from the contribution index)"); + // Generous for debug builds; release is several times faster. + assert!(install < Duration::from_secs(5), "install took {install:?}"); + assert!(reload < Duration::from_secs(3), "start-up load took {reload:?}"); + + // Start-up reads the validated declaration from the index, not the SVGs: + // a tampered icon file is not even looked at... + let index = f.plugins_dir.join(".cache/contributions"); + assert_eq!(std::fs::read_dir(&index).unwrap().count(), 1); + std::fs::write(f.installed("acme.many").join("svg/i1.svg"), "").unwrap(); + let third = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + third.load_all(); + let icon = third.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap(); + assert!(icon.iter().all(|(_, svg)| !svg.contains("script"))); + // ...and without the index everything is validated again. + std::fs::remove_dir_all(&index).unwrap(); + let fourth = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + fourth.load_all(); + assert_eq!(fourth.info_of("acme.many").unwrap().status, PluginStatus::Failed, "the tampered icon is rejected"); +} + +#[test] +fn linked_development_plugins() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("toy"); + copy_dir(&sample("language-toy"), &pkg); + let info = f.host.link(&pkg).unwrap(); + assert_eq!(info.source, PluginSource::Linked); + assert_eq!(info.status, PluginStatus::NeedsConsent); + f.host.grant("language-toy", &Consent::default()).unwrap(); + assert_eq!(f.host.languages().len(), 1); + // Survives a restart (links.json) and keeps its grant (by path). + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.info_of("language-toy").unwrap().status, PluginStatus::Static); + again.unlink("language-toy").unwrap(); + assert!(again.info_of("language-toy").is_err()); +} + +#[test] +fn catalog_lists_sample_packages() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let broken = tmp.path().join("broken"); + std::fs::create_dir_all(&broken).unwrap(); + std::fs::write( + broken.join("plugin.toml"), + "id = \"x\"\nname = \"X\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\nwasm = \"dist/plugin.wasm\"\n", + ) + .unwrap(); + std::fs::create_dir_all(tmp.path().join("junk")).unwrap(); + std::fs::write(tmp.path().join("junk/plugin.toml"), "garbage = ").unwrap(); + + let catalog = f.host.catalog(&[repo().join("plugins"), repo().join("templates"), tmp.path().to_path_buf()]); + let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect(); + for id in ["hello", "theme-harbor-night", "language-toy", "x"] { + assert!(ids.contains(&id), "{id} missing from {ids:?}"); + } + let hello = catalog.iter().find(|c| c.id == "hello").unwrap(); + assert!(hello.has_code && hello.problem.is_none() && hello.change_notes.is_some()); + assert_eq!(hello.contributions.iter().find(|c| c.point == "commands").unwrap().count, 2); + let theme = catalog.iter().find(|c| c.id == "theme-harbor-night").unwrap(); + assert!(!theme.has_code); + assert_eq!(theme.contributions[0].count, 1); + let x = catalog.iter().find(|c| c.id == "x").unwrap(); + assert!(x.problem.as_deref().unwrap().contains("plugin.wasm")); +} + +fn official(name: &str) -> PathBuf { + repo().join("plugins/official").join(name) +} + +/// Plan P5, String Tools: 26 commands show before the code loads; one command +/// edits every selection as one versioned change. +#[test] +fn official_string_tools() { + let f = fixture(Limits::default()); + let info = f.install(&official("string-tools")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let titles = f.command_titles("openidea.string-tools"); + assert_eq!(titles.len(), 26); + assert!(titles.contains(&"String Tools: To snake_case".to_owned())); + let categories: std::collections::BTreeSet = + f.host.ui_items(COMMANDS).iter().filter_map(|c| c.decl["category"].as_str().map(str::to_owned)).collect(); + assert_eq!(categories.len(), 4, "{categories:?}"); + + let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 3), (1, 5, 1, 5)]); + let before = f.workspace.document(&editor.path).unwrap().0; + f.run_in(&editor, "openidea.string-tools", "upper-case").unwrap(); + assert_eq!(f.workspace.text("src/notes.txt"), "ONE two three\nfour FIVE\n"); + assert_eq!(f.workspace.document(&editor.path).unwrap().0, before + 1, "both selections in one change"); +} + +/// Plan P5, Key Promoter: action events are batched; an idle plugin is +/// woken once per batch, and its counts persist in storage. +#[test] +fn official_key_promoter() { + let f = fixture(Limits::default()); + f.install(&official("key-promoter")); + assert!(f.host.event_kinds().contains(&EventKind::ActionPerformed), "subscribed by its manifest"); + let menu = |action: &str| { + WorkbenchEvent::ActionPerformed(ActionPerformed { + action: action.into(), + title: "Duplicate Line".into(), + source: ActionSource::Menu, + shortcut: Some("Ctrl+D".into()), + }) + }; + let typed = WorkbenchEvent::ActionPerformed(ActionPerformed { + action: "editor.copy".into(), + title: "Copy".into(), + source: ActionSource::Keyboard, + shortcut: Some("Ctrl+C".into()), + }); + f.take_events(); + let now = Instant::now(); + f.host.post_events(Some("main"), vec![menu("code.duplicate"), typed]); + f.host.flush_events_at(now); + assert_eq!(f.host.instances(), 0, "an idle plugin collects a batch first"); + f.host.post_events(Some("main"), vec![menu("code.duplicate"), menu("code.duplicate")]); + f.host.flush_events_at(now + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + let info = f.host.info_of("openidea.key-promoter").unwrap(); + assert_eq!((info.status, info.stats.activations, info.stats.calls), (PluginStatus::Active, 1, 1), "one wake-up"); + let tips: Vec<_> = f + .take_events() + .into_iter() + .filter_map(|(client, e)| match e { + PluginEvent::Notification { title, body, .. } => Some((client, title, body)), + _ => None, + }) + .collect(); + assert_eq!(tips.len(), 1, "{tips:?}"); + assert_eq!(tips[0].0.as_deref(), Some("main"), "shown in the window the actions came from"); + assert!(tips[0].1.contains("Ctrl+D") && tips[0].2.contains("3 times"), "{tips:?}"); + let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.key-promoter/storage.json")).unwrap(); + assert!(stored.contains("\"count:code.duplicate\":\"3\""), "{stored}"); + + // Uninstalling removes its data. + f.host.uninstall("openidea.key-promoter").unwrap(); + assert!(!f.plugins_dir.join(".data/openidea.key-promoter").exists()); + assert!(f.host.event_kinds().is_empty()); +} + +fn git(dir: &Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"]) + .args(args) + .current_dir(dir) + .output() + .expect("git"); + assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); +} + +/// Plan P5 acceptance: Git Toolbox's scheduled fetch still runs after the +/// instance was reclaimed while idle, and the status bar follows. +#[test] +fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() { + let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() }); + let tmp = tempfile::tempdir().unwrap(); + // The project tracks a remote that gets one more commit. + let remote = tmp.path().join("remote.git"); + std::fs::create_dir(&remote).unwrap(); + git(&remote, &["init", "--bare", "-b", "main"]); + git(&f.project, &["init", "-b", "main"]); + git(&f.project, &["add", "."]); + git(&f.project, &["commit", "-m", "one"]); + git(&f.project, &["remote", "add", "origin", remote.to_str().unwrap()]); + git(&f.project, &["push", "-u", "origin", "main"]); + let other = tmp.path().join("other"); + git(tmp.path(), &["clone", remote.to_str().unwrap(), other.to_str().unwrap()]); + std::fs::write(other.join("b.txt"), "b").unwrap(); + git(&other, &["add", "."]); + git(&other, &["commit", "-m", "two"]); + git(&other, &["push"]); + + f.install(&official("git-toolbox")); + // Focusing an editor wakes it to show the branch (nothing fetched yet). + let now = Instant::now(); + f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]); + f.host.flush_events_at(now + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + let item = |host: &PluginHost| { + host.status_items().into_iter().find(|(p, id, _)| p == "openidea.git-toolbox" && id == "branch") + }; + assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main".into())); + + // Idle: reclaimed. + std::thread::sleep(Duration::from_millis(100)); + f.host.tick(); + assert_eq!(f.host.instances(), 0); + assert_eq!(f.status("openidea.git-toolbox"), PluginStatus::Inactive); + assert!(item(&f.host).is_some(), "its status item stays while it sleeps"); + + // Five minutes later: activated by its schedule, fetches, shows ↓1. + f.take_events(); + f.host.tick_at(Instant::now() + Duration::from_secs(301)); + assert!(f.host.wait_events(Duration::from_secs(30))); + let info = f.host.info_of("openidea.git-toolbox").unwrap(); + assert_eq!(info.stats.activations, 2, "{:?}", info.error); + assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main ↓1".into())); + let events = f.take_events(); + assert!( + events.iter().any( + |(_, e)| matches!(e, PluginEvent::TaskStarted { title, cancellable: true, .. } if title == "Fetching") + ) + ); + assert!(events.iter().any(|(_, e)| matches!(e, PluginEvent::TaskEnded { .. }))); + let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.git-toolbox/storage.json")).unwrap(); + assert!(stored.contains("last-fetch:"), "{stored}"); +} + +/// Restricted Mode: git does not run in an untrusted project (no fetch). +#[test] +fn git_toolbox_does_not_fetch_untrusted_projects() { + let f = fixture(Limits::default()); + git(&f.project, &["init", "-b", "main"]); + f.install(&official("git-toolbox")); + let untrusted = Arc::new(TestWorkspace { + root: f.project.clone(), + documents: f.workspace.documents.clone(), + trusted: false, + diagnostics: Mutex::default(), + settings: Mutex::default(), + }); + f.host.set_workspace(Some(untrusted)); + let err = f.run("openidea.git-toolbox", "fetch", None).unwrap_err(); + assert!(err.contains("Restricted Mode"), "{err}"); +} + +/// A tiny HTTP server: each request is answered after `delay(n)` (n counts +/// requests) with `body`. Returns its port and the request bodies it got. +fn http_server( + body: &'static str, + delay: impl Fn(usize) -> Duration + Send + Sync + 'static, +) -> (u16, Arc>>) { + use std::io::{BufRead, BufReader, Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen: Arc>> = Arc::default(); + let (log, delay) = (seen.clone(), Arc::new(delay)); + std::thread::spawn(move || { + for (n, stream) in listener.incoming().flatten().enumerate() { + let (log, delay) = (log.clone(), delay.clone()); + std::thread::spawn(move || { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut len = 0; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut req = vec![0; len]; + let _ = reader.read_exact(&mut req); + log.lock().push(String::from_utf8_lossy(&req).into_owned()); + std::thread::sleep(delay(n)); + let mut stream = stream; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + }); + } + }); + (port, seen) +} + +/// Plan P6, AI Completion: typing on cancels the previous request (the fetch +/// returns `cancelled`, the instance stays); the plugin's own work stays +/// within the edit-path budget. +#[test] +fn official_ai_completion_cancels_superseded_requests() { + let f = fixture(Limits::default()); + f.install(&official("ai-completion")); + let (port, seen) = http_server(r#"{"choices":[{"text":"a + b\n"}]}"#, |n| { + if n == 0 { Duration::from_secs(3) } else { Duration::ZERO } + }); + f.run("openidea.ai-completion", "set-endpoint", Some(&format!("http://localhost:{port}"))).unwrap(); + let doc = ide_plugin::ProviderDocument { + path: f.project.join("src/a.rs"), + language: "rust".into(), + version: 1, + text: "fn add(a: i32, b: i32) -> i32 {\n \n}\n".into(), + }; + let at = ide_core::Pos::new(1, 4); + let host = Arc::new(f); + // A slow request, superseded 200 ms later by the next keystroke's. + let first = ide_core::CancellationToken::new(); + let slow = { + let (host, doc, first) = (host.clone(), doc.clone(), first.clone()); + std::thread::spawn(move || { + let started = Instant::now(); + let r = host.host.provide_inline_completion(&doc, at, &first); + (r, started.elapsed()) + }) + }; + std::thread::sleep(Duration::from_millis(200)); + first.cancel(); + let started = Instant::now(); + let second = host.host.provide_inline_completion(&doc, at, &ide_core::CancellationToken::new()); + let took = started.elapsed(); + let (old, old_took) = slow.join().unwrap(); + assert!(old.is_none(), "the superseded request yields nothing"); + assert!(old_took < Duration::from_secs(1), "cancelled, not waited out: {old_took:?}"); + assert_eq!(second.map(|(_, s)| s.insert_text).as_deref(), Some("a + b")); + eprintln!("inline completion after cancelling the previous request: {took:?}"); + let info = host.host.info_of("openidea.ai-completion").unwrap(); + assert_eq!((info.stats.crashes, info.stats.activations), (0, 1), "cooperative: same instance"); + assert!(seen.lock()[1].contains("\"prompt\":\"fn add(a: i32, b: i32) -> i32 {\\n \""), "{:?}", seen.lock()); + + // The plugin's own share of a call (local server, no delay): edit-path budget. + let mut times = Vec::new(); + for _ in 0..10 { + let started = Instant::now(); + assert!(host.host.provide_inline_completion(&doc, at, &ide_core::CancellationToken::new()).is_some()); + times.push(started.elapsed()); + } + times.sort(); + eprintln!("inline completion round trip p95 (local server): {:?}", times[9]); + assert!(times[9] < Duration::from_millis(500), "{times:?}"); +} + +/// Plan P6, Checkstyle: a save runs the approved `checkstyle` and publishes +/// diagnostics; quick fixes come through the code-action provider; saving +/// again cancels the check still running. +#[cfg(unix)] +#[test] +fn official_checkstyle_checks_on_save() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let bin = f.plugins_dir.parent().unwrap().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + // Reports a tab on line 2; files containing SLOW take 5 s. + std::fs::write( + bin.join("checkstyle"), + "#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q SLOW \"$file\" && sleep 5\n\ + echo ''\n\ + echo ''\n\ + echo ''\nexit 1\n", + ) + .unwrap(); + std::fs::set_permissions(bin.join("checkstyle"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + let info = f.install(&official("checkstyle")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + + let java = f.project.join("src/A.java"); + std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap(); + let saved = |f: &Fixture| { + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + }; + saved(&f); + assert!(f.host.wait_events(Duration::from_secs(20))); + let diagnostics = + |f: &Fixture| f.workspace.diagnostics.lock().get(&("openidea.checkstyle".into(), java.clone())).cloned(); + let d = diagnostics(&f).expect("published"); + assert_eq!((d.len(), d[0].code.as_deref(), d[0].range.start.line), (1, Some("FileTabCharacter"), 1)); + + // The quick fix, through the provider (as the editor asks for it). + let doc = ide_plugin::ProviderDocument { + path: java.clone(), + language: "java".into(), + version: 1, + text: std::fs::read_to_string(&java).unwrap(), + }; + let range = ide_core::Range::new(ide_core::Pos::new(1, 0), ide_core::Pos::new(1, 0)); + let actions = f.host.provide_code_actions(&doc, range); + assert_eq!(actions.len(), 1, "{actions:?}"); + assert_eq!(actions[0].1.title, "Checkstyle: Replace tabs with spaces"); + assert_eq!(actions[0].1.edits[0].1[0].new_text, " int x;"); + + // A slow check is cancelled by the next save. + f.workspace.diagnostics.lock().clear(); + std::fs::write(&java, "class A { // SLOW\n\tint x;\n}\n").unwrap(); + let started = Instant::now(); + saved(&f); + std::thread::sleep(Duration::from_millis(300)); + std::fs::write(&java, "class A {\n\tint y;\n}\n").unwrap(); + saved(&f); + assert!(f.host.wait_events(Duration::from_secs(20))); + assert!(started.elapsed() < Duration::from_secs(4), "the slow run was killed: {:?}", started.elapsed()); + assert_eq!(diagnostics(&f).map(|d| d.len()), Some(1), "published by the second check"); + let info = f.host.info_of("openidea.checkstyle").unwrap(); + assert_eq!(info.stats.crashes, 0); +} + +/// Plan P6, MyBatis Navigator: interface ↔ XML through the providers, on a +/// real project tree (`find-files`), within the edit-path budget. +#[test] +fn official_mybatis_navigates_between_interface_and_xml() { + let f = fixture(Limits::default()); + f.install(&official("mybatis")); + let java_path = f.project.join("src/main/java/com/acme/UserMapper.java"); + let xml_path = f.project.join("src/main/resources/mapper/UserMapper.xml"); + std::fs::create_dir_all(java_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(xml_path.parent().unwrap()).unwrap(); + let java = "package com.acme;\n\npublic interface UserMapper {\n User findById(long id);\n}\n"; + let xml = "\n \n\n"; + std::fs::write(&java_path, java).unwrap(); + std::fs::write(&xml_path, xml).unwrap(); + // Noise the index has to skip. + for i in 0..200 { + std::fs::write(f.project.join(format!("src/f{i}.xml")), "").unwrap(); + } + let doc = |path: &Path, language: &str, text: &str| ide_plugin::ProviderDocument { + path: path.to_path_buf(), + language: language.into(), + version: 1, + text: text.into(), + }; + let jdoc = doc(&java_path, "java", java); + let markers = f.host.provide_line_markers(&jdoc); + assert_eq!(markers.len(), 1, "{markers:?}"); + let target = markers[0].1.target.clone().unwrap(); + assert_eq!((target.path, target.range.start), (xml_path.clone(), ide_core::Pos::new(1, 14))); + + let xdoc = doc(&xml_path, "xml", xml); + let to_java = f.host.provide_definition(&xdoc, ide_core::Pos::new(1, 16)); + assert_eq!( + to_java.iter().map(|l| (l.path.clone(), l.range.start)).collect::>(), + [(java_path.clone(), ide_core::Pos::new(3, 9))] + ); + assert!( + f.host.provide_definition(&doc(&f.project.join("src/a.rs"), "rust", ""), ide_core::Pos::new(0, 0)).is_empty(), + "not its language" + ); + + // Warm (index cached): well inside the edit-path budget. + let mut times = Vec::new(); + for _ in 0..20 { + let started = Instant::now(); + assert_eq!(f.host.provide_line_markers(&jdoc).len(), 1); + times.push(started.elapsed()); + } + times.sort(); + eprintln!("mybatis line markers p95: {:?}", times[18]); + assert!(times[18] < Duration::from_millis(50), "{times:?}"); + assert_eq!(f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls, 0); +} + +/// Plan P7, CSV Editor: a custom editor renders the document as a table (the +/// rows asked for), and edits the document itself — versioned, so a text edit +/// made meanwhile is never overwritten. +#[test] +fn official_csv_editor_edits_the_shared_document() { + let f = fixture(Limits::default()); + let info = f.install(&official("csv-editor")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + std::fs::write(f.project.join("people.csv"), "name,city\nAda,London\nBob,Oslo\n").unwrap(); + let editor = f.workspace.open("people.csv", &[]); + let inv = Invocation { editor: Some(editor.clone()), ..f.inv() }; + let viewport = Some(ide_plugin::Viewport { first_row: 1, rows: 10 }); + let view = f.host.render_view("openidea.csv-editor", "table", viewport, inv.clone()).unwrap(); + let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") }; + assert_eq!((t.columns.clone(), t.total_rows, t.first_row), (vec!["name".to_owned(), "city".into()], 2, 1)); + assert_eq!(t.rows, [vec!["Bob".to_owned(), "Oslo".into()]]); + assert_eq!(view.status.as_deref(), Some("2 rows × 2 columns, delimiter ','")); + + let cell = ide_plugin::ViewEvent::Cell { row: 0, column: 1, value: "Paris, FR".into() }; + f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap(); + assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n"); + + // Settings: without a header row every record is a row. + let mut values = serde_json::Map::new(); + values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false)); + f.workspace.settings.lock().insert("openidea.csv-editor".into(), values); + let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap(); + let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") }; + assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3)); + + // Undeclared views are refused before any code runs. + let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound); +} + +/// Plan P7 acceptance: the sample plugin makes a tool window with a tree view. +/// Its menus, shortcut and status bar item are declarations, listed before +/// any code runs; the tree is rendered by the code, which loads on first show. +#[test] +fn sample_plugin_tool_window_with_a_tree() { + let f = fixture(Limits::default()); + let info = f.install(&sample("hello")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + for point in ["menus", "keymaps", "statusBar", "views"] { + assert_eq!(f.host.ui_items(point).len(), 1, "{point}"); + } + let view = f.host.ui_items("views").remove(0); + assert_eq!((view.key.as_str(), view.decl["location"].as_str()), ("greetings", Some("right"))); + assert_eq!(f.status("hello"), PluginStatus::Inactive, "declarations need no code"); + + let tree = |f: &Fixture| match f.host.render_view("hello", "greetings", None, f.inv()).unwrap().body { + ide_plugin::ViewBody::Tree(items) => items, + other => panic!("{other:?}"), + }; + assert_eq!(tree(&f).iter().map(|i| i.label.as_str()).collect::>(), ["English", "Deutsch", "日本語"]); + assert_eq!(f.status("hello"), PluginStatus::Active, "activated on show (on-view)"); + f.take_events(); + f.host.view_event("hello", "greetings", ide_plugin::ViewEvent::Expand { key: "English".into() }, f.inv()).unwrap(); + let refreshed = f.take_events().into_iter().any(|(_, e)| { + matches!(e, PluginEvent::ViewChanged { ref plugin_id, ref view } if plugin_id == "hello" && view == "greetings") + }); + assert!(refreshed, "the plugin asked for a refresh"); + let items = tree(&f); + assert_eq!(items.len(), 6); + assert_eq!(items[1].parent.as_deref(), Some("English")); + f.host + .view_event("hello", "greetings", ide_plugin::ViewEvent::Activate { key: "English/Hi".into() }, f.inv()) + .unwrap(); + assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::Notification { title, .. } if title == "Hi"))); + + // Disabling withdraws every declaration. + f.host.set_enabled("hello", false).unwrap(); + for point in ["menus", "keymaps", "statusBar", "views"] { + assert!(f.host.ui_items(point).is_empty(), "{point}"); + } +} + +/// Plan P7, Kubernetes: the cluster tree through `kubectl` (pinned), logs in +/// the terminal of the invoking window. +#[test] +fn official_kubernetes_browses_the_cluster_and_opens_logs() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let bin = f.plugins_dir.parent().unwrap().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write( + bin.join("kubectl"), + "#!/bin/sh\ncase \"$*\" in\n\ + *'get namespaces'*) echo '{\"items\":[{\"metadata\":{\"name\":\"default\"}}]}' ;;\n\ + *'get pods'*) echo '{\"items\":[{\"metadata\":{\"name\":\"web-1\"},\"status\":{\"phase\":\"Running\",\"containerStatuses\":[{\"ready\":true}]}}]}' ;;\n\ + *) echo '{\"items\":[]}' ;;\nesac\n", + ) + .unwrap(); + std::fs::set_permissions(bin.join("kubectl"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + let info = f.install(&official("kubernetes")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let ev = |e: ide_plugin::ViewEvent| f.host.view_event("openidea.kubernetes", "cluster", e, f.inv()).unwrap(); + ev(ide_plugin::ViewEvent::Expand { key: "default".into() }); + ev(ide_plugin::ViewEvent::Expand { key: "default/pods".into() }); + let view = f.host.render_view("openidea.kubernetes", "cluster", None, f.inv()).unwrap(); + let ide_plugin::ViewBody::Tree(items) = &view.body else { panic!("{view:?}") }; + let pod = items.iter().find(|i| i.key == "default/pods/web-1").expect("the pod"); + assert_eq!(pod.detail.as_deref(), Some("Running 1/1")); + f.take_events(); + ev(ide_plugin::ViewEvent::Activate { key: "default/pods/web-1".into() }); + let terminal = f.take_events().into_iter().find_map(|(client, e)| match e { + PluginEvent::OpenTerminal { title, program, args, .. } => Some((client, title, program, args)), + _ => None, + }); + let (client, title, program, args) = terminal.expect("a terminal"); + assert_eq!((client.as_deref(), title.as_str()), (Some("main"), "logs: web-1")); + assert_eq!(program.0, bin.join("kubectl").canonicalize().unwrap()); + assert_eq!(args, ["logs", "-f", "--tail=200", "-n", "default", "web-1"]); +} diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs new file mode 100644 index 000000000..0b5ac11ce --- /dev/null +++ b/crates/ide-host/tests/remote_plugins.rs @@ -0,0 +1,311 @@ +//! Plan P6 remote acceptance: code plugins of a remote project run on the +//! server (docs/plugin_plan.md 3). The desktop's plugin host syncs its live +//! code plugins over the remote protocol; Checkstyle then checks the file on +//! the server's disk when it is saved, and secrets come from the desktop. + +#![cfg(unix)] + +use ide_core::IdeEvent; +use ide_host::plugins::remote_host::{ + EVENT_NOTIFICATION, PackageSyncReport, PluginEventMessage, RemotePlugins, SECRET_REQUEST, SYNC_PACKAGES, + SecretRequest, +}; +use ide_host::plugins::{Consent, PluginEvent, PluginHost, PluginStatus, WorkbenchEvent}; +use ide_host::rpc::{self, Dispatcher, EventMessage, Invoke}; +use ide_host::{Host, HostConfig}; +use ide_jsonrpc::{Handler, Peer}; +use ide_plugin::Limits; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +#[derive(Default)] +struct Client { + events: Mutex>, + plugin_events: Mutex>, + secret_requests: Mutex>, +} + +impl Handler for Client { + fn on_notification(&self, method: &str, params: Value) { + match method { + "event" => self.events.lock().push(serde_json::from_value(params).unwrap()), + EVENT_NOTIFICATION => self.plugin_events.lock().push(serde_json::from_value(params).unwrap()), + _ => {} + } + } + + fn on_request(&self, method: &str, params: Value) -> Result { + if method == SECRET_REQUEST { + let r: SecretRequest = serde_json::from_value(params).unwrap(); + let value = (r.name == "api-key").then(|| "sk-desktop".to_owned()); + self.secret_requests.lock().push(r); + return Ok(serde_json::to_value(value).unwrap()); + } + Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned())) + } +} + +async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result { + let call = Invoke { window: "main".into(), cmd: cmd.into(), args }; + peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply) +} + +async fn sync(peer: &Peer, desktop: &PluginHost, files: &[&str]) -> PackageSyncReport { + let files: serde_json::Map = files + .iter() + .map(|id| ((*id).to_owned(), serde_json::to_value(desktop.remote_package_files(id).unwrap()).unwrap())) + .collect(); + let args = json!({ "packages": desktop.remote_packages(), "files": files }); + serde_json::from_value(invoke(peer, SYNC_PACKAGES, args).await.unwrap()).unwrap() +} + +fn official(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official").join(name) +} + +/// A `checkstyle` that reports a tab on line 2 of the file it is given. +fn fake_checkstyle(bin: &Path) { + std::fs::create_dir_all(bin).unwrap(); + std::fs::write( + bin.join("checkstyle"), + "#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q '\t' \"$file\" || { echo ''; exit 0; }\n\ + echo ''\n\ + echo ''\n\ + echo ''\nexit 1\n", + ) + .unwrap(); + std::fs::set_permissions(bin.join("checkstyle"), std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +async fn until(what: &str, secs: u64, mut done: impl FnMut() -> bool) { + for _ in 0..secs * 50 { + if done() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("timed out waiting for {what}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn remote_projects_run_the_desktops_code_plugins_on_the_server() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + fake_checkstyle(&bin); + let path_var = { + let bin = bin.clone(); + Arc::new(move || Some(bin.clone().into_os_string())) + }; + + // The desktop: Checkstyle and AI Completion installed and granted. + let desktop = + PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + .unwrap(); + desktop.set_program_path(path_var.clone()); + for name in ["checkstyle", "ai-completion"] { + let info = desktop.install_from_dir(&official(name), &Consent::all()).unwrap(); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + } + + // The server, with the project on "its" disk. + // Canonical, as the UI reports paths (macOS: /var → /private/var). + let project = tmp.path().join("remote/demo"); + std::fs::create_dir_all(project.join("src")).unwrap(); + let project = std::fs::canonicalize(project).unwrap(); + let base = tmp.path().join("server"); + let peer_slot = Arc::new(OnceLock::new()); + let sink_peer = peer_slot.clone(); + let host = Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap(); + plugins.host().set_program_path(path_var); + let mut dispatcher = Dispatcher::standard(); + plugins.register(&mut dispatcher); + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let client = Arc::new(Client::default()); + let peer = Peer::connect(cr, cw, client.clone()); + let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap(); + invoke(&peer, "open_project", json!({ "path": project })).await.unwrap(); + invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap(); + + // Digests first; then the files of what the server lacks; then nothing to do. + let first = sync(&peer, &desktop, &[]).await; + assert_eq!(first.missing, ["openidea.ai-completion", "openidea.checkstyle"], "{first:?}"); + let second = sync(&peer, &desktop, &["openidea.ai-completion", "openidea.checkstyle"]).await; + assert_eq!(second.installed.len(), 2, "{second:?}"); + assert!(second.failed.is_empty(), "{second:?}"); + assert_eq!(sync(&peer, &desktop, &[]).await, PackageSyncReport::default()); + let remote = plugins.host().info_of("openidea.checkstyle").unwrap(); + assert_eq!(remote.status, PluginStatus::Inactive, "{:?}", remote.error); + assert!(remote.capabilities.iter().any(|c| c.id == "process:checkstyle" && c.granted)); + + // The remote window's UI routes the run-time commands to the server. + let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap(); + assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}"); + + // Saving a file that exists only on the server: Checkstyle reads that file. + let java = project.join("src/A.java"); + std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap(); + let saved = WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned()); + invoke(&peer, "plugin_post_events", json!({ "events": [saved] })).await.unwrap(); + let published = || { + client.events.lock().iter().find_map(|m| match &m.event { + IdeEvent::Diagnostics { path, source, items } if source == "plugin:openidea.checkstyle" => { + Some((path.0.clone(), items.clone())) + } + _ => None, + }) + }; + until("remote checkstyle diagnostics", 20, || published().is_some()).await; + let (path, items) = published().unwrap(); + assert_eq!(path, java); + assert_eq!((items.len(), items[0].range.start.line), (1, 1)); + + // Secrets stay on the desktop: the server asks for them when used. + let secrets = plugins.host().secrets("openidea.ai-completion").unwrap(); + assert_eq!(secrets, [("api-key".to_owned(), true)]); + assert_eq!(client.secret_requests.lock()[0].plugin, "openidea.ai-completion"); + + // A command of a remote plugin, with this window as its client. + invoke( + &peer, + "plugin_run_command", + json!({ "pluginId": "openidea.ai-completion", "commandId": "toggle", "arg": null, "editor": null }), + ) + .await + .unwrap(); + until("the command's notification", 5, || { + client + .plugin_events + .lock() + .iter() + .any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::Notification { .. })) + }) + .await; + + // Disabled on the desktop: gone from the server. + desktop.set_enabled("openidea.checkstyle", false).unwrap(); + let removed = sync(&peer, &desktop, &[]).await; + assert_eq!(removed.removed, ["openidea.checkstyle"]); + assert!(plugins.host().info_of("openidea.checkstyle").is_err()); + plugins.host().shutdown(); +} + +/// Plan P7 remote acceptance: CSV Editor and Kubernetes in a remote project. +/// The table edits the document on the server; the cluster is the server's +/// `kubectl`'s, and its log terminal opens in the remote window. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn remote_projects_get_plugin_views_and_custom_editors() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write( + bin.join("kubectl"), + "#!/bin/sh\ncase \"$*\" in\n\ + *'get namespaces'*) echo '{\"items\":[{\"metadata\":{\"name\":\"remote-ns\"}}]}' ;;\n\ + *) echo '{\"items\":[]}' ;;\nesac\n", + ) + .unwrap(); + std::fs::set_permissions(bin.join("kubectl"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path_var = { + let bin = bin.clone(); + Arc::new(move || Some(bin.clone().into_os_string())) + }; + let desktop = + PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + .unwrap(); + desktop.set_program_path(path_var.clone()); + for name in ["csv-editor", "kubernetes"] { + let info = desktop.install_from_dir(&official(name), &Consent::all()).unwrap(); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + } + + let project = tmp.path().join("remote/demo"); + std::fs::create_dir_all(&project).unwrap(); + let project = std::fs::canonicalize(project).unwrap(); + std::fs::write(project.join("people.csv"), "name,city\nAda,London\n").unwrap(); + let base = tmp.path().join("server"); + let peer_slot = Arc::new(OnceLock::new()); + let sink_peer = peer_slot.clone(); + let host = Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap(); + plugins.host().set_program_path(path_var); + let mut dispatcher = Dispatcher::standard(); + plugins.register(&mut dispatcher); + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let client = Arc::new(Client::default()); + let peer = Peer::connect(cr, cw, client.clone()); + let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap(); + invoke(&peer, "open_project", json!({ "path": project })).await.unwrap(); + invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap(); + let report = sync(&peer, &desktop, &["openidea.csv-editor", "openidea.kubernetes"]).await; + assert_eq!(report.installed.len(), 2, "{report:?}"); + + // CSV Editor: the document is the server's. + let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap(); + let doc = opened["id"].as_u64().expect("doc id"); + let args = |extra: Value| { + let mut a = json!({ "pluginId": "openidea.csv-editor", "view": "table", "doc": doc }); + a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + a + }; + let view = + invoke(&peer, "plugin_view_render", args(json!({ "viewport": { "firstRow": 0, "rows": 50 } }))).await.unwrap(); + assert_eq!(view["body"]["kind"], "table"); + assert_eq!(view["body"]["value"]["rows"], json!([["Ada", "London"]])); + let cell = json!({ "event": { "kind": "cell", "row": 0, "column": 1, "value": "Oslo" } }); + invoke(&peer, "plugin_view_event", args(cell)).await.unwrap(); + let snap = invoke(&peer, "doc_snapshot", json!({ "doc": doc })).await.unwrap(); + assert_eq!(snap["text"], "name,city\nAda,Oslo\n"); + + // Kubernetes: the server's kubectl; the terminal opens in this window. + let k8s = + |event: Value| json!({ "pluginId": "openidea.kubernetes", "view": "cluster", "doc": null, "event": event }); + let view = invoke( + &peer, + "plugin_view_render", + json!({ "pluginId": "openidea.kubernetes", "view": "cluster", "doc": null }), + ) + .await + .unwrap(); + assert_eq!(view["body"]["value"][0]["label"], "remote-ns", "{view}"); + invoke(&peer, "plugin_view_event", k8s(json!({ "kind": "select", "key": "remote-ns/pods/api-0" }))).await.unwrap(); + invoke(&peer, "plugin_view_event", k8s(json!({ "kind": "button", "id": "logs" }))).await.unwrap(); + until("the log terminal", 5, || { + client.plugin_events.lock().iter().any(|m| { + m.window.as_deref() == Some("main") + && matches!(&m.event, PluginEvent::OpenTerminal { program, .. } if program.0.starts_with(bin.canonicalize().unwrap())) + }) + }) + .await; + plugins.host().shutdown(); +} diff --git a/crates/ide-language/Cargo.toml b/crates/ide-language/Cargo.toml index b830b706e..4bda809a6 100644 --- a/crates/ide-language/Cargo.toml +++ b/crates/ide-language/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] ide-core.workspace = true +ide-extension.workspace = true ide-text.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index bd4fa6d59..ca544edfc 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -8,16 +8,20 @@ use ide_core::{LanguageId, Range, SymbolKind}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; mod markers; +pub mod plugin_point; mod registry; mod syntax; pub use markers::marked_root; +pub use plugin_point::PluginLanguages; pub use registry::{FileTypes, LanguageRegistry}; pub use syntax::{ - CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, - MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, - SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility, + CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, + FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, + Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, + WebReferenceKind, merge_visibility, set_external_syntax, }; /// Parsed form of `languages/.toml`. Backend-only; the UI gets [`LanguageInfo`]. @@ -138,6 +142,9 @@ pub struct LanguageServerConfig { /// Register source files with tsserver when no project configuration exists. #[serde(default)] pub tsserver_project: bool, + /// Set on plugin languages: start exactly this approved program. + #[serde(skip)] + pub pinned: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -157,6 +164,41 @@ pub struct DebuggerConfig { pub connect: Option, #[serde(default)] pub install_hint: Option, + /// Set on plugin languages: start exactly this approved program. + #[serde(skip)] + pub pinned: Option, +} + +/// The program a plugin's `process:` grant approved (docs/plugin_plan.md 4.2): +/// a plugin language's server or debug adapter starts from this exact path, +/// and only while the file still has the approved content. Set by the plugin +/// host, never read from a definition. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProgramPin { + pub path: PathBuf, + /// blake3 of the file when approved. + pub hash: String, +} + +impl ProgramPin { + /// Approves `path` as it is now. + pub fn approve(path: impl Into) -> std::io::Result { + let path = path.into(); + let hash = ide_extension::ledger::file_hash(&path)?; + Ok(Self { path, hash }) + } + + /// Checks the file right before it is started; the error tells the user what to do. + pub fn verify(&self) -> Result<&Path, String> { + match ide_extension::ledger::file_hash(&self.path) { + Ok(hash) if hash == self.hash => Ok(&self.path), + Ok(_) => Err(format!( + "{} changed since it was approved; review the plugin's permissions in Settings › Plugins", + self.path.display() + )), + Err(e) => Err(format!("{}: {e}", self.path.display())), + } + } } fn default_transport() -> String { diff --git a/crates/ide-language/src/plugin_point.rs b/crates/ide-language/src/plugin_point.rs new file mode 100644 index 000000000..1aa4cbe56 --- /dev/null +++ b/crates/ide-language/src/plugin_point.rs @@ -0,0 +1,313 @@ +//! The `languages` extension point: language definitions contributed by +//! plugins, in the same format as `languages/.toml`. +//! +//! ```toml +//! [[contributes.languages]] +//! path = "languages/solidity.toml" +//! ``` +//! +//! A definition that starts programs (`language-servers.command`, +//! `debugger.command`) needs `process:` for each: a declaration is a +//! capability too, or registering languages would bypass the sandbox. +//! Committed definitions live in [`PluginLanguages`], an overlay over the +//! built-in registry (built-in definitions win on id clashes): see +//! [`crate::LanguageRegistry::attach_plugins`]. A server or debugger whose +//! program is not granted is left out; the language itself still works. + +use crate::{LanguageConfig, ProgramPin}; +use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode}; +use parking_lot::RwLock; +use serde::Deserialize; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub const POINT_ID: &str = "languages"; + +/// Decides whether a plugin may start a program: the approved file for a +/// granted `process:` capability, or `None`. +pub type ProcessPolicy = dyn Fn(&str, &Capability) -> Option + Send + Sync; + +/// Languages of enabled plugins, in commit order. +#[derive(Default)] +pub struct PluginLanguages { + items: RwLock>, + seq: AtomicU64, + revision: AtomicU64, + policy: RwLock>>, +} + +impl PluginLanguages { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn all(&self) -> Vec { + self.items.read().iter().map(|(_, _, c)| c.clone()).collect() + } + + /// `(plugin id, definition)` pairs. + pub fn with_owners(&self) -> Vec<(String, LanguageConfig)> { + self.items.read().iter().map(|(_, p, c)| (p.clone(), c.clone())).collect() + } + + /// The plugin that contributes language `id`. + pub fn owner_of(&self, id: &ide_core::LanguageId) -> Option { + self.items.read().iter().find(|(_, _, c)| &c.id == id).map(|(_, p, _)| p.clone()) + } + + /// Changes whenever [`effective`](Self::effective) may give something else. + pub fn revision(&self) -> u64 { + self.revision.load(Ordering::Acquire) + } + + /// Who may start what. Without a policy no plugin program starts. + pub fn set_process_policy(&self, policy: Arc) { + *self.policy.write() = Some(policy); + self.invalidate(); + } + + /// Grants changed: registries recompute [`effective`](Self::effective). + pub fn invalidate(&self) { + self.revision.fetch_add(1, Ordering::Release); + } + + /// The definitions as they take effect: servers and the debugger whose + /// program is granted are pinned to the approved file, the others are + /// left out. + pub fn effective(&self) -> Vec { + let policy = self.policy.read().clone(); + let pin = |plugin: &str, program: &str| { + policy.as_ref().and_then(|p| p(plugin, &Capability::Process(program.to_owned()))) + }; + self.items + .read() + .iter() + .map(|(_, plugin, cfg)| { + let mut cfg = cfg.clone(); + cfg.language_servers.retain_mut(|s| { + s.pinned = pin(plugin, &s.command); + s.pinned.is_some() + }); + let debugger_pin = cfg.debugger.as_ref().and_then(|d| pin(plugin, &d.command)); + match (&mut cfg.debugger, debugger_pin) { + (Some(d), Some(p)) => d.pinned = Some(p), + (debugger, _) => *debugger = None, + } + cfg + }) + .collect() + } + + /// Adds a definition until the returned handle is dropped. + pub fn add(self: &Arc, plugin: String, cfg: LanguageConfig) -> Disposable { + let seq = self.seq.fetch_add(1, Ordering::Relaxed); + self.items.write().push((seq, plugin, cfg)); + self.revision.fetch_add(1, Ordering::Release); + let weak = Arc::downgrade(self); + Disposable::new(move || { + if let Some(this) = weak.upgrade() { + this.items.write().retain(|(s, _, _)| *s != seq); + this.revision.fetch_add(1, Ordering::Release); + } + }) + } +} + +pub struct LanguagesPoint { + store: Arc, +} + +impl LanguagesPoint { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + /// A `languages/.toml`-style file in the package. + path: Option, + /// The same, inline (runtime `register-language`). + definition: Option, +} + +/// Parses and checks one language definition. +pub fn parse_definition(src: &str) -> Result { + let cfg: LanguageConfig = toml::from_str(src).map_err(|e| format!("invalid language definition: {e}"))?; + validate_definition(&cfg)?; + Ok(cfg) +} + +/// Checks a definition that did not come from a package file (a remote sync). +pub fn validate_definition(cfg: &LanguageConfig) -> Result<(), String> { + let id = cfg.id.as_str(); + if id.is_empty() || cfg.name.trim().is_empty() { + return Err("language `id` and `name` must not be empty".into()); + } + if !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+')) || id.len() > 64 { + return Err(format!("invalid language id {id:?}")); + } + if let Some(first) = &cfg.first_line { + regex_lite::Regex::new(first).map_err(|e| format!("invalid first-line pattern: {e}"))?; + } + Ok(()) +} + +/// The programs a definition starts, as `process:` capabilities. +pub fn required_capabilities(cfg: &LanguageConfig) -> Vec { + let mut caps: Vec = Vec::new(); + let servers = cfg.language_servers.iter().map(|s| s.command.as_str()); + for program in servers.chain(cfg.debugger.iter().map(|d| d.command.as_str())) { + let cap = Capability::Process(program.to_owned()); + if !program.trim().is_empty() && !caps.contains(&cap) { + caps.push(cap); + } + } + caps +} + +impl ExtensionPoint for LanguagesPoint { + fn id(&self) -> &'static str { + POINT_ID + } + + fn placement(&self) -> Placement { + Placement::Workspace + } + + fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result { + let decl: Decl = decode(POINT_ID, decl)?; + let (src, file) = match (decl.path, decl.definition) { + (Some(path), None) => { + let src = pkg.read_text(&path)?; + (src, Some(path)) + } + (None, Some(def)) => (def, None), + _ => return Err("give either `path` or `definition`".into()), + }; + let cfg = parse_definition(&src).map_err(|e| match &file { + Some(f) => format!("{}: {e}", f.display()), + None => e, + })?; + let caps = required_capabilities(&cfg); + let (store, plugin, key, label) = ( + self.store.clone(), + pkg.id().to_owned(), + cfg.id.to_string(), + format!("{} (.{})", cfg.name, cfg.extensions.join(", .")), + ); + let mut prepared = Prepared::new(label, move || Ok(store.add(plugin, cfg))).key(key); + if let Some(f) = file { + prepared = prepared.file(f); + } + for cap in caps { + prepared = prepared.capability(cap); + } + Ok(prepared) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_extension::{ExtensionRegistry, Manifest, Scope}; + + #[test] + fn languages_point_registers_and_requires_process_capabilities() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir(tmp.path().join("l")).unwrap(); + std::fs::write( + tmp.path().join("l/sol.toml"), + "id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\ + [[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\nargs = [\"--lsp\"]\n", + ) + .unwrap(); + let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.languages]]\npath = \"l/sol.toml\""; + let pkg = Package::new(Manifest::parse(src).unwrap(), tmp.path().to_path_buf()); + let store = PluginLanguages::new(); + let reg = ExtensionRegistry::new(); + reg.register(Arc::new(LanguagesPoint::new(store.clone()))).unwrap(); + let set = reg.prepare(&pkg).unwrap(); + assert_eq!(set.capabilities, [Capability::Process("solc".into())]); + assert_eq!(set.files, [PathBuf::from("l/sol.toml")]); + reg.commit(set, Scope::Static).unwrap(); + assert_eq!(store.all()[0].id.as_str(), "solidity"); + reg.dispose_plugin("p"); + assert!(store.all().is_empty()); + + let bad = |def: &str| { + let mut t = toml::Table::new(); + t.insert("definition".into(), def.into()); + reg.contribute(&pkg, POINT_ID, toml::Value::Table(t)).unwrap_err() + }; + assert!(bad("not toml [").contains("invalid language")); + assert!(bad("id = \"\"\nname = \"x\"").contains("must not be empty")); + assert!(bad("id = \"a b\"\nname = \"x\"").contains("invalid language id")); + } + + #[test] + fn registry_overlay_follows_the_store_and_the_grants() { + use crate::LanguageRegistry; + use ide_core::LanguageId; + use std::path::Path; + + let base = LanguageRegistry::load(None).unwrap(); + let store = PluginLanguages::new(); + base.attach_plugins(store.clone()); + // Projects fork before plugins load: the fork follows the store anyway. + let project = base.fork(); + assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text()); + + let sol = parse_definition( + "id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\ + [[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\n\ + [debugger]\nadapter = \"soldbg\"\ncommand = \"soldbg\"\n", + ) + .unwrap(); + let rust_clash = parse_definition("id = \"rust\"\nname = \"Evil Rust\"\nextensions = [\"rs\"]").unwrap(); + let ext_clash = parse_definition( + "id = \"rusty\"\nname = \"Rusty\"\nextensions = [\"rs\", \"rsy\"]\nfilenames = [\"Cargo.lock\"]", + ) + .unwrap(); + let _sol = store.add("acme.sol".into(), sol); + let _clash = store.add("acme.evil".into(), rust_clash); + let _ext = store.add("acme.evil".into(), ext_clash); + + assert_eq!(project.detect(Path::new("a.sol"), None).as_str(), "solidity"); + let rust = project.get(&LanguageId::new("rust")).unwrap(); + assert_eq!(rust.name, "Rust", "built-in definitions win on id clashes"); + assert_eq!(project.detect(Path::new("main.rs"), None).as_str(), "rust", "and keep their extensions"); + assert_ne!(project.detect(Path::new("Cargo.lock"), None).as_str(), "rusty", "and file names"); + assert_eq!(project.detect(Path::new("x.rsy"), None).as_str(), "rusty"); + // No policy: the language works, its programs do not start. + let cfg = project.get(&LanguageId::new("solidity")).unwrap(); + assert!(cfg.language_servers.is_empty() && cfg.debugger.is_none()); + + let pin = ProgramPin { path: "/opt/solc".into(), hash: "h".into() }; + let granted = pin.clone(); + store.set_process_policy(Arc::new(move |plugin, cap| { + (plugin == "acme.sol" && *cap == Capability::Process("solc".into())).then(|| granted.clone()) + })); + let cfg = project.get(&LanguageId::new("solidity")).unwrap(); + assert_eq!(cfg.language_servers[0].pinned.as_ref(), Some(&pin)); + assert!(cfg.debugger.is_none(), "soldbg was not granted"); + + drop(_sol); + assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text()); + assert!(base.get(&LanguageId::new("solidity")).is_none()); + } + + #[test] + fn program_pins_check_the_file() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("tool"); + std::fs::write(&bin, "v1").unwrap(); + let pin = ProgramPin { path: bin.clone(), hash: ide_extension::ledger::file_hash(&bin).unwrap() }; + assert_eq!(pin.verify().unwrap(), bin); + std::fs::write(&bin, "v2").unwrap(); + assert!(pin.verify().unwrap_err().contains("changed since it was approved")); + } +} diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 34caf9517..832645955 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -1,10 +1,11 @@ use crate::LanguageConfig; +use crate::plugin_point::PluginLanguages; use ide_core::settings::{FileSettings, FileTypeAssociation}; use ide_core::{IdeError, IdeResult, LanguageId}; use parking_lot::RwLock; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; /// Built-in definitions, embedded at compile time. Add new languages here. const BUILTIN: &[(&str, &str)] = &[ @@ -88,12 +89,22 @@ const BUILTIN: &[(&str, &str)] = &[ ]; pub struct LanguageRegistry { - languages: Vec, - /// Lookup tables of the definitions with the user's file types applied. - tables: RwLock>, + /// Built-in definitions and the user's `languages/*.toml`. + base: Vec, + /// Languages of enabled plugins, shared by every fork; `base` wins on id clashes. + plugins: OnceLock>, + state: RwLock>, file_types: RwLock, } +/// The effective definitions and their lookup tables (the user's file types applied). +struct State { + languages: Vec, + tables: Tables, + /// [`PluginLanguages::revision`] they were built from. + plugins_revision: u64, +} + /// The user's file type associations (Settings › Editor › File Types) and /// per-file overrides (Override File Type), resolved for one project. #[derive(Clone, Debug, Default, PartialEq)] @@ -247,12 +258,73 @@ impl LanguageRegistry { } } let tables = Tables::build(&languages, &FileTypes::default()); - Self { languages, tables: RwLock::new(Arc::new(tables)), file_types: RwLock::default() } + let state = State { languages: languages.clone(), tables, plugins_revision: 0 }; + Self { + base: languages, + plugins: OnceLock::new(), + state: RwLock::new(Arc::new(state)), + file_types: RwLock::default(), + } + } + + /// Adds the languages of enabled plugins (once, before projects fork the + /// registry). They follow the store: installing, disabling or changing a + /// grant takes effect on the next lookup in every fork. + pub fn attach_plugins(&self, plugins: Arc) { + if self.plugins.set(plugins).is_err() { + tracing::warn!("plugin languages are already attached"); + } } /// A registry with the same definitions and no user file types (one per project). pub fn fork(&self) -> Self { - Self::from_configs(self.languages.clone()) + let fork = Self::from_configs(self.base.clone()); + if let Some(p) = self.plugins.get() { + let _ = fork.plugins.set(p.clone()); + } + fork + } + + /// The current effective state, rebuilt when the plugin languages changed. + fn state(&self) -> Arc { + let Some(plugins) = self.plugins.get() else { return self.state.read().clone() }; + let revision = plugins.revision(); + { + let state = self.state.read(); + if state.plugins_revision == revision { + return state.clone(); + } + } + let file_types = self.file_types.read(); + let mut state = self.state.write(); + if state.plugins_revision != revision { + *state = Arc::new(self.build(&file_types, Some(revision))); + } + state.clone() + } + + fn build(&self, file_types: &FileTypes, revision: Option) -> State { + let mut languages = self.base.clone(); + let mut plugins_revision = 0; + if let Some(plugins) = self.plugins.get() { + plugins_revision = revision.unwrap_or_else(|| plugins.revision()); + // A plugin adds languages; it can't take over a built-in or user + // definition's id, extensions or file names (File Types can). + let base_ext: std::collections::HashSet = + self.base.iter().flat_map(|l| &l.extensions).map(|e| e.to_ascii_lowercase()).collect(); + let base_names: std::collections::HashSet<&str> = + self.base.iter().flat_map(|l| &l.filenames).map(String::as_str).collect(); + for mut cfg in plugins.effective() { + if languages.iter().any(|l| l.id == cfg.id) { + continue; + } + cfg.extensions.retain(|e| !base_ext.contains(&e.to_ascii_lowercase())); + cfg.filenames.retain(|n| !base_names.contains(n.as_str())); + languages.push(cfg); + } + } + let tables = Tables::build(&languages, file_types); + State { languages, tables, plugins_revision } } /// A copy with the same definitions and file types (to compare detection before / after a change). @@ -268,17 +340,19 @@ impl LanguageRegistry { if *current == file_types { return false; } - *self.tables.write() = Arc::new(Tables::build(&self.languages, &file_types)); + let state = self.build(&file_types, None); + *self.state.write() = Arc::new(state); *current = file_types; true } + /// Built-in, user and plugin definitions. pub fn all(&self) -> Vec { - self.languages.clone() + self.state().languages.clone() } pub fn get(&self, id: &LanguageId) -> Option { - self.languages.iter().find(|l| &l.id == id).cloned() + self.state().languages.iter().find(|l| &l.id == id).cloned() } /// The `framework-ui-modes` mode of a `language` file at `path`: the first @@ -286,7 +360,8 @@ impl LanguageRegistry { /// directory without one: loose files). `None` keeps the language's own /// `ui-mode`. pub fn framework_ui_mode(&self, language: &LanguageId, path: &Path, project_root: Option<&Path>) -> Option { - let cfg = self.languages.iter().find(|l| &l.id == language)?; + let state = self.state(); + let cfg = state.languages.iter().find(|l| &l.id == language)?; cfg.framework_ui_modes .iter() .find(|f| crate::marked_root(project_root, path, &f.root_markers).is_some()) @@ -296,7 +371,8 @@ impl LanguageRegistry { /// Creating or removing `path` can change a file's `framework_ui_mode`: /// it is a framework's root marker. pub fn is_framework_marker(&self, path: &Path) -> bool { - self.languages + self.state() + .languages .iter() .flat_map(|l| &l.framework_ui_modes) .any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m))) @@ -307,33 +383,34 @@ impl LanguageRegistry { /// then hashbang / first line; falls back to plaintext. The user's patterns /// win over the definitions'. pub fn detect(&self, path: &Path, first_line: Option<&str>) -> LanguageId { - let t = self.tables.read().clone(); + let state = self.state(); + let (t, languages) = (&state.tables, &state.languages); if !t.overrides.is_empty() && let Some(&i) = t.overrides.get(path) { - return self.languages[i].id.clone(); + return languages[i].id.clone(); } let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default(); if let Some(&i) = t.by_name.get(file_name) { - return self.languages[i].id.clone(); + return languages[i].id.clone(); } if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) { - return self.languages[*i].id.clone(); + return languages[*i].id.clone(); } let lower = file_name.to_ascii_lowercase(); let mut rest = lower.as_str(); while let Some(dot) = rest.find('.') { rest = &rest[dot + 1..]; if let Some(&i) = t.by_ext.get(rest) { - return self.languages[i].id.clone(); + return languages[i].id.clone(); } } if let Some(line) = first_line { if let Some((_, i)) = t.hashbangs.iter().find(|(h, _)| hashbang_runs(line, h)) { - return self.languages[*i].id.clone(); + return languages[*i].id.clone(); } if let Some((_, i)) = t.first_line.iter().find(|(re, _)| re.is_match(line)) { - return self.languages[*i].id.clone(); + return languages[*i].id.clone(); } } LanguageId::plain_text() diff --git a/crates/ide-language/src/syntax/cache.rs b/crates/ide-language/src/syntax/cache.rs index 038ace1cb..393eb7529 100644 --- a/crates/ide-language/src/syntax/cache.rs +++ b/crates/ide-language/src/syntax/cache.rs @@ -197,6 +197,7 @@ impl SyntaxCache { let query = g.outline_query()?; Some(outline::outline(g, query, tree, text)) }) + .or_else(|| super::external::outline(lang, text)) }, ) } @@ -211,6 +212,7 @@ impl SyntaxCache { self.with_tree(doc, lang, version, text, |g, tree, text| { Some(folding::folding(g.spec, g.kinds(), tree, text, self.syntax.comment_tokens(lang))) }) + .or_else(|| super::external::folding(lang, text)) .or_else(|| Some(self.syntax.custom_folding(lang, text))) }, ) @@ -227,6 +229,7 @@ impl SyntaxCache { self.with_tree(doc, lang, version, text, |g, tree, text| { Some(highlight::highlights(g.highlight_query()?, tree, text)) }) + .or_else(|| super::external::highlights(lang, text)) }, )) } diff --git a/crates/ide-language/src/syntax/external.rs b/crates/ide-language/src/syntax/external.rs new file mode 100644 index 000000000..3d91a14a8 --- /dev/null +++ b/crates/ide-language/src/syntax/external.rs @@ -0,0 +1,239 @@ +//! Syntax from outside this crate for languages without a built-in grammar: a +//! plugin's `syntax-provider` (docs/plugin_p0.md 4.1). Providers return flat +//! results in UTF-8 byte offsets; they are validated and converted here into +//! the same [`OutlineItem`], [`FoldRange`] and highlight data the grammars +//! produce, so the editor can't tell the difference. Results are memoized per +//! document version by the [`super::SyntaxCache`] like the grammars' answers. + +use super::highlight::SyntaxHighlightKind; +use super::lines::LineIndex; +use crate::{FoldKind, FoldRange}; +use ide_core::{LanguageId, OutlineItem, SymbolKind}; +use parking_lot::RwLock; +use std::sync::Arc; + +/// A highlighted range (UTF-8 byte offsets); `kind` is a kebab-case +/// [`SyntaxHighlightKind`] name. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalToken { + pub start: u32, + pub end: u32, + pub kind: String, +} + +/// A structure-view symbol (UTF-8 byte offsets); `parent` indexes an earlier +/// symbol of the same list. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalSymbol { + pub name: String, + pub detail: Option, + pub kind: String, + pub start: u32, + pub end: u32, + pub name_start: u32, + pub name_end: u32, + pub parent: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalFold { + pub start_line: u32, + pub end_line: u32, + pub kind: String, +} + +/// Answers for languages it provides, `None` for the others. +pub trait ExternalSyntax: Send + Sync { + fn highlights(&self, lang: &LanguageId, text: &str) -> Option>; + fn outline(&self, lang: &LanguageId, text: &str) -> Option>; + fn folding(&self, lang: &LanguageId, text: &str) -> Option>; +} + +static PROVIDER: RwLock>> = RwLock::new(None); + +/// Installs (or removes) the provider every project's syntax cache consults. +pub fn set_external_syntax(provider: Option>) { + *PROVIDER.write() = provider; +} + +fn provider() -> Option> { + PROVIDER.read().clone() +} + +fn clamp(text: &str, offset: u32) -> usize { + let mut at = (offset as usize).min(text.len()); + while !text.is_char_boundary(at) { + at -= 1; + } + at +} + +/// `[from, to, kind]` triples in UTF-16 offsets, sorted and non-overlapping. +pub(crate) fn highlights(lang: &LanguageId, text: &str) -> Option> { + let mut tokens = provider()?.highlights(lang, text)?; + tokens.sort_by_key(|t| (t.start, t.end)); + let lines = LineIndex::new(text); + let mut data = Vec::with_capacity(tokens.len() * 3); + let mut covered = 0usize; + for t in tokens { + let Some(kind) = SyntaxHighlightKind::from_name(&t.kind) else { continue }; + let (start, end) = (clamp(text, t.start), clamp(text, t.end)); + if end <= start || start < covered { + continue; + } + covered = end; + let index = SyntaxHighlightKind::ALL.iter().position(|k| *k == kind).unwrap_or_default() as u32; + data.extend([lines.utf16_at(start) as u32, lines.utf16_at(end) as u32, index]); + } + Some(data) +} + +fn symbol_kind(name: &str) -> SymbolKind { + let camel: String = { + let mut out = String::with_capacity(name.len()); + let mut upper = false; + for c in name.chars() { + if c == '-' { + upper = true; + } else if upper { + out.push(c.to_ascii_uppercase()); + upper = false; + } else { + out.push(c); + } + } + out + }; + serde_json::from_value(serde_json::Value::String(camel)).unwrap_or(SymbolKind::Unknown) +} + +pub(crate) fn outline(lang: &LanguageId, text: &str) -> Option> { + let symbols = provider()?.outline(lang, text)?; + let lines = LineIndex::new(text); + // Built bottom-up: children are attached to their parent (an earlier index). + let mut items: Vec> = Vec::with_capacity(symbols.len()); + let mut parents: Vec> = Vec::with_capacity(symbols.len()); + for (i, s) in symbols.iter().enumerate() { + let (start, end) = (clamp(text, s.start), clamp(text, s.end.max(s.start))); + let name_start = clamp(text, s.name_start).clamp(start, end); + let name_end = clamp(text, s.name_end).clamp(name_start, end); + items.push(Some(OutlineItem { + name: s.name.clone(), + detail: s.detail.clone(), + kind: symbol_kind(&s.kind), + range: lines.range(start, end), + selection_range: lines.range(name_start, name_end), + children: Vec::new(), + visibility: None, + })); + parents.push(s.parent.map(|p| p as usize).filter(|&p| p < i)); + } + for i in (0..items.len()).rev() { + if let Some(p) = parents[i] { + let child = items[i].take().expect("each item is moved once"); + if let Some(parent) = items[p].as_mut() { + parent.children.insert(0, child); + } + } + } + Some(items.into_iter().flatten().collect()) +} + +pub(crate) fn folding(lang: &LanguageId, text: &str) -> Option> { + let folds = provider()?.folding(lang, text)?; + let last_line = LineIndex::new(text).line_of(text.len()) as u32; + let mut out: Vec = folds + .into_iter() + .filter(|f| f.start_line < f.end_line && f.end_line <= last_line) + .map(|f| FoldRange { + start_line: f.start_line, + end_line: f.end_line, + kind: match f.kind.as_str() { + "comment" => FoldKind::Comment, + "imports" => FoldKind::Imports, + _ => FoldKind::Region, + }, + }) + .collect(); + out.sort_by_key(|f| (f.start_line, std::cmp::Reverse(f.end_line))); + out.dedup_by_key(|f| (f.start_line, f.end_line)); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Fake; + + impl ExternalSyntax for Fake { + fn highlights(&self, lang: &LanguageId, _: &str) -> Option> { + (lang.as_str() == "fake").then(|| { + vec![ + ExternalToken { start: 9, end: 12, kind: "keyword".into() }, + // "é" is 2 bytes, 1 UTF-16 unit. + ExternalToken { start: 0, end: 6, kind: "string".into() }, + ExternalToken { start: 2, end: 4, kind: "number".into() }, // overlaps: dropped + ExternalToken { start: 13, end: 14, kind: "nonsense".into() }, + ExternalToken { start: 7, end: 99, kind: "doc-comment".into() }, + ] + }) + } + fn outline(&self, lang: &LanguageId, _: &str) -> Option> { + let sym = |name: &str, kind: &str, start, end, parent| ExternalSymbol { + name: name.into(), + detail: None, + kind: kind.into(), + start, + end, + name_start: start, + name_end: start + 1, + parent, + }; + (lang.as_str() == "fake").then(|| { + vec![ + sym("C", "class", 0, 14, None), + sym("f", "function", 8, 12, Some(0)), + sym("g", "event", 12, 13, Some(0)), + sym("bad", "method", 1, 2, Some(9)), + ] + }) + } + fn folding(&self, lang: &LanguageId, _: &str) -> Option> { + (lang.as_str() == "fake").then(|| { + vec![ + ExternalFold { start_line: 0, end_line: 1, kind: "comment".into() }, + ExternalFold { start_line: 1, end_line: 1, kind: "region".into() }, + ExternalFold { start_line: 0, end_line: 9, kind: "region".into() }, + ] + }) + } + } + + #[test] + fn converts_and_validates_provider_results() { + set_external_syntax(Some(Arc::new(Fake))); + let text = "\"héllo\"\nfn x(){}"; + let lang = LanguageId::new("fake"); + assert!(highlights(&LanguageId::new("other"), text).is_none()); + let data = highlights(&lang, text).unwrap(); + let string = SyntaxHighlightKind::ALL.iter().position(|k| *k == SyntaxHighlightKind::String).unwrap() as u32; + let doc = SyntaxHighlightKind::ALL.iter().position(|k| *k == SyntaxHighlightKind::DocComment).unwrap() as u32; + // UTF-16: `"héllo"` is 7 units (bytes 0..8 → 0..7); byte 6 is inside "o\"" → 5. + assert_eq!(&data[..3], &[0, 5, string]); + assert_eq!(&data[3..], &[6, 16, doc], "clamped to the text, keyword dropped as overlapping"); + + let outline = outline(&lang, text).unwrap(); + assert_eq!(outline.len(), 2, "orphan with a bad parent stays top-level"); + assert_eq!(outline[0].kind, SymbolKind::Class); + assert_eq!( + outline[0].children.iter().map(|c| c.kind).collect::>(), + [SymbolKind::Function, SymbolKind::Event] + ); + assert_eq!(outline[1].kind, SymbolKind::Method); + + let folds = folding(&lang, text).unwrap(); + assert_eq!(folds, [FoldRange { start_line: 0, end_line: 1, kind: FoldKind::Comment }]); + set_external_syntax(None); + } +} diff --git a/crates/ide-language/src/syntax/highlight.rs b/crates/ide-language/src/syntax/highlight.rs index 07c942492..fc43637b4 100644 --- a/crates/ide-language/src/syntax/highlight.rs +++ b/crates/ide-language/src/syntax/highlight.rs @@ -72,6 +72,24 @@ impl SyntaxHighlightKind { Self::Operator, Self::Punctuation, ]; + + /// The kebab-case name plugins use (`doc-comment`, `function-call`, …). + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|k| { + let camel = serde_json::to_value(k).ok(); + let camel = camel.as_ref().and_then(|v| v.as_str()).unwrap_or_default(); + let mut kebab = String::with_capacity(camel.len() + 2); + for c in camel.chars() { + if c.is_ascii_uppercase() { + kebab.push('-'); + kebab.push(c.to_ascii_lowercase()); + } else { + kebab.push(c); + } + } + kebab == name + }) + } } /// Highlighted ranges of a whole document, packed like LSP semantic tokens. diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index e961eb352..8c0c9bf24 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -2,6 +2,7 @@ mod cache; mod configuration; +mod external; mod folding; mod grammars; mod highlight; @@ -14,6 +15,7 @@ mod web; use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag}; pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats}; pub use configuration::ConfigurationKey; +pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, set_external_syntax}; use grammars::{SPECS, Spec}; pub use highlight::{SyntaxHighlightKind, SyntaxHighlights}; use ide_core::{LanguageId, OutlineItem, Pos, Range}; diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 5c7d53c01..c8f68f0e0 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -96,8 +96,12 @@ impl Launcher for ProcessLauncher { cancel: &CancellationToken, ) -> Result<(Peer, Option), LaunchError> { let search = server_search_path(config); - let binary = ide_core::tool_path::find_command(&config.command, &search, root) - .and_then(|b| resolve_rustup_proxy(&config.command, b)); + let binary = match &config.pinned { + // A plugin's server: the approved program, unchanged since. + Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()), + None => ide_core::tool_path::find_command(&config.command, &search, root) + .and_then(|b| resolve_rustup_proxy(&config.command, b)), + }; let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())]; env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone()))); if let Some(addr) = &config.connect { @@ -120,6 +124,9 @@ impl Launcher for ProcessLauncher { } fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool { + if let Some(pin) = &config.pinned { + return pin.path.is_file(); + } ide_core::tool_path::find_command(&config.command, &server_search_path(config), root).is_some() } } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 5a600ec83..fef3fb364 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -228,6 +228,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { max_java: None, launch_error: None, tsserver_project: false, + pinned: None, } } diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 7ed98d3bf..3bd922754 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -132,6 +132,7 @@ async fn missing_binary_is_reported_with_install_hint() { max_java: None, launch_error: None, tsserver_project: false, + pinned: None, }; let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)])); let path = dir.path().join("a.nope"); @@ -398,6 +399,7 @@ async fn typescript_language_server_end_to_end() { max_java: None, launch_error: None, tsserver_project: false, + pinned: None, }; let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)])); let doc = DocId::next(); @@ -444,6 +446,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option) + Send>; + +/// Why an [`Actor::call`] produced no result. +#[derive(Debug)] +pub enum ActorError { + /// The plugin's own outcome: an error it returned, or a trap (the actor has ended). + Call(CallError), + /// The caller's wait ran out; the result, if any, is dropped. + SoftTimeout, + /// The actor ended (an earlier call trapped, or it was stopped) before running the call. + Stopped, + /// The caller cancelled the call (`call_cancellable`). + Cancelled, +} + +pub struct Actor { + tx: Mutex>>, + thread: Mutex>>, + cancel: Arc, + alive: Arc, + /// The trap that ended the actor, until someone takes it to report it. + crash: Arc>>, + busy: Arc, + /// Milliseconds since `epoch` at the last call's end. + last_used: Arc, + epoch: Instant, +} + +impl Actor { + /// Moves an activated runtime onto its own thread. + pub fn spawn(name: &str, runtime: Runtime) -> std::io::Result { + let (tx, rx) = mpsc::channel::(); + let cancel = runtime.cancel_handle(); + let alive = Arc::new(AtomicBool::new(true)); + let busy = Arc::new(AtomicBool::new(false)); + let last_used = Arc::new(AtomicU64::new(0)); + let epoch = Instant::now(); + let thread = { + let (alive, busy, last_used) = (alive.clone(), busy.clone(), last_used.clone()); + std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || { + let mut slot = Some(runtime); + while let Ok(job) = rx.recv() { + busy.store(true, Ordering::Release); + job(&mut slot); + last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release); + busy.store(false, Ordering::Release); + if slot.is_none() { + break; + } + } + alive.store(false, Ordering::Release); + // Queued jobs are dropped with `rx`: their callers see `Stopped`. + drop(rx); + if let Some(mut rt) = slot { + rt.deactivate(); + } + })? + }; + Ok(Self { + tx: Mutex::new(Some(tx)), + thread: Mutex::new(Some(thread)), + cancel, + alive, + crash: Arc::default(), + busy, + last_used, + epoch, + }) + } + + /// Runs `f` on the plugin's thread. With `wait`, gives up after that long + /// (counting the time queued behind other calls) and the call, if it has + /// not started yet, is skipped. Without, waits for the outcome: the + /// runtime's own deadline bounds it. + pub fn call( + &self, + wait: Option, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + ) -> Result { + let give_up = wait.map(|w| Instant::now() + w); + let (reply, result) = mpsc::sync_channel(1); + let (crash, alive) = (self.crash.clone(), self.alive.clone()); + let job: Job = Box::new(move |slot| { + if give_up.is_some_and(|t| Instant::now() >= t) { + return; + } + let Some(rt) = slot.as_mut() else { return }; + let outcome = f(rt); + if let Err(CallError::Trap(e)) = &outcome { + *crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone()); + // Before the reply: the caller sees the actor dead. + alive.store(false, Ordering::Release); + *slot = None; + } + let _ = reply.send(outcome); + }); + { + let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner()); + let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) }; + tx.send(job).map_err(|_| ActorError::Stopped)?; + } + let outcome = match give_up { + Some(t) => match result.recv_timeout(t.saturating_duration_since(Instant::now())) { + Ok(r) => r, + Err(mpsc::RecvTimeoutError::Timeout) => return Err(ActorError::SoftTimeout), + Err(mpsc::RecvTimeoutError::Disconnected) => { + // Skipped as too late, or the actor ended. + return Err(if self.is_alive() { ActorError::SoftTimeout } else { ActorError::Stopped }); + } + }, + None => result.recv().map_err(|_| ActorError::Stopped)?, + }; + outcome.map_err(ActorError::Call) + } + + /// Like [`call`](Self::call) without a soft deadline, but `cancel` (the + /// caller's request was superseded) cancels the call cooperatively, and + /// interrupts it if it has not returned `grace` later. Returns + /// [`ActorError::Cancelled`] then. + pub fn call_cancellable( + &self, + cancel: &ide_core::CancellationToken, + grace: Duration, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + ) -> Result { + let (reply, result) = mpsc::sync_channel(1); + let (crash, alive) = (self.crash.clone(), self.alive.clone()); + let skip = cancel.clone(); + let job: Job = Box::new(move |slot| { + if skip.is_cancelled() { + return; + } + let Some(rt) = slot.as_mut() else { return }; + let outcome = f(rt); + if let Err(CallError::Trap(e)) = &outcome { + *crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone()); + alive.store(false, Ordering::Release); + *slot = None; + } + let _ = reply.send(outcome); + }); + { + let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner()); + let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) }; + tx.send(job).map_err(|_| ActorError::Stopped)?; + } + let mut cancelled_at: Option = None; + loop { + match result.recv_timeout(Duration::from_millis(5)) { + Ok(outcome) if cancelled_at.is_some() => { + drop(outcome); + return Err(ActorError::Cancelled); + } + Ok(outcome) => return outcome.map_err(ActorError::Call), + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped }); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + if !cancel.is_cancelled() { + continue; + } + match cancelled_at { + None => { + cancelled_at = Some(Instant::now()); + if self.is_busy() { + self.cancel.cancel_soft(); + } + } + Some(at) if at.elapsed() >= grace => { + // Did not return in time: interrupt it (the instance is rebuilt). + self.cancel(); + return Err(ActorError::Cancelled); + } + Some(_) => {} + } + } + } + + /// Queues `f` without waiting; `done` gets the outcome on the actor's + /// thread (`Stopped` if the actor ended before running it). + pub fn post( + &self, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + done: impl FnOnce(Result) + Send + 'static, + ) { + let (crash, alive) = (self.crash.clone(), self.alive.clone()); + let job: Job = Box::new(move |slot| { + let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) }; + let outcome = f(rt); + if let Err(CallError::Trap(e)) = &outcome { + *crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone()); + alive.store(false, Ordering::Release); + *slot = None; + } + done(outcome.map_err(ActorError::Call)); + }); + let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner()); + // A job that can't be queued is dropped, and with it `done` (unrun): + // the caller sees the actor dead on its next look. + if let Some(tx) = tx.as_ref() { + let _ = tx.send(job); + } + } + + /// Cooperatively cancels the running call (see [`call_cancellable`](Self::call_cancellable)). + pub fn cancel_soft(&self) { + if self.is_busy() { + self.cancel.cancel_soft(); + } + } + + /// Interrupts the running call (it fails as a trap: the instance is dropped). + pub fn cancel(&self) { + if self.busy.load(Ordering::Acquire) { + self.cancel.cancel(); + } + } + + /// The trap that ended this actor; `Some` only for the first caller, which + /// reports it (also when the caller that saw it had stopped waiting). + pub fn take_crash(&self) -> Option { + self.crash.lock().unwrap_or_else(|e| e.into_inner()).take() + } + + /// The runtime is still usable (no call trapped, not stopped). + pub fn is_alive(&self) -> bool { + self.alive.load(Ordering::Acquire) + } + + pub fn is_busy(&self) -> bool { + self.busy.load(Ordering::Acquire) + } + + /// Time since the last call ended (or since start). + pub fn idle_for(&self) -> Duration { + let last = Duration::from_millis(self.last_used.load(Ordering::Acquire)); + self.epoch.elapsed().saturating_sub(last) + } + + /// Deactivates the plugin and ends the thread after the queued calls. + /// With `join`, waits for that (bounded by the runtime's deadlines). + pub fn stop(&self, join: bool) { + self.tx.lock().unwrap_or_else(|e| e.into_inner()).take(); + if join { + let thread = self.thread.lock().unwrap_or_else(|e| e.into_inner()).take(); + if let Some(t) = thread + && t.thread().id() != std::thread::current().id() + { + let _ = t.join(); + } + } + } +} + +impl Drop for Actor { + fn drop(&mut self) { + self.stop(false); + } +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index dc6a3ed4e..bebcad554 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -1,771 +1,23 @@ -//! Sandboxed WebAssembly plugin host (Zed-style). +//! The WebAssembly runtime for plugins (wasmtime, component model, WASI +//! preview 2). Package handling, extension points and grants live in +//! `ide-extension`; the host that assembles them is `ide-host::plugins`. //! -//! A plugin is a directory with a `plugin.toml` manifest ([`PluginManifest`]) -//! and, optionally, a WebAssembly component implementing the -//! `openidea:plugin/plugin@0.1.0` world (`wit/plugin.wit`). Contribution-only -//! plugins (themes, languages) need no code. -//! -//! Each plugin with code runs in its own wasmtime `Store` with WASI preview 2 -//! (no files, env, network), a fuel budget and wall-clock deadline per call, -//! and a linear-memory cap. A trap (panic, budget exhausted, permission -//! violation) drops the instance and marks the plugin `Failed` for the session; -//! the IDE keeps running. -//! -//! Sources: `//` (installed) and `/.openidea/plugins/*/` -//! (dev plugins, loaded by [`PluginHost::sync_project`]). +//! Each plugin with code runs in its own wasmtime `Store`: no files, env or +//! network beyond what [`HostApi`] grants, a fuel budget and wall-clock +//! deadline per call, and a linear-memory cap. A trap (panic, budget +//! exhausted, permission violation, cancellation) poisons the instance; the +//! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread. -mod manifest; -mod package; +mod actor; mod runtime; mod sandbox; -mod theme; mod types; +mod wit_host; -pub use manifest::{ - API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest, ThemeDecl, +pub use actor::{Actor, ActorError}; +pub use runtime::{ + API_VERSION, CANCELLED, CallCancel, CallEffects, CallError, HostApi, PluginEngine, Runtime, RuntimeSetup, }; -pub use sandbox::resolve_in_root; -pub use theme::{ThemeDef, ThemeKind}; +pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root}; pub use types::*; - -use ide_core::{IdeError, IdeResult}; -use ide_language::LanguageConfig; -use parking_lot::{Condvar, Mutex, RwLock}; -use runtime::{CallEffects, CallError, PluginEngine, Registrations, Runtime, RuntimeSetup}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -const STATE_FILE: &str = "state.json"; -/// Top-level files copied with a package on install besides those the manifest references. -const LICENSE_FILES: &[&str] = - &["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING"]; -/// Dev plugins live here, relative to a project root. -pub const DEV_PLUGINS_DIR: &str = ".openidea/plugins"; - -#[derive(Default, Serialize, Deserialize)] -struct PersistedState { - #[serde(default)] - disabled: BTreeSet, - #[serde(default)] - theme: Option, -} - -#[derive(Clone, PartialEq, Serialize, Deserialize)] -struct ThemeRef { - plugin: String, - theme: String, - /// The theme's window frame colour as the UI resolved it (`#rrggbb[aa]`), so - /// new windows open in it before the plugin is loaded. - #[serde(default, skip_serializing_if = "Option::is_none")] - frame: Option, -} - -struct Meta { - status: PluginStatus, - error: Option, -} - -struct LoadedPlugin { - manifest: PluginManifest, - dir: PathBuf, - source: PluginSource, - /// Project that owns a dev plugin. - dev_root: Option, - /// Manifest contributions, restored on every (re)start. - static_regs: Registrations, - registrations: Arc>, - meta: Mutex, - runtime: Mutex>, -} - -impl LoadedPlugin { - fn is_live(&self) -> bool { - matches!(self.meta.lock().status, PluginStatus::Active | PluginStatus::Static) - } - - fn set_failed(&self, error: String) { - let mut meta = self.meta.lock(); - meta.status = PluginStatus::Failed; - meta.error = Some(error); - } -} - -type ProjectPolicy = Arc bool + Send + Sync>; - -pub struct PluginHost { - project_policy: RwLock, - engine: PluginEngine, - plugins_dir: PathBuf, - sink: Arc, - limits: Limits, - plugins: RwLock>>, - state: Mutex, - /// client (window) → path of its focused editor. - active_files: Arc>>, - /// Serializes loading / unloading so concurrent windows can't race. - lifecycle: Mutex<()>, - /// Set once the first [`load_all`](Self::load_all) has finished. - initial_load: (Mutex, Condvar), -} - -impl PluginHost { - /// Creates the host; call [`load_all`](Self::load_all) to load installed plugins. - pub fn new(plugins_dir: PathBuf, sink: Arc, limits: Limits) -> IdeResult { - std::fs::create_dir_all(&plugins_dir)?; - let state = std::fs::read_to_string(plugins_dir.join(STATE_FILE)) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - Ok(Self { - project_policy: RwLock::new(Arc::new(|_| false)), - engine: PluginEngine::new().map_err(IdeError::internal)?, - plugins_dir, - sink, - limits, - plugins: RwLock::default(), - state: Mutex::new(state), - active_files: Arc::default(), - lifecycle: Mutex::new(()), - initial_load: (Mutex::new(false), Condvar::new()), - }) - } - - pub fn plugins_dir(&self) -> &Path { - &self.plugins_dir - } - - /// Loads every package under the plugins directory (already loaded ones are kept). - pub fn load_all(&self) { - let _guard = self.lifecycle.lock(); - for dir in package_dirs(&self.plugins_dir) { - if !self.is_loaded_from(&dir) { - self.load_dir(&dir, PluginSource::Installed, None); - } - } - let (done, cv) = &self.initial_load; - *done.lock() = true; - cv.notify_all(); - self.emit(None, PluginEvent::CommandsChanged); - } - - /// Waits (at most `timeout`) for the first [`load_all`](Self::load_all), so a - /// listing made at startup already holds the installed plugins and the - /// selected theme. Returns whether loading has finished. - pub fn wait_initial_load(&self, timeout: Duration) -> bool { - let (done, cv) = &self.initial_load; - let mut done = done.lock(); - if !*done { - cv.wait_while_for(&mut done, |d| !*d, timeout); - } - *done - } - - /// Loads new dev plugins from `/.openidea/plugins/` and unloads vanished ones. - pub fn set_project_policy(&self, policy: ProjectPolicy) { - *self.project_policy.write() = policy; - } - - pub fn restrict_project(&self, root: &Path) { - let _guard = self.lifecycle.lock(); - let ids: Vec<_> = self - .plugins - .read() - .iter() - .filter(|(_, p)| p.dev_root.as_deref() == Some(root)) - .map(|(id, _)| id.clone()) - .collect(); - for id in ids { - self.unload(&id); - } - self.emit(None, PluginEvent::CommandsChanged); - } - - pub fn sync_project(&self, root: &Path) { - if !(self.project_policy.read())(root) { - self.restrict_project(root); - return; - } - let _guard = self.lifecycle.lock(); - let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins")); - let stale: Vec = self - .plugins - .read() - .iter() - .filter(|(_, p)| p.dev_root.as_deref() == Some(root) && !dirs.contains(&p.dir)) - .map(|(id, _)| id.clone()) - .collect(); - let mut changed = !stale.is_empty(); - for id in stale { - self.unload(&id); - } - for dir in dirs { - if !self.is_loaded_from(&dir) { - self.load_dir(&dir, PluginSource::Dev, Some(root.to_path_buf())); - changed = true; - } - } - if changed { - self.emit(None, PluginEvent::CommandsChanged); - } - } - - /// Installed plugins plus the dev plugins of `project` (if any). - pub fn list(&self, project: Option<&Path>) -> PluginsSnapshot { - let mut plugins: Vec = self - .plugins - .read() - .values() - .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) - .map(|p| self.info(p)) - .collect(); - plugins.sort_by_key(|p| p.name.to_lowercase()); - PluginsSnapshot { plugins, active_theme: self.active_theme() } - } - - pub fn info_of(&self, id: &str) -> IdeResult { - Ok(self.info(&*self.get(id)?)) - } - - pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> { - let _guard = self.lifecycle.lock(); - let plugin = self.get(id)?; - { - let mut state = self.state.lock(); - if enabled { - state.disabled.remove(id) - } else { - state.disabled.insert(id.to_owned()) - }; - } - self.save_state(); - self.stop(&plugin); - if enabled { - self.start(&plugin); - } else { - plugin.meta.lock().status = PluginStatus::Disabled; - } - self.emit(None, PluginEvent::CommandsChanged); - self.emit_theme_if_owned(id); - Ok(()) - } - - /// Re-reads a plugin from disk (e.g. after rebuilding a dev plugin). - pub fn reload(&self, id: &str) -> IdeResult { - let _guard = self.lifecycle.lock(); - let plugin = self.get(id)?; - let (dir, source, root) = (plugin.dir.clone(), plugin.source, plugin.dev_root.clone()); - self.unload(id); - let new_id = self.load_dir(&dir, source, root); - self.emit(None, PluginEvent::CommandsChanged); - self.emit_theme_if_owned(&new_id); - self.info_of(&new_id) - } - - /// Installs the package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. - pub fn install_from_path(&self, path: &Path) -> IdeResult { - if package::is_zip(path) && path.is_file() { - // Outside the plugins directory: a crash must not leave a package that loads. - let unpacked = tempfile::Builder::new().prefix("openidea-plugin-").tempdir()?; - let root = package::unpack_zip(path, unpacked.path())?; - return self.install_from_dir(&root); - } - if path.file_name().is_some_and(|name| name == MANIFEST_FILE) && path.is_file() { - return self.install_from_dir(path.parent().unwrap_or(path)); - } - self.install_from_dir(path) - } - - /// Copies a plugin package (manifest + referenced files) into the plugins - /// directory, replacing an installed version with the same id. - pub fn install_from_dir(&self, src: &Path) -> IdeResult { - let _guard = self.lifecycle.lock(); - let manifest = PluginManifest::load(src).map_err(IdeError::invalid)?; - for file in manifest.files() { - if !src.join(file).is_file() { - return Err(IdeError::invalid(format!( - "{} is missing (referenced by plugin.toml); build the plugin first", - file.display() - ))); - } - } - if let Some(wasm) = &manifest.wasm { - self.engine.compile(&src.join(wasm)).map_err(IdeError::invalid)?; - } - let id = manifest.id.clone(); - if let Some(existing) = self.plugins.read().get(&id) - && existing.source == PluginSource::Dev - { - return Err(IdeError::new( - ide_core::error::ErrorKind::AlreadyExists, - format!("a dev plugin with id {id:?} is loaded from {}", existing.dir.display()), - )); - } - let target = self.plugins_dir.join(&id); - let same_dir = src.canonicalize().ok().zip(target.canonicalize().ok()).is_some_and(|(a, b)| a == b); - self.unload(&id); - if !same_dir { - let staging = self.plugins_dir.join(format!(".install-{id}")); - let _ = std::fs::remove_dir_all(&staging); - let copy = || -> std::io::Result<()> { - std::fs::create_dir_all(&staging)?; - std::fs::copy(src.join(MANIFEST_FILE), staging.join(MANIFEST_FILE))?; - for file in manifest.files() { - let to = staging.join(file); - if let Some(parent) = to.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(src.join(file), to)?; - } - for name in LICENSE_FILES { - if src.join(name).is_file() { - std::fs::copy(src.join(name), staging.join(name))?; - } - } - if target.exists() { - std::fs::remove_dir_all(&target)?; - } - std::fs::rename(&staging, &target) - }; - if let Err(e) = copy() { - let _ = std::fs::remove_dir_all(&staging); - return Err(e.into()); - } - } - self.load_dir(&target, PluginSource::Installed, None); - self.emit(None, PluginEvent::CommandsChanged); - self.emit_theme_if_owned(&id); - self.info_of(&id) - } - - pub fn uninstall(&self, id: &str) -> IdeResult<()> { - let _guard = self.lifecycle.lock(); - let plugin = self.get(id)?; - if plugin.source != PluginSource::Installed { - return Err(IdeError::invalid("dev plugins are removed by deleting them from .openidea/plugins")); - } - self.unload(id); - std::fs::remove_dir_all(&plugin.dir)?; - let theme_cleared = { - let mut state = self.state.lock(); - state.disabled.remove(id); - let owned = state.theme.as_ref().is_some_and(|t| t.plugin == id); - if owned { - state.theme = None; - } - owned - }; - self.save_state(); - self.emit(None, PluginEvent::CommandsChanged); - if theme_cleared { - self.emit(None, PluginEvent::ThemeChanged { theme: None }); - } - Ok(()) - } - - /// Runs a plugin command. Blocks for up to the configured time limit. - pub fn run_command( - &self, - id: &str, - command: &str, - arg: Option<&str>, - invocation: Invocation, - ) -> IdeResult> { - let plugin = self.get(id)?; - let client = invocation.client.clone(); - let mut runtime = plugin.runtime.lock(); - let Some(rt) = runtime.as_mut() else { - let meta = plugin.meta.lock(); - return Err(IdeError::unavailable(match meta.status { - PluginStatus::Disabled => format!("plugin {id:?} is disabled"), - PluginStatus::Static => format!("plugin {id:?} has no code to run commands"), - _ => format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed")), - })); - }; - match rt.run_command(command, arg, invocation) { - Ok((value, fx)) => { - drop(runtime); - self.apply_effects(&plugin, fx); - Ok(value) - } - Err(CallError::Guest(e)) => Err(IdeError::internal(e)), - Err(CallError::Trap(e)) => { - *runtime = None; - drop(runtime); - self.fail(&plugin, &e, client.as_deref()); - Err(IdeError::internal(format!("plugin {id:?} {e}"))) - } - } - } - - /// The UI reports which file its focused editor shows (for `active-file`). - pub fn set_active_file(&self, client: &str, path: Option) { - let mut files = self.active_files.lock(); - match path { - Some(p) => files.insert(client.to_owned(), p), - None => files.remove(client), - }; - } - - /// Selects a plugin theme (`None` restores the built-in theme). - pub fn set_theme(&self, selection: Option<(&str, &str)>) -> IdeResult> { - if let Some((plugin, theme)) = selection { - let p = self.get(plugin)?; - if !p.registrations.lock().themes.iter().any(|t| t.id == theme) { - return Err(IdeError::not_found(format!("plugin {plugin:?} has no theme {theme:?}"))); - } - } - self.state.lock().theme = - selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into(), frame: None }); - self.save_state(); - let active = self.active_theme(); - self.emit(None, PluginEvent::ThemeChanged { theme: active.clone() }); - Ok(active) - } - - /// Records the selected theme's resolved window frame colour (`#rgb`, - /// `#rrggbb` or `#rrggbbaa`); ignored without a selected theme. - pub fn set_theme_frame(&self, frame: Option<&str>) -> IdeResult<()> { - if let Some(f) = frame - && !is_hex_color(f) - { - return Err(IdeError::invalid(format!("not a hex colour: {f:?}"))); - } - let changed = match self.state.lock().theme.as_mut() { - Some(t) if t.frame.as_deref() != frame => { - t.frame = frame.map(str::to_owned); - true - } - _ => false, - }; - if changed { - self.save_state(); - } - Ok(()) - } - - /// Frame colour for a new window: the recorded one of the selected theme, - /// unless its plugin is disabled. Available before plugins are loaded. - pub fn startup_frame(&self) -> Option { - let state = self.state.lock(); - let t = state.theme.as_ref()?; - if state.disabled.contains(&t.plugin) { - return None; - } - t.frame.clone() - } - - /// The selected plugin theme, when its plugin is enabled and running. - pub fn active_theme(&self) -> Option { - let sel = self.state.lock().theme.clone()?; - let plugin = self.plugins.read().get(&sel.plugin).cloned()?; - if !plugin.is_live() { - return None; - } - let theme = plugin.registrations.lock().themes.iter().find(|t| t.id == sel.theme).cloned()?; - Some(ActiveTheme { plugin_id: sel.plugin, theme }) - } - - /// Languages contributed by live plugins (for runtime language registration). - pub fn languages(&self) -> Vec { - self.plugins - .read() - .values() - .filter(|p| p.is_live()) - .flat_map(|p| p.registrations.lock().languages.clone()) - .collect() - } - - /// Deactivates every plugin (also done on drop). - pub fn shutdown(&self) { - for plugin in self.plugins.read().values() { - if let Some(mut rt) = plugin.runtime.lock().take() { - rt.deactivate(); - } - } - } - - // ---- internals ---- - - fn get(&self, id: &str) -> IdeResult> { - let plugin = - self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?; - if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) { - return Err(IdeError::unavailable("Project is in Restricted Mode")); - } - Ok(plugin) - } - - fn is_loaded_from(&self, dir: &Path) -> bool { - self.plugins.read().values().any(|p| p.dir == dir) - } - - /// Loads one package and starts it unless disabled. Returns the plugin id. - fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option) -> String { - let (manifest, static_regs, error) = match PluginManifest::load(dir) { - Ok(m) => match static_contributions(dir, &m) { - Ok(regs) => (m, regs, None), - Err(e) => (m, Registrations::default(), Some(e)), - }, - Err(e) => (placeholder_manifest(dir), Registrations::default(), Some(e)), - }; - let id = manifest.id.clone(); - let plugin = Arc::new(LoadedPlugin { - manifest, - dir: dir.to_path_buf(), - source, - dev_root, - registrations: Arc::new(Mutex::new(static_regs.clone())), - static_regs, - meta: Mutex::new(Meta { status: PluginStatus::Disabled, error: None }), - runtime: Mutex::new(None), - }); - { - let mut plugins = self.plugins.write(); - if let Some(existing) = plugins.get(&id) { - tracing::warn!( - "plugin {id:?} at {} ignored: already loaded from {}", - dir.display(), - existing.dir.display() - ); - return id; - } - plugins.insert(id.clone(), plugin.clone()); - } - if let Some(e) = error { - tracing::warn!("plugin {}: {e}", dir.display()); - plugin.set_failed(e); - } else if !self.state.lock().disabled.contains(&id) { - self.start(&plugin); - } - id - } - - fn unload(&self, id: &str) { - let Some(plugin) = self.plugins.write().remove(id) else { return }; - self.stop(&plugin); - } - - fn start(&self, plugin: &Arc) { - *plugin.registrations.lock() = plugin.static_regs.clone(); - let Some(wasm) = &plugin.manifest.wasm else { - *plugin.meta.lock() = Meta { status: PluginStatus::Static, error: None }; - return; - }; - let started = self.engine.compile(&plugin.dir.join(wasm)).and_then(|component| { - let active_files = self.active_files.clone(); - let setup = RuntimeSetup { - plugin_id: plugin.manifest.id.clone(), - version: plugin.manifest.version.clone(), - permissions: plugin.manifest.permissions.clone(), - registrations: plugin.registrations.clone(), - sink: self.sink.clone(), - active_file: Arc::new(move |client| client.and_then(|c| active_files.lock().get(c).cloned())), - limits: self.limits, - }; - let mut rt = Runtime::instantiate(&self.engine, &component, setup)?; - match rt.activate() { - Ok(fx) => Ok((rt, fx)), - Err(CallError::Guest(e)) => Err(format!("activation failed: {e}")), - Err(CallError::Trap(e)) => Err(format!("activation {e}")), - } - }); - match started { - Ok((rt, _fx)) => { - *plugin.runtime.lock() = Some(rt); - *plugin.meta.lock() = Meta { status: PluginStatus::Active, error: None }; - } - Err(e) => self.fail(plugin, &e, None), - } - } - - fn stop(&self, plugin: &LoadedPlugin) { - if let Some(mut rt) = plugin.runtime.lock().take() { - rt.deactivate(); - } - } - - fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { - tracing::warn!(plugin = %plugin.manifest.id, "disabled after failure: {error}"); - plugin.set_failed(error.to_owned()); - self.emit( - client, - PluginEvent::Notification { - plugin_id: plugin.manifest.id.clone(), - level: NotificationLevel::Error, - title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest.name), - body: error.to_owned(), - }, - ); - self.emit(None, PluginEvent::CommandsChanged); - self.emit_theme_if_owned(&plugin.manifest.id); - } - - fn apply_effects(&self, plugin: &LoadedPlugin, fx: CallEffects) { - if fx.commands_changed { - self.emit(None, PluginEvent::CommandsChanged); - } - let sel = self.state.lock().theme.clone(); - if let Some(sel) = sel - && sel.plugin == plugin.manifest.id - && fx.themes_changed.contains(&sel.theme) - { - self.emit(None, PluginEvent::ThemeChanged { theme: self.active_theme() }); - } - } - - fn emit_theme_if_owned(&self, id: &str) { - let owned = self.state.lock().theme.as_ref().is_some_and(|t| t.plugin == id); - if owned { - self.emit(None, PluginEvent::ThemeChanged { theme: self.active_theme() }); - } - } - - fn emit(&self, client: Option<&str>, event: PluginEvent) { - self.sink.emit(client, event); - } - - fn save_state(&self) { - let json = serde_json::to_string_pretty(&*self.state.lock()).unwrap_or_default(); - if let Err(e) = std::fs::write(self.plugins_dir.join(STATE_FILE), json) { - tracing::warn!("saving plugin state: {e}"); - } - } - - fn info(&self, p: &LoadedPlugin) -> PluginInfo { - let m = &p.manifest; - let regs = p.registrations.lock().clone(); - let meta = p.meta.lock(); - PluginInfo { - id: m.id.clone(), - name: m.name.clone(), - version: m.version.clone(), - description: m.description.clone(), - authors: m.authors.clone(), - homepage: m.homepage.clone(), - change_notes: m.change_notes.clone(), - source: p.source, - path: p.dir.clone().into(), - enabled: !self.state.lock().disabled.contains(&m.id), - status: meta.status, - error: meta.error.clone(), - has_code: m.wasm.is_some(), - permissions: m.permissions.clone(), - commands: regs.commands, - languages: regs - .languages - .iter() - .map(|l| PluginLanguage { - id: l.id.to_string(), - name: l.name.clone(), - extensions: l.extensions.clone(), - }) - .collect(), - themes: regs - .themes - .iter() - .map(|t| PluginTheme { id: t.id.clone(), name: t.name.clone(), kind: t.kind }) - .collect(), - } - } -} - -impl Drop for PluginHost { - fn drop(&mut self) { - self.shutdown(); - } -} - -fn is_hex_color(s: &str) -> bool { - s.strip_prefix('#').is_some_and(|h| matches!(h.len(), 3 | 6 | 8) && h.bytes().all(|b| b.is_ascii_hexdigit())) -} - -/// Plugin packages found in `dirs` (first occurrence of an id wins). Invalid -/// manifests are skipped; packages with missing files carry a `problem`. -pub fn scan_catalog(dirs: &[PathBuf]) -> Vec { - let mut out: Vec = Vec::new(); - for dir in dirs.iter().flat_map(|d| package_dirs(d)) { - let Ok(m) = PluginManifest::load(&dir) else { continue }; - if out.iter().any(|c| c.id == m.id) { - continue; - } - let problem = m - .files() - .into_iter() - .find(|f| !dir.join(f).is_file()) - .map(|f| format!("{} is missing; build the plugin first", f.display())); - out.push(CatalogPlugin { - has_code: m.wasm.is_some(), - language_count: m.contributes.languages.len() as u32, - theme_count: m.contributes.themes.len() as u32, - commands: m.contributes.commands.clone(), - permissions: m.permissions.clone(), - id: m.id, - name: m.name, - version: m.version, - description: m.description, - authors: m.authors, - homepage: m.homepage, - change_notes: m.change_notes, - path: dir.into(), - problem, - }); - } - out.sort_by_key(|a| a.name.to_lowercase()); - out -} - -/// Subdirectories of `dir` that contain a `plugin.toml`, sorted. -fn package_dirs(dir: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; - let mut dirs: Vec = entries - .flatten() - .map(|e| e.path()) - .filter(|p| { - p.join(MANIFEST_FILE).is_file() - && !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) - }) - .collect(); - dirs.sort(); - dirs -} - -fn static_contributions(dir: &Path, m: &PluginManifest) -> Result { - let mut regs = Registrations::default(); - for cmd in &m.contributes.commands { - regs.add_command(cmd.clone()); - } - let read = |p: &Path| std::fs::read_to_string(dir.join(p)).map_err(|e| format!("{}: {e}", p.display())); - for f in &m.contributes.languages { - let cfg = runtime::parse_language(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?; - regs.add_language(cfg); - } - for f in &m.contributes.themes { - let mut theme = ThemeDef::parse(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?; - if let Some(scheme) = &f.editor_scheme { - theme.merge_editor_scheme(&read(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?; - } - regs.add_theme(theme); - } - Ok(regs) -} - -/// Stand-in for a package whose manifest failed to parse, so it still shows up (as failed). -fn placeholder_manifest(dir: &Path) -> PluginManifest { - let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - PluginManifest { - id: format!("invalid-{}", name.to_lowercase().replace(|c: char| !c.is_ascii_alphanumeric(), "-")), - name, - version: "0.0.0".into(), - description: String::new(), - authors: Vec::new(), - homepage: None, - change_notes: None, - openidea_api: API_VERSION.into(), - wasm: None, - permissions: Vec::new(), - contributes: Contributions::default(), - } -} +pub use wasmtime::component::Component; diff --git a/crates/ide-plugin/src/manifest.rs b/crates/ide-plugin/src/manifest.rs deleted file mode 100644 index c32ac6cad..000000000 --- a/crates/ide-plugin/src/manifest.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! `plugin.toml`: the plugin package manifest. - -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::path::{Component, Path, PathBuf}; - -/// Plugin API version implemented by this host (`openidea-api` in the manifest). -pub const API_VERSION: &str = "0.1"; -pub const MANIFEST_FILE: &str = "plugin.toml"; - -/// Capabilities a plugin must declare to use the gated host functions. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)] -#[serde(rename_all = "kebab-case")] -pub enum Permission { - /// `workspace-root`, `read-file` (inside the project root only). - ProjectRead, - /// `active-file`. - EditorRead, - /// `insert-text`, `replace-selection`. - EditorWrite, -} - -impl Permission { - pub fn as_str(self) -> &'static str { - match self { - Permission::ProjectRead => "project-read", - Permission::EditorRead => "editor-read", - Permission::EditorWrite => "editor-write", - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct PluginManifest { - pub id: String, - pub name: String, - pub version: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub authors: Vec, - #[serde(default)] - pub homepage: Option, - /// Release notes of this version (Settings › Plugins › What's New). - #[serde(default)] - pub change_notes: Option, - pub openidea_api: String, - /// Component file relative to the package directory; absent for - /// contribution-only plugins (themes, languages). - #[serde(default)] - pub wasm: Option, - #[serde(default)] - pub permissions: Vec, - #[serde(default)] - pub contributes: Contributions, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct Contributions { - #[serde(default)] - pub commands: Vec, - #[serde(default)] - pub languages: Vec, - #[serde(default)] - pub themes: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct CommandDecl { - pub id: String, - pub title: String, - #[serde(default)] - pub category: Option, -} - -/// A contributed file, relative to the package directory. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FileDecl { - pub path: PathBuf, -} - -/// A contributed theme: the UI theme file plus an optional editor colour scheme -/// whose tokens are merged over it (IntelliJ's `.theme.json` + `.icls`). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct ThemeDecl { - pub path: PathBuf, - #[serde(default)] - pub editor_scheme: Option, -} - -impl PluginManifest { - pub fn parse(src: &str) -> Result { - let manifest: Self = toml::from_str(src).map_err(|e| e.to_string())?; - manifest.validate()?; - Ok(manifest) - } - - pub fn load(dir: &Path) -> Result { - let path = dir.join(MANIFEST_FILE); - let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; - Self::parse(&src).map_err(|e| format!("{}: {e}", path.display())) - } - - pub fn validate(&self) -> Result<(), String> { - validate_plugin_id(&self.id)?; - if self.name.trim().is_empty() { - return Err("`name` must not be empty".into()); - } - if !is_version(&self.version) { - return Err(format!("`version` must look like 1.2.3, got {:?}", self.version)); - } - if !api_compatible(&self.openidea_api) { - return Err(format!( - "unsupported openidea-api {:?} (this IDE implements {API_VERSION})", - self.openidea_api - )); - } - if let Some(wasm) = &self.wasm { - check_relative(wasm, "wasm")?; - } - let mut seen = std::collections::HashSet::new(); - for c in &self.contributes.commands { - validate_command_id(&c.id)?; - if !seen.insert(c.id.as_str()) { - return Err(format!("duplicate command id {:?}", c.id)); - } - if c.title.trim().is_empty() { - return Err(format!("command {:?} has an empty title", c.id)); - } - } - let theme_files = self.contributes.themes.iter().flat_map(|t| std::iter::once(&t.path).chain(&t.editor_scheme)); - for path in self.contributes.languages.iter().map(|f| &f.path).chain(theme_files) { - check_relative(path, "contributes path")?; - } - Ok(()) - } - - /// Every file of the package besides the manifest (what an install copies). - pub fn files(&self) -> Vec<&Path> { - let mut files: Vec<&Path> = self.wasm.iter().map(PathBuf::as_path).collect(); - files.extend(self.contributes.languages.iter().map(|f| f.path.as_path())); - for t in &self.contributes.themes { - files.push(t.path.as_path()); - files.extend(t.editor_scheme.as_deref()); - } - files - } - - pub fn has(&self, p: Permission) -> bool { - self.permissions.contains(&p) - } -} - -/// `[a-z0-9][a-z0-9_-]*`, at most 64 chars (no dots: it is one segment of an action id). -pub fn validate_plugin_id(id: &str) -> Result<(), String> { - validate_ident(id, "plugin id", false) -} - -/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars. -pub fn validate_command_id(id: &str) -> Result<(), String> { - validate_ident(id, "command id", true) -} - -fn validate_ident(id: &str, what: &str, dots: bool) -> Result<(), String> { - let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()); - let ok_rest = - id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_' || (dots && c == '.')); - if ok_first && ok_rest && id.len() <= 64 { - Ok(()) - } else { - let allowed = if dots { "a-z, 0-9, '.', '-', '_'" } else { "a-z, 0-9, '-', '_'" }; - Err(format!("invalid {what} {id:?}: use {allowed}, starting with a letter or digit (max 64)")) - } -} - -fn is_version(v: &str) -> bool { - let core = v.split(['-', '+']).next().unwrap_or_default(); - let parts: Vec<&str> = core.split('.').collect(); - parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) -} - -/// `0.1` and `0.1.x` are compatible with API 0.1 (pre-1.0: minor is breaking). -fn api_compatible(v: &str) -> bool { - v == API_VERSION || v.strip_prefix(API_VERSION).is_some_and(|rest| rest.starts_with('.')) -} - -/// Package paths must stay inside the package directory. -fn check_relative(path: &Path, what: &str) -> Result<(), String> { - let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_))); - if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) } -} - -#[cfg(test)] -mod tests { - use super::*; - - const FULL: &str = r#" - id = "hello" - name = "Hello" - version = "0.1.0" - description = "Says hello" - authors = ["A"] - openidea-api = "0.1" - wasm = "dist/plugin.wasm" - permissions = ["project-read", "editor-write"] - - [[contributes.commands]] - id = "say.hi" - title = "Say Hi" - category = "Hello" - - [[contributes.themes]] - path = "themes/dark.json" - editor-scheme = "themes/dark.scheme.json" - "#; - - #[test] - fn parses_full_manifest() { - let m = PluginManifest::parse(FULL).unwrap(); - assert_eq!(m.id, "hello"); - assert_eq!(m.permissions, vec![Permission::ProjectRead, Permission::EditorWrite]); - assert_eq!(m.contributes.commands[0].category.as_deref(), Some("Hello")); - assert_eq!( - m.files(), - vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json"), Path::new("themes/dark.scheme.json")] - ); - assert!(m.has(Permission::EditorWrite) && !m.has(Permission::EditorRead)); - } - - #[test] - fn minimal_contribution_only_manifest() { - let m = - PluginManifest::parse("id = \"t\"\nname = \"T\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1.3\"").unwrap(); - assert!(m.wasm.is_none() && m.permissions.is_empty()); - } - - fn err(src: &str) -> String { - PluginManifest::parse(src).unwrap_err() - } - - #[test] - fn rejects_invalid_manifests() { - let base = |extra: &str, id: &str, api: &str| { - format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nopenidea-api = \"{api}\"\n{extra}") - }; - assert!(err(&base("", "Bad.Id", "0.1")).contains("invalid plugin id")); - assert!(err(&base("", "ok", "0.2")).contains("unsupported openidea-api")); - assert!(err(&base("", "ok", "0.10")).contains("unsupported openidea-api")); - assert!(err(&base("permissions = [\"network\"]", "ok", "0.1")).contains("unknown variant")); - assert!(err(&base("wasm = \"../evil.wasm\"", "ok", "0.1")).contains("relative path")); - assert!(err(&base("wasm = \"/abs.wasm\"", "ok", "0.1")).contains("relative path")); - assert!(err(&base("bogus = 1", "ok", "0.1")).contains("unknown field")); - let dup = - "[[contributes.commands]]\nid = \"a\"\ntitle = \"A\"\n[[contributes.commands]]\nid = \"a\"\ntitle = \"B\""; - assert!(err(&base(dup, "ok", "0.1")).contains("duplicate command")); - let traversal = "[[contributes.languages]]\npath = \"a/../../x.toml\""; - assert!(err(&base(traversal, "ok", "0.1")).contains("relative path")); - let scheme_escape = "[[contributes.themes]]\npath = \"t.json\"\neditor-scheme = \"../s.json\""; - assert!(err(&base(scheme_escape, "ok", "0.1")).contains("relative path")); - assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nopenidea-api = \"0.1\"").contains("version")); - assert!(err("id = \"ok\"\nname = \" \"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"").contains("name")); - } - - #[test] - fn ident_rules() { - assert!(validate_command_id("word-count.v2_x").is_ok()); - assert!(validate_command_id("-x").is_err()); - assert!(validate_command_id("").is_err()); - assert!(validate_command_id(&"a".repeat(65)).is_err()); - assert!(validate_plugin_id("a.b").is_err()); - assert!(is_version("1.2.3-beta.1+build")); - assert!(!is_version("1.2.x")); - } -} diff --git a/crates/ide-plugin/src/package.rs b/crates/ide-plugin/src/package.rs deleted file mode 100644 index cefb3fa90..000000000 --- a/crates/ide-plugin/src/package.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Plugin packages on disk: a folder, its `plugin.toml`, or a `.zip` of the folder. - -use crate::MANIFEST_FILE; -use ide_core::{IdeError, IdeResult}; -use std::fs::{self, File}; -use std::io::{self, BufReader, Read}; -use std::path::{Path, PathBuf}; - -/// Far above any real package; stops a zip bomb before it fills the disk. -const MAX_ENTRIES: usize = 10_000; -const MAX_UNPACKED_BYTES: u64 = 1024 * 1024 * 1024; - -pub(crate) fn is_zip(path: &Path) -> bool { - path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip")) -} - -fn invalid_zip(zip: &Path, reason: impl std::fmt::Display) -> IdeError { - IdeError::invalid(format!("{} is not a valid plugin archive: {reason}", zip.display())) -} - -/// Unpacks `zip` into `dest` and returns the package folder inside it. Refuses the -/// whole archive when an entry would leave `dest` or is a symbolic link. -pub(crate) fn unpack_zip(zip: &Path, dest: &Path) -> IdeResult { - let mut archive = zip::ZipArchive::new(BufReader::new(File::open(zip)?)).map_err(|e| invalid_zip(zip, e))?; - if archive.len() > MAX_ENTRIES { - return Err(invalid_zip(zip, format!("more than {MAX_ENTRIES} entries"))); - } - let mut budget = MAX_UNPACKED_BYTES; - for i in 0..archive.len() { - let mut entry = archive.by_index(i).map_err(|e| invalid_zip(zip, e))?; - let name = entry.name().to_owned(); - let Some(rel) = entry.enclosed_name() else { - return Err(invalid_zip(zip, format!("unsafe path {name:?}"))); - }; - if entry.is_symlink() { - return Err(invalid_zip(zip, format!("symbolic link {name:?}"))); - } - let out = dest.join(rel); - if entry.is_dir() { - fs::create_dir_all(&out)?; - continue; - } - if let Some(parent) = out.parent() { - fs::create_dir_all(parent)?; - } - // One byte over the budget tells a full budget from a file that exactly fits. - let written = - io::copy(&mut (&mut entry).take(budget + 1), &mut File::create(&out)?).map_err(|e| invalid_zip(zip, e))?; - if written > budget { - return Err(invalid_zip(zip, "it unpacks to more than 1 GB")); - } - budget -= written; - } - package_root(dest).ok_or_else(|| { - IdeError::invalid(format!( - "{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", - zip.display() - )) - }) -} - -/// `dir` itself, or its only folder as when a zip holds the package folder. -/// Archiver metadata (`__MACOSX`, dot files) does not count as a second folder. -fn package_root(dir: &Path) -> Option { - if dir.join(MANIFEST_FILE).is_file() { - return Some(dir.to_path_buf()); - } - let mut folders = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| { - let name = p.file_name().unwrap_or_default().to_string_lossy(); - p.is_dir() && !name.starts_with('.') && name != "__MACOSX" - }); - let only = folders.next().filter(|_| folders.next().is_none())?; - only.join(MANIFEST_FILE).is_file().then_some(only) -} diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 83962044f..8a8d10c4b 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -1,41 +1,120 @@ -//! Wasmtime glue: one shared engine, one `Store` per plugin, host imports. +//! Wasmtime glue for the `openidea:plugin@1.0.0` world (`wit/`): one shared +//! engine, one `Store` per plugin. Everything a call may do outside the +//! sandbox goes through [`HostApi`], implemented by the host; the imports +//! themselves are in `wit_host`. -use crate::manifest::{API_VERSION, CommandDecl, Permission, validate_command_id}; -use crate::sandbox; -use crate::theme::ThemeDef; -use crate::types::{Invocation, Limits, NotificationLevel, PluginEvent, PluginEventSink}; -use ide_language::LanguageConfig; +use crate::types::{CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument}; +use crate::types::{ + EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, + RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent, +}; +use crate::types::{ + FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, +}; +use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings}; +use ide_core::CancellationToken; +use ide_extension::Capability; use parking_lot::Mutex; use std::path::Path; use std::sync::Arc; -use std::time::Duration; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; -use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder}; +use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDeadline}; use wasmtime_wasi::p2::pipe::MemoryOutputPipe; use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; -mod bindings { - wasmtime::component::bindgen!({ - world: "plugin", - path: "../../wit", - imports: { default: trappable }, - }); +use bindings::openidea::plugin::events as wit_events; +use bindings::openidea::plugin::types as wit_types; +use providers_bindings::exports::openidea::plugin::providers as wit_prov; +use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax; +use views_bindings::exports::openidea::plugin::view_provider as wit_view; +use views_bindings::openidea::plugin::view_types as wit_vt; + +/// The API version of the world above. +pub const API_VERSION: &str = "1.0"; +const EPOCH_TICK: Duration = Duration::from_millis(10); +pub(crate) const MAX_TEXT: usize = 1 << 20; +pub(crate) const MAX_TITLE: usize = 256; +const STDERR_CAPACITY: usize = 256 << 10; +/// Message of a call interrupted through [`Runtime::cancel_handle`]. +pub const CANCELLED: &str = "was cancelled"; + +/// What plugin code may ask of the IDE, per plugin. Implemented by the host, +/// which routes notifications and status items to windows, edits to open +/// documents and runtime registrations through the extension registry. +/// Capabilities are already checked when these are called. +pub trait HostApi: Send + Sync { + fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String); + /// Sets (`Some`) or removes the plugin's status bar item `id`. + fn status_item(&self, id: &str, item: Option); + /// Registers a declaration with an extension point at runtime + /// (`commands`, `themes`, `languages`). Returns whether anything changed. + fn contribute(&self, point: &str, decl: toml::Value) -> Result; + /// Roots of the open projects. + fn projects(&self) -> Vec; + /// Unsaved text of an open editor's file. + fn buffer_text(&self, path: &Path) -> Option; + /// Version and text of the open document at `path`. + fn document(&self, path: &Path) -> Option<(u64, String)>; + /// Applies edits to the open document at `path` if it is still at `version`. + fn apply_edits( + &self, + client: Option<&str>, + path: &Path, + version: u64, + edits: Vec, + ) -> Result; + fn storage_get(&self, key: &str) -> Result, HostError>; + /// Sets, or (`None`) deletes. + fn storage_set(&self, key: &str, value: Option) -> Result<(), HostError>; + fn storage_keys(&self, prefix: &str) -> Result, HostError>; + fn vcs_status(&self, root: &Path) -> Result, HostError>; + fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>; + /// Subscribes to (or unsubscribes from) a kind of workbench event. + /// Project files matching `glob` (root-relative), at most `limit`. + fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result, HostError>; + /// Replaces the plugin's diagnostics for `path`. + fn publish_diagnostics(&self, path: &Path, items: Vec) -> Result<(), HostError>; + /// Performs `request` (its host is granted), filling in secret placeholders. + fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result; + fn secret_is_set(&self, name: &str) -> bool; + /// Runs the approved file of `process:`. + fn run_process( + &self, + program: &str, + args: Vec, + cwd: Option<&Path>, + stdin: Option, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result; + fn subscribe(&self, kind: EventKind, on: bool); + /// Starts a progress indicator; cancelling it from the UI cancels `cancel`. + fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64; + fn task_report(&self, id: u64, done: u32, total: Option, message: Option); + fn task_cancelled(&self, id: u64) -> bool; + fn task_end(&self, id: u64); + /// The plugin's view (or custom editors) `view` must be rendered again. + fn refresh_view(&self, view: &str); + /// A message for the script of the `html` body of `view`. + fn post_view_message(&self, view: &str, message: String) -> Result<(), HostError>; + /// Opens a terminal in `client`'s window running the approved file of + /// `process:` in `cwd`. + fn open_terminal( + &self, + client: Option<&str>, + title: String, + program: &str, + args: Vec, + cwd: Option<&Path>, + ) -> Result<(), HostError>; + /// A setting of the plugin as JSON (project over global over the schema default). + fn setting(&self, root: Option<&Path>, key: &str) -> Option; } -use bindings::openidea::plugin::host::{self as wit_host}; -use bindings::openidea::plugin::types::{self as wit_types, LogLevel}; - -/// client → path of its focused editor. -pub(crate) type ActiveFileFn = Arc) -> Option + Send + Sync>; - -const EPOCH_TICK: Duration = Duration::from_millis(10); -const MAX_TEXT: usize = 1 << 20; -const MAX_TITLE: usize = 256; -const MAX_COMMANDS: usize = 256; -const STDERR_CAPACITY: usize = 256 << 10; - /// Shared compilation engine plus an epoch ticker thread for wall-clock limits. -pub(crate) struct PluginEngine { +pub struct PluginEngine { engine: Engine, linker: Linker, } @@ -65,73 +144,141 @@ impl PluginEngine { pub fn compile(&self, wasm: &Path) -> Result { let bytes = std::fs::read(wasm).map_err(|e| format!("{}: {e}", wasm.display()))?; - Component::new(&self.engine, &bytes) - .map_err(|e| format!("{}: not a valid plugin component: {e}", wasm.display())) + self.compile_bytes(&bytes).map_err(|e| format!("{}: {e}", wasm.display())) } -} -/// What a plugin contributed (manifest files + runtime registrations). -#[derive(Default, Clone)] -pub(crate) struct Registrations { - pub commands: Vec, - pub languages: Vec, - pub themes: Vec, -} + pub fn compile_bytes(&self, bytes: &[u8]) -> Result { + Component::new(&self.engine, bytes).map_err(|e| format!("not a valid plugin component: {e}")) + } -impl Registrations { - pub fn add_command(&mut self, cmd: CommandDecl) -> bool { - match self.commands.iter_mut().find(|c| c.id == cmd.id) { - Some(existing) if *existing == cmd => false, - Some(existing) => { - *existing = cmd; - true - } - None => { - self.commands.push(cmd); - true + /// Compiles `wasm`, or loads it precompiled from `cache_dir` (docs/plugin_plan.md + /// 4.5): the key is the module's hash plus the engine's compatibility hash, + /// so an engine upgrade simply misses and recompiles. Cache failures only + /// cost the compilation. + pub fn compile_cached(&self, wasm: &Path, cache_dir: &Path) -> Result { + let bytes = std::fs::read(wasm).map_err(|e| format!("{}: {e}", wasm.display()))?; + let file = cache_dir.join(format!("{}.cwasm", self.cache_key(&bytes))); + if file.is_file() { + // SAFETY: the cache directory belongs to the IDE (inside its plugins + // directory) and is written only by `store_precompiled` below from + // components this engine compiled; a corrupt file fails validation. + match unsafe { Component::deserialize_file(&self.engine, &file) } { + Ok(component) => return Ok(component), + Err(e) => { + tracing::info!("discarding precompiled {}: {e}", file.display()); + let _ = std::fs::remove_file(&file); + } } } + let component = self.compile_bytes(&bytes).map_err(|e| format!("{}: {e}", wasm.display()))?; + store_precompiled(&component, &file); + Ok(component) } - pub fn add_language(&mut self, cfg: LanguageConfig) { - self.languages.retain(|l| l.id != cfg.id); - self.languages.push(cfg); + /// Fills the cache for `wasm` if needed (background warm-up). Returns the + /// cache file name, to keep it when pruning. + pub fn precompile(&self, wasm: &Path, cache_dir: &Path) -> Result { + let bytes = std::fs::read(wasm).map_err(|e| format!("{}: {e}", wasm.display()))?; + let name = format!("{}.cwasm", self.cache_key(&bytes)); + let file = cache_dir.join(&name); + if !file.is_file() { + let component = self.compile_bytes(&bytes)?; + store_precompiled(&component, &file); + } + Ok(name) } - pub fn add_theme(&mut self, theme: ThemeDef) { - self.themes.retain(|t| t.id != theme.id); - self.themes.push(theme); + fn cache_key(&self, bytes: &[u8]) -> String { + use std::hash::{Hash, Hasher}; + let mut engine = std::collections::hash_map::DefaultHasher::new(); + self.engine.precompile_compatibility_hash().hash(&mut engine); + let module = blake3::hash(bytes).to_hex(); + format!("{}-{:016x}", &module[..32], engine.finish()) + } + + pub fn engine(&self) -> &Engine { + &self.engine + } + + /// Interfaces a component imports (`openidea:plugin/host@0.1.0`, `wasi:…`). + pub fn imports(&self, component: &Component) -> Vec { + component.component_type().imports(&self.engine).map(|(name, _)| name.to_owned()).collect() } } -pub(crate) fn parse_language(src: &str) -> Result { - let cfg: LanguageConfig = toml::from_str(src).map_err(|e| format!("invalid language definition: {e}"))?; - if cfg.id.as_str().is_empty() || cfg.name.trim().is_empty() { - return Err("language `id` and `name` must not be empty".into()); +fn store_precompiled(component: &Component, file: &Path) { + let Ok(bytes) = component.serialize() else { return }; + if let Some(dir) = file.parent() { + let _ = std::fs::create_dir_all(dir); + } + let tmp = file.with_extension(format!("tmp{}", std::process::id())); + if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, file)).is_err() { + let _ = std::fs::remove_file(&tmp); } - Ok(cfg) } -/// Side effects of one call the host must act on afterwards. +/// Side effects of one call the host acts on afterwards. +#[derive(Default, Debug)] +pub struct CallEffects { + /// A runtime registration changed contributions. + pub contributions_changed: bool, +} + +/// Interrupts the running call: the epoch callback traps on `flag`, host +/// operations (a fetch) watch `token`. Reset when a call starts. #[derive(Default)] -pub(crate) struct CallEffects { - pub commands_changed: bool, - pub themes_changed: Vec, +pub struct CallCancel { + flag: AtomicBool, + token: Mutex, } -pub(crate) struct StoreData { +impl CallCancel { + pub fn cancel(&self) { + self.flag.store(true, Ordering::Release); + self.token.lock().cancel(); + } + + /// Cooperative cancellation: host operations (`net`, `process`, fetch) + /// return `cancelled` and tasks report it; the guest keeps its instance. + pub fn cancel_soft(&self) { + self.token.lock().cancel(); + } + + pub(crate) fn token(&self) -> CancellationToken { + self.token.lock().clone() + } + + fn is_set(&self) -> bool { + self.flag.load(Ordering::Acquire) + } + + fn reset(&self) { + self.flag.store(false, Ordering::Release); + *self.token.lock() = CancellationToken::new(); + } +} + +pub struct StoreData { + /// The running call is interrupted at the next epoch tick past this. + pub(crate) deadline: Instant, + /// Wall-clock limit of the running call (for the message). + pub(crate) timeout: Duration, + pub(crate) call_started: Instant, + pub(crate) cancel: Arc, + pub(crate) limits: Limits, wasi: WasiCtx, - table: ResourceTable, - limits: StoreLimits, + pub(crate) table: ResourceTable, + store_limits: StoreLimits, stderr: MemoryOutputPipe, stderr_seen: usize, - plugin_id: String, - permissions: Vec, - registrations: Arc>, - sink: Arc, - invocation: Invocation, - active_file: ActiveFileFn, - effects: CallEffects, + pub(crate) plugin_id: String, + pub(crate) granted: Vec, + pub(crate) host: Arc, + pub(crate) invocation: Invocation, + pub(crate) effects: CallEffects, + /// Tasks begun by the running call and not ended yet. + pub(crate) open_tasks: Vec, + pub(crate) last_report: Option, } impl WasiView for StoreData { @@ -141,21 +288,6 @@ impl WasiView for StoreData { } impl StoreData { - fn require(&self, p: Permission) -> wasmtime::Result<()> { - if self.permissions.contains(&p) { - Ok(()) - } else { - Err(wasmtime::Error::msg(format!( - "permission denied: add \"{}\" to `permissions` in plugin.toml", - p.as_str() - ))) - } - } - - fn emit(&self, event: PluginEvent) { - self.sink.emit(self.invocation.client.as_deref(), event); - } - /// New guest stderr output since the last call (panic messages, eprintln!). fn drain_stderr(&mut self) -> String { let all = self.stderr.contents(); @@ -165,7 +297,7 @@ impl StoreData { } } -fn clip(mut s: String, max: usize) -> String { +pub(crate) fn clip(mut s: String, max: usize) -> String { if s.len() > max { let mut end = max; while !s.is_char_boundary(end) { @@ -176,170 +308,330 @@ fn clip(mut s: String, max: usize) -> String { s } -impl wit_types::Host for StoreData {} - -impl wit_host::Host for StoreData { - fn log(&mut self, level: LogLevel, message: String) -> wasmtime::Result<()> { - let id = &self.plugin_id; - let message = clip(message, MAX_TEXT); - match level { - LogLevel::Trace => tracing::trace!(plugin = %id, "{message}"), - LogLevel::Debug => tracing::debug!(plugin = %id, "{message}"), - LogLevel::Info => tracing::info!(plugin = %id, "{message}"), - LogLevel::Warn => tracing::warn!(plugin = %id, "{message}"), - LogLevel::Error => tracing::error!(plugin = %id, "{message}"), - } - Ok(()) - } - - fn register_command(&mut self, id: String, title: String, category: Option) -> wasmtime::Result<()> { - validate_command_id(&id).map_err(wasmtime::Error::msg)?; - let title = clip(title, MAX_TITLE); - if title.trim().is_empty() { - return Err(wasmtime::Error::msg(format!("command {id:?} has an empty title"))); - } - let cmd = CommandDecl { id, title, category: category.map(|c| clip(c, MAX_TITLE)).filter(|c| !c.is_empty()) }; - let mut regs = self.registrations.lock(); - if regs.commands.len() >= MAX_COMMANDS && !regs.commands.iter().any(|c| c.id == cmd.id) { - return Err(wasmtime::Error::msg(format!("too many commands (max {MAX_COMMANDS})"))); - } - if regs.add_command(cmd) { - self.effects.commands_changed = true; - } - Ok(()) - } - - fn show_notification( - &mut self, - level: wit_types::NotificationLevel, - title: String, - body: String, - ) -> wasmtime::Result<()> { - let level = match level { - wit_types::NotificationLevel::Info => NotificationLevel::Info, - wit_types::NotificationLevel::Warning => NotificationLevel::Warning, - wit_types::NotificationLevel::Error => NotificationLevel::Error, - }; - self.emit(PluginEvent::Notification { - plugin_id: self.plugin_id.clone(), - level, - title: clip(title, MAX_TITLE), - body: clip(body, 16 << 10), - }); - Ok(()) - } - - fn workspace_root(&mut self) -> wasmtime::Result> { - self.require(Permission::ProjectRead)?; - Ok(self.invocation.workspace_root.as_ref().map(|p| p.to_string_lossy().into_owned())) - } - - fn active_file(&mut self) -> wasmtime::Result> { - self.require(Permission::EditorRead)?; - Ok((self.active_file)(self.invocation.client.as_deref())) - } - - fn read_file(&mut self, path: String) -> wasmtime::Result> { - self.require(Permission::ProjectRead)?; - let Some(root) = self.invocation.workspace_root.clone() else { - return Ok(Err("no project is open".into())); - }; - let real = match sandbox::resolve_in_root(&root, &path) { - Ok(real) => real, - Err(e) => return Ok(Err(e)), - }; - if let Some(text) = self.invocation.buffer_text.as_ref().and_then(|f| f(&real)) { - return Ok(Ok(text)); - } - Ok(sandbox::read_text(&real)) - } - - fn insert_text(&mut self, text: String) -> wasmtime::Result<()> { - self.require(Permission::EditorWrite)?; - self.emit(PluginEvent::InsertText { plugin_id: self.plugin_id.clone(), text: clip(text, MAX_TEXT) }); - Ok(()) - } - - fn replace_selection(&mut self, text: String) -> wasmtime::Result<()> { - self.require(Permission::EditorWrite)?; - self.emit(PluginEvent::ReplaceSelection { plugin_id: self.plugin_id.clone(), text: clip(text, MAX_TEXT) }); - Ok(()) - } - - fn register_language(&mut self, definition: String) -> wasmtime::Result> { - Ok(parse_language(&definition).map(|cfg| { - self.registrations.lock().add_language(cfg); - self.effects.commands_changed = true; - })) - } - - fn register_theme(&mut self, definition: String) -> wasmtime::Result> { - Ok(ThemeDef::parse(&definition).map(|theme| { - self.effects.themes_changed.push(theme.id.clone()); - self.registrations.lock().add_theme(theme); - self.effects.commands_changed = true; - })) - } -} - /// A call failed: either the plugin returned an error (it stays usable) or it /// trapped / ran out of budget (the instance is poisoned and must be dropped). #[derive(Debug)] -pub(crate) enum CallError { +pub enum CallError { Guest(String), Trap(String), } -pub(crate) struct RuntimeSetup { +pub struct RuntimeSetup { pub plugin_id: String, pub version: String, - pub permissions: Vec, - pub registrations: Arc>, - pub sink: Arc, - pub active_file: ActiveFileFn, + /// Capabilities granted to the plugin. + pub granted: Vec, + pub host: Arc, pub limits: Limits, } -pub(crate) struct Runtime { +pub struct Runtime { store: Store, - plugin: bindings::Plugin, + exports: Exports, limits: Limits, version: String, } +/// What the component exports: the `plugin` world, plus optional interfaces. +struct Exports { + plugin: bindings::Plugin, + syntax: Option, + providers: Option, + views: Option, +} + +/// Most results one syntax call may return (more are dropped). +const MAX_SYNTAX_ITEMS: usize = 200_000; +/// Most events per delivered batch. +const MAX_BATCH: usize = 1000; + +fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event { + use crate::types::ActionSource as S; + match e { + WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p), + WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p), + WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p), + WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p), + WorkbenchEvent::Scheduled => wit_events::Event::Scheduled, + WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed { + action: a.action, + title: a.title, + source: match a.source { + S::Keyboard => wit_events::ActionSource::Keyboard, + S::Menu => wit_events::ActionSource::Menu, + S::Toolbar => wit_events::ActionSource::Toolbar, + S::ContextMenu => wit_events::ActionSource::ContextMenu, + S::Search => wit_events::ActionSource::Search, + S::Other => wit_events::ActionSource::Other, + }, + shortcut: a.shortcut, + }), + } +} + impl Runtime { pub fn instantiate(engine: &PluginEngine, component: &Component, setup: RuntimeSetup) -> Result { let stderr = MemoryOutputPipe::new(STDERR_CAPACITY); // No preopened dirs, env, args or network: host imports are the only way out. let wasi = WasiCtx::builder().stderr(stderr.clone()).build(); - let limits = StoreLimitsBuilder::new() + let store_limits = StoreLimitsBuilder::new() .memory_size(setup.limits.memory_bytes) .instances(32) .tables(64) .memories(8) .trap_on_grow_failure(true) .build(); + let now = Instant::now(); let data = StoreData { + deadline: now + setup.limits.timeout, + timeout: setup.limits.timeout, + call_started: now, + cancel: Arc::default(), + limits: setup.limits, wasi, table: ResourceTable::new(), - limits, + store_limits, stderr, stderr_seen: 0, plugin_id: setup.plugin_id, - permissions: setup.permissions, - registrations: setup.registrations, - sink: setup.sink, + granted: setup.granted, + host: setup.host, invocation: Invocation::default(), - active_file: setup.active_file, effects: CallEffects::default(), + open_tasks: Vec::new(), + last_report: None, }; let mut store = Store::new(&engine.engine, data); - store.limiter(|d| &mut d.limits); - store.epoch_deadline_trap(); - arm(&mut store, &setup.limits).map_err(|e| e.to_string())?; - let plugin = bindings::Plugin::instantiate(&mut store, component, &engine.linker) - .map_err(|e| describe_trap(&e, &setup.limits))?; - Ok(Self { store, plugin, limits: setup.limits, version: setup.version }) + store.limiter(|d| &mut d.store_limits); + // Checked every epoch tick: the per-call deadline and cancellation. + store.epoch_deadline_callback(|ctx| { + let data = ctx.data(); + if data.cancel.is_set() { + Err(wasmtime::Error::msg(CANCELLED)) + } else if Instant::now() >= data.deadline { + Err(wasmtime::Error::msg(format!("timed out after {:?}", data.timeout))) + } else { + Ok(UpdateDeadline::Continue(1)) + } + }); + arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?; + let api_hint = |e: wasmtime::Error| { + let msg = describe_trap(&e, &setup.limits); + if msg.contains("openidea:plugin/") { + format!("{msg} (the component was built for a different plugin API; this IDE runs API {API_VERSION})") + } else { + msg + } + }; + let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?; + let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?; + let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok(); + let provider_indices = wit_prov::GuestIndices::new(&pre).ok(); + let view_indices = wit_view::GuestIndices::new(&pre).ok(); + let instance = pre.instantiate(&mut store).map_err(api_hint)?; + let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?; + let syntax = match syntax_indices { + Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; + let providers = match provider_indices { + Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; + let views = match view_indices { + Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; + Ok(Self { + store, + exports: Exports { plugin, syntax, providers, views }, + limits: setup.limits, + version: setup.version, + }) + } + + /// The component exports `syntax-provider`. + pub fn has_syntax(&self) -> bool { + self.exports.syntax.is_some() + } + + pub fn syntax_highlights( + &mut self, + language: &str, + text: &str, + timeout: Duration, + ) -> Result, CallError> { + let tokens = self.syntax_call(timeout, |s, store| s.call_highlights(store, language, text))?; + Ok(tokens + .into_iter() + .take(MAX_SYNTAX_ITEMS) + .map(|t| SyntaxToken { start: t.start, end: t.end, kind: clip(t.kind, 32) }) + .collect()) + } + + pub fn syntax_outline( + &mut self, + language: &str, + text: &str, + timeout: Duration, + ) -> Result, CallError> { + let symbols = self.syntax_call(timeout, |s, store| s.call_outline(store, language, text))?; + Ok(symbols + .into_iter() + .take(MAX_SYNTAX_ITEMS) + .map(|s| SyntaxSymbol { + name: clip(s.name, MAX_TITLE), + detail: s.detail.map(|d| clip(d, MAX_TITLE)), + kind: clip(s.kind, 32), + start: s.start, + end: s.end, + name_start: s.name_start, + name_end: s.name_end, + parent: s.parent, + }) + .collect()) + } + + pub fn syntax_folding( + &mut self, + language: &str, + text: &str, + timeout: Duration, + ) -> Result, CallError> { + let folds = self.syntax_call(timeout, |s, store| s.call_folding(store, language, text))?; + Ok(folds + .into_iter() + .take(MAX_SYNTAX_ITEMS) + .map(|f| SyntaxFold { start_line: f.start_line, end_line: f.end_line, kind: clip(f.kind, 32) }) + .collect()) + } + + fn syntax_call( + &mut self, + timeout: Duration, + f: impl FnOnce(&wit_syntax::Guest, &mut Store) -> wasmtime::Result, + ) -> Result { + if self.exports.syntax.is_none() { + return Err(CallError::Guest("the plugin does not provide syntax".into())); + } + self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store)) + .map(|(r, _)| r) + } + + /// The component exports `providers`. + pub fn has_providers(&self) -> bool { + self.exports.providers.is_some() + } + + fn provider_call( + &mut self, + timeout: Duration, + f: impl FnOnce(&wit_prov::Guest, &mut Store) -> wasmtime::Result, + ) -> Result { + if self.exports.providers.is_none() { + return Err(CallError::Guest("the plugin does not export providers".into())); + } + self.call(Invocation::default(), timeout, |e, store| f(e.providers.as_ref().expect("checked"), store)) + .map(|(r, _)| r) + } + + pub fn provide_completion( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + trigger: Option<&str>, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let items = self.provider_call(timeout, |p, s| p.call_completion(s, &d, pos_to_wit(at), trigger))?; + Ok(items + .into_iter() + .take(MAX_PROVIDER_ITEMS) + .map(|i| CompletionItem { + label: clip(i.label, MAX_TITLE), + detail: i.detail.map(|d| clip(d, MAX_TITLE)), + kind: i.kind.map(|k| completion_kind(k).to_owned()), + insert_text: clip(i.insert_text, 64 << 10), + replace: i.replace.map(range_from_wit), + sort_text: i.sort_text.map(|t| clip(t, 64)), + documentation: i.documentation.map(|d| clip(d, 16 << 10)), + }) + .collect()) + } + + pub fn provide_inline_completion( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let s = self.provider_call(timeout, |p, s| p.call_inline_completion(s, &d, pos_to_wit(at)))?; + Ok(s.map(|s| InlineSuggestion { + insert_text: clip(s.insert_text, 64 << 10), + replace: s.replace.map(range_from_wit), + })) + } + + pub fn provide_code_actions( + &mut self, + doc: &ProviderDocument, + selection: ide_core::Range, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?; + Ok(actions + .into_iter() + .take(MAX_PROVIDER_ITEMS) + .map(|a| CodeAction { + title: clip(a.title, MAX_TITLE), + kind: a.kind.map(|k| clip(k, 64)), + preferred: a.preferred, + edits: a + .edits + .into_iter() + .map(|f| { + let edits = f + .edits + .into_iter() + .map(|e| ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text }) + .collect(); + (std::path::PathBuf::from(f.path), edits) + }) + .collect(), + }) + .collect()) + } + + pub fn provide_definition( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let locations = self.provider_call(timeout, |p, s| p.call_definition(s, &d, pos_to_wit(at)))?; + Ok(locations.into_iter().take(MAX_PROVIDER_ITEMS).map(location_from_wit).collect()) + } + + pub fn provide_line_markers( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + let d = doc_to_wit(doc); + let markers = self.provider_call(timeout, |p, s| p.call_line_markers(s, &d))?; + Ok(markers + .into_iter() + .take(MAX_PROVIDER_ITEMS) + .map(|m| LineMarker { + line: m.line, + icon: m.icon.map(|i| clip(i, 32)), + tooltip: clip(m.tooltip, 1024), + target: m.target.map(location_from_wit), + }) + .collect()) } pub fn activate(&mut self) -> Result { @@ -348,10 +640,12 @@ impl Runtime { plugin_id: data.plugin_id.clone(), plugin_version: self.version.clone(), api_version: API_VERSION.into(), - permissions: data.permissions.iter().map(|p| p.as_str().to_owned()).collect(), + capabilities: data.granted.iter().map(|c| c.to_string()).collect(), }; - self.call(Invocation::default(), |plugin, store| plugin.call_activate(store, &ctx)) - .and_then(|(r, fx)| r.map(|()| fx).map_err(CallError::Guest)) + self.call(Invocation::default(), self.limits.timeout, |e, store| { + e.plugin.openidea_plugin_lifecycle().call_activate(store, &ctx) + }) + .and_then(|(r, fx)| r.map(|()| fx).map_err(CallError::Guest)) } pub fn run_command( @@ -360,29 +654,123 @@ impl Runtime { arg: Option<&str>, invocation: Invocation, ) -> Result<(Option, CallEffects), CallError> { - let (result, fx) = self.call(invocation, |plugin, store| plugin.call_run_command(store, id, arg))?; + let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let r = e.plugin.openidea_plugin_commands().call_run( + &mut *store, + id, + wasmtime::component::Resource::new_borrow(ctx.rep()), + arg, + ); + store.data_mut().drop_invocation(ctx); + r + })?; result.map(|v| (v, fx)).map_err(CallError::Guest) } + /// The component exports `view-provider`. + pub fn has_views(&self) -> bool { + self.exports.views.is_some() + } + + /// Content of a view or custom editor (`invocation.editor`: its document). + pub fn render_view( + &mut self, + view: &str, + viewport: Option, + invocation: Invocation, + ) -> Result<(View, CallEffects), CallError> { + if self.exports.views.is_none() { + return Err(CallError::Guest("the plugin does not export view-provider".into())); + } + let viewport = viewport.map(|v| wit_vt::Viewport { first_row: v.first_row, rows: v.rows.min(MAX_TABLE_ROWS) }); + let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let views = e.views.as_ref().expect("checked"); + let r = + views.call_render(&mut *store, view, viewport, wasmtime::component::Resource::new_borrow(ctx.rep())); + store.data_mut().drop_invocation(ctx); + r + })?; + match result { + Ok(v) => Ok((view_from_wit(v), fx)), + Err(e) => Err(CallError::Guest(crate::wit_host::error_text(e))), + } + } + + /// A user action in a view or custom editor. + pub fn view_event( + &mut self, + view: &str, + event: ViewEvent, + invocation: Invocation, + ) -> Result { + if self.exports.views.is_none() { + return Err(CallError::Guest("the plugin does not export view-provider".into())); + } + let event = event_to_wit_view(event); + let (result, fx) = self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let views = e.views.as_ref().expect("checked"); + let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep())); + store.data_mut().drop_invocation(ctx); + r + })?; + result.map(|()| fx).map_err(|e| CallError::Guest(crate::wit_host::error_text(e))) + } + + /// Delivers a batch of events (from the window in `invocation`). + pub fn handle_events( + &mut self, + events: Vec, + invocation: Invocation, + ) -> Result { + let events: Vec = events.into_iter().take(MAX_BATCH).map(event_to_wit).collect(); + self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let r = e.plugin.openidea_plugin_event_handler().call_handle( + &mut *store, + &events, + wasmtime::component::Resource::new_borrow(ctx.rep()), + ); + store.data_mut().drop_invocation(ctx); + r + }) + .map(|((), fx)| fx) + } + /// Best effort; errors are logged. pub fn deactivate(&mut self) { if let Err(CallError::Trap(e) | CallError::Guest(e)) = - self.call(Invocation::default(), |plugin, store| plugin.call_deactivate(store)) + self.call(Invocation::default(), self.limits.timeout, |e, store| { + e.plugin.openidea_plugin_lifecycle().call_deactivate(store) + }) { tracing::warn!(plugin = %self.store.data().plugin_id, "deactivate failed: {e}"); } } + /// Interrupts the running call when cancelled (the instance is then + /// poisoned, like after a timeout). Reset when the next call starts. + pub fn cancel_handle(&self) -> Arc { + self.store.data().cancel.clone() + } + fn call( &mut self, invocation: Invocation, - f: impl FnOnce(&bindings::Plugin, &mut Store) -> wasmtime::Result, + timeout: Duration, + f: impl FnOnce(&Exports, &mut Store) -> wasmtime::Result, ) -> Result<(R, CallEffects), CallError> { - arm(&mut self.store, &self.limits).map_err(|e| CallError::Trap(e.to_string()))?; + arm(&mut self.store, &self.limits, timeout).map_err(|e| CallError::Trap(e.to_string()))?; self.store.data_mut().invocation = invocation; - let result = f(&self.plugin, &mut self.store); + let result = f(&self.exports, &mut self.store); let data = self.store.data_mut(); data.invocation = Invocation::default(); + // Tasks don't outlive the call that began them. + for id in std::mem::take(&mut data.open_tasks) { + data.host.task_end(id); + } let effects = std::mem::take(&mut data.effects); let stderr = data.drain_stderr(); if !stderr.trim().is_empty() { @@ -405,17 +793,78 @@ impl Runtime { } } -fn arm(store: &mut Store, limits: &Limits) -> wasmtime::Result<()> { +/// Most items one provider call may return (more are dropped). +const MAX_PROVIDER_ITEMS: usize = 10_000; + +fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position { + wit_types::Position { line: p.line, character: p.col } +} + +fn range_to_wit(r: ide_core::Range) -> wit_types::Range { + wit_types::Range { start: pos_to_wit(r.start), end: pos_to_wit(r.end) } +} + +fn range_from_wit(r: wit_types::Range) -> ide_core::Range { + ide_core::Range::new( + ide_core::Pos::new(r.start.line, r.start.character), + ide_core::Pos::new(r.end.line, r.end.character), + ) +} + +fn location_from_wit(l: wit_types::Location) -> Location { + Location { path: std::path::PathBuf::from(l.path), range: range_from_wit(l.range) } +} + +fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document { + wit_prov::Document { + path: d.path.to_string_lossy().into_owned(), + language: d.language.clone(), + version: d.version, + text: d.text.clone(), + } +} + +fn completion_kind(k: wit_prov::CompletionKind) -> &'static str { + use wit_prov::CompletionKind as K; + match k { + K::Text => "text", + K::Method => "method", + K::Function => "function", + K::Constructor => "constructor", + K::Field => "field", + K::Variable => "variable", + K::Class => "class", + K::Interface => "interface", + K::Module => "module", + K::Property => "property", + K::Keyword => "keyword", + K::Snippet => "snippet", + K::Value => "value", + K::Constant => "constant", + K::File => "file", + K::Reference => "reference", + } +} + +/// Resets the budgets for the next call: fuel, deadline, cancellation. +fn arm(store: &mut Store, limits: &Limits, timeout: Duration) -> wasmtime::Result<()> { store.set_fuel(limits.fuel)?; - let ticks = (limits.timeout.as_millis() / EPOCH_TICK.as_millis()).max(1) as u64; - store.set_epoch_deadline(ticks); + let data = store.data_mut(); + let now = Instant::now(); + data.call_started = now; + data.deadline = now + timeout; + data.timeout = timeout; + data.last_report = None; + data.cancel.reset(); + // The callback above runs every tick and decides. + store.set_epoch_deadline(1); Ok(()) } fn describe_trap(e: &wasmtime::Error, limits: &Limits) -> String { match e.downcast_ref::() { Some(wasmtime::Trap::OutOfFuel) => format!("exceeded its CPU budget ({} fuel per call)", limits.fuel), - Some(wasmtime::Trap::Interrupt) => format!("timed out after {:?}", limits.timeout), + Some(wasmtime::Trap::Interrupt) => CANCELLED.to_owned(), Some(wasmtime::Trap::UnreachableCodeReached) => "crashed (panic / unreachable)".to_owned(), Some(trap) => format!("trapped: {trap}"), None => { @@ -428,3 +877,110 @@ fn describe_trap(e: &wasmtime::Error, limits: &Limits) -> String { } } } + +/// Most table rows one render returns (and a viewport asks for). +const MAX_TABLE_ROWS: u32 = 2_000; +/// Most tree / list items and form fields per view. +const MAX_VIEW_ITEMS: usize = 20_000; +const MAX_CELL: usize = 64 << 10; +const MAX_HTML: usize = 4 << 20; + +fn opt_clip(s: Option, max: usize) -> Option { + s.map(|s| clip(s, max)) +} + +fn view_from_wit(v: wit_vt::View) -> View { + let body = match v.body { + wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)), + wit_vt::Body::Tree(items) => ViewBody::Tree( + items + .into_iter() + .take(MAX_VIEW_ITEMS) + .map(|i| ViewTreeItem { + key: clip(i.key, 1024), + parent: opt_clip(i.parent, 1024), + label: clip(i.label, MAX_TITLE), + icon: opt_clip(i.icon, 64), + detail: opt_clip(i.detail, MAX_TITLE), + expandable: i.expandable, + expanded: i.expanded, + }) + .collect(), + ), + wit_vt::Body::List(items) => ViewBody::List( + items + .into_iter() + .take(MAX_VIEW_ITEMS) + .map(|i| ViewListItem { + key: clip(i.key, 1024), + label: clip(i.label, MAX_TITLE), + icon: opt_clip(i.icon, 64), + detail: opt_clip(i.detail, MAX_TITLE), + }) + .collect(), + ), + wit_vt::Body::Form(fields) => ViewBody::Form( + fields + .into_iter() + .take(MAX_VIEW_ITEMS) + .map(|f| ViewField { + key: clip(f.key, 128), + label: clip(f.label, MAX_TITLE), + value: match f.value { + wit_vt::FieldValue::Text(t) => FieldValue::Text(clip(t, MAX_CELL)), + wit_vt::FieldValue::Multiline(t) => FieldValue::Multiline(clip(t, 1 << 20)), + wit_vt::FieldValue::Checkbox(b) => FieldValue::Checkbox(b), + wit_vt::FieldValue::Choice((options, selected)) => FieldValue::Choice { + options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(), + selected: clip(selected, MAX_TITLE), + }, + }, + }) + .collect(), + ), + wit_vt::Body::Table(t) => ViewBody::Table(ViewTable { + columns: t.columns.into_iter().take(1000).map(|c| clip(c, MAX_TITLE)).collect(), + total_rows: t.total_rows, + first_row: t.first_row, + rows: t + .rows + .into_iter() + .take(MAX_TABLE_ROWS as usize) + .map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect()) + .collect(), + editable: t.editable, + }), + wit_vt::Body::Html(h) => ViewBody::Html(clip(h, MAX_HTML)), + }; + View { + toolbar: v + .toolbar + .into_iter() + .take(32) + .map(|b| ViewButton { + id: clip(b.id, 64), + label: clip(b.label, MAX_TITLE), + icon: opt_clip(b.icon, 64), + enabled: b.enabled, + }) + .collect(), + body, + status: opt_clip(v.status, 1024), + } +} + +fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent { + use wit_vt::ViewEvent as W; + match e { + ViewEvent::Button { id } => W::Button(id), + ViewEvent::Activate { key } => W::Activate(key), + ViewEvent::Select { key } => W::Select(key), + ViewEvent::Expand { key } => W::Expand(key), + ViewEvent::Collapse { key } => W::Collapse(key), + ViewEvent::Field { key, value } => W::Field((key, value)), + ViewEvent::Cell { row, column, value } => W::Cell(wit_vt::CellEdit { row, column, value }), + ViewEvent::InsertRow { before } => W::InsertRow(before), + ViewEvent::DeleteRows { rows } => W::DeleteRows(rows), + ViewEvent::Message { text } => W::Message(text), + } +} diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 47890f3de..e9074a700 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -1,11 +1,8 @@ -//! UI-facing types and the event stream. +//! Types shared by the runtime and the host. -use crate::manifest::{CommandDecl, Permission}; -use crate::theme::{ThemeDef, ThemeKind}; use serde::{Deserialize, Serialize}; use specta::Type; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::path::PathBuf; use std::time::Duration; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -16,156 +13,458 @@ pub enum NotificationLevel { Error, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub enum PluginSource { - /// Installed under `/plugins//`. - Installed, - /// Loaded from `/.openidea/plugins//` while that project is open. - Dev, +/// The focused editor of the window a command came from. +#[derive(Clone, Debug, Default)] +pub struct EditorContext { + pub path: PathBuf, + pub language: String, + /// Selections when the command was invoked (cursors are empty ranges). + pub selections: Vec, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub enum PluginStatus { - /// Code loaded and activated. - Active, - /// No code; static contributions only. - Static, - Disabled, - /// Failed to load, or trapped and was disabled for this session (see `error`). - Failed, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct PluginLanguage { - pub id: String, - pub name: String, - pub extensions: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct PluginTheme { - pub id: String, - pub name: String, - pub kind: ThemeKind, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct PluginInfo { - pub id: String, - pub name: String, - pub version: String, - pub description: String, - pub authors: Vec, - pub homepage: Option, - pub change_notes: Option, - pub source: PluginSource, - /// Package directory. - pub path: ide_core::IpcPath, - pub enabled: bool, - pub status: PluginStatus, - pub error: Option, - pub has_code: bool, - pub permissions: Vec, - /// Commands contributed while enabled (manifest + registered at runtime). - pub commands: Vec, - pub languages: Vec, - pub themes: Vec, -} - -/// A plugin package available for installation from a local catalog -/// directory (the samples shipped with the IDE). Not installed by listing it. -#[derive(Clone, Debug, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct CatalogPlugin { - pub id: String, - pub name: String, - pub version: String, - pub description: String, - pub authors: Vec, - pub homepage: Option, - pub change_notes: Option, - /// Package directory; install with `install_from_path(path)`. - pub path: ide_core::IpcPath, - pub has_code: bool, - pub permissions: Vec, - pub commands: Vec, - pub language_count: u32, - pub theme_count: u32, - /// Why the package cannot be installed as is (e.g. its WASM is not built). - pub problem: Option, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct ActiveTheme { - pub plugin_id: String, - pub theme: ThemeDef, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct PluginsSnapshot { - pub plugins: Vec, - pub active_theme: Option, -} - -/// Backend → UI notifications (Tauri event `ide://plugin`). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum PluginEvent { - /// Plugins, their status or their commands changed: re-fetch `plugin_list`. - CommandsChanged, - #[serde(rename_all = "camelCase")] - Notification { plugin_id: String, level: NotificationLevel, title: String, body: String }, - #[serde(rename_all = "camelCase")] - InsertText { plugin_id: String, text: String }, - #[serde(rename_all = "camelCase")] - ReplaceSelection { plugin_id: String, text: String }, - #[serde(rename_all = "camelCase")] - ThemeChanged { theme: Option }, -} - -/// Delivers events to the UI. `client` is the window that invoked the plugin -/// (`None` = broadcast, e.g. during activation). -pub trait PluginEventSink: Send + Sync { - fn emit(&self, client: Option<&str>, event: PluginEvent); -} - -impl, PluginEvent) + Send + Sync> PluginEventSink for F { - fn emit(&self, client: Option<&str>, event: PluginEvent) { - self(client, event) - } -} - -pub type BufferText = Arc Option + Send + Sync>; - -/// Where a command runs: the invoking window and its project. -#[derive(Clone, Default)] +/// Where a command or event batch comes from: the window and its project. +#[derive(Clone, Debug, Default)] pub struct Invocation { pub client: Option, pub workspace_root: Option, - /// Unsaved editor contents win over disk for `read-file`. - pub buffer_text: Option, + pub editor: Option, } -/// Per-call resource limits for plugin code. +/// A failed host call, as the plugin sees it (WIT `types.error`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostError { + /// Not granted; names the capability. + Denied(String), + /// The document changed since the version an edit was made for. + Stale, + NotFound(String), + Invalid(String), + Cancelled, + Failed(String), +} + +impl std::fmt::Display for HostError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Denied(c) => write!(f, "permission denied: {c} is not granted"), + Self::Stale => f.write_str("the document changed"), + Self::NotFound(m) => write!(f, "not found: {m}"), + Self::Invalid(m) => write!(f, "invalid: {m}"), + Self::Cancelled => f.write_str("cancelled"), + Self::Failed(m) => f.write_str(m), + } + } +} + +/// A plugin's status bar item. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StatusItem { + pub text: String, + pub tooltip: Option, + /// One of the plugin's commands, run on click. + pub command: Option, +} + +/// Kinds of workbench events a plugin can subscribe to (`on-event:`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)] +#[serde(rename_all = "kebab-case")] +pub enum EventKind { + FileOpened, + FileSaved, + EditorFocused, + ActionPerformed, + VcsChanged, + Scheduled, +} + +impl EventKind { + pub const ALL: [Self; 6] = [ + Self::FileOpened, + Self::FileSaved, + Self::EditorFocused, + Self::ActionPerformed, + Self::VcsChanged, + Self::Scheduled, + ]; + + pub fn name(self) -> &'static str { + match self { + Self::FileOpened => "file-opened", + Self::FileSaved => "file-saved", + Self::EditorFocused => "editor-focused", + Self::ActionPerformed => "action-performed", + Self::VcsChanged => "vcs-changed", + Self::Scheduled => "scheduled", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.name() == s) + } + + /// The capability a subscription needs (`None`: `scheduled`, which the + /// manifest declares). + pub fn capability(self) -> Option { + match self { + Self::ActionPerformed => Some(ide_extension::Capability::InputEvents), + Self::Scheduled => None, + _ => Some(ide_extension::Capability::Events), + } + } +} + +/// How the user started an action. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ActionSource { + Keyboard, + Menu, + Toolbar, + ContextMenu, + Search, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ActionPerformed { + pub action: String, + pub title: String, + pub source: ActionSource, + pub shortcut: Option, +} + +/// A workbench event delivered to plugins. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum WorkbenchEvent { + FileOpened(String), + FileSaved(String), + EditorFocused(Option), + ActionPerformed(ActionPerformed), + VcsChanged(String), + Scheduled, +} + +impl WorkbenchEvent { + pub fn kind(&self) -> EventKind { + match self { + Self::FileOpened(_) => EventKind::FileOpened, + Self::FileSaved(_) => EventKind::FileSaved, + Self::EditorFocused(_) => EventKind::EditorFocused, + Self::ActionPerformed(_) => EventKind::ActionPerformed, + Self::VcsChanged(_) => EventKind::VcsChanged, + Self::Scheduled => EventKind::Scheduled, + } + } +} + +/// State of the repository containing a project (WIT `vcs.repository-status`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RepositoryStatus { + pub root: String, + pub branch: Option, + pub commit: Option, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub operation: Option, + pub changed_files: u32, +} + +/// Resource limits and supervision policy for plugin code +/// (docs/plugin_plan.md 4.5). #[derive(Clone, Copy, Debug)] pub struct Limits { /// Wasm fuel (≈ instructions) per host → plugin call. pub fuel: u64, - /// Wall-clock limit per call (epoch interruption). + /// Wall-clock limit of commands, events, activation and deactivation. pub timeout: Duration, + /// Wall-clock limit of a call that began a task (`tasks.begin`). + pub background: Duration, + /// Edit-path calls (syntax): the caller stops waiting after this and the + /// result is dropped; the plugin keeps its instance. + pub edit_soft: Duration, + /// Edit-path calls still running after this are interrupted (the instance + /// is rebuilt on next use). + pub edit_hard: Duration, /// Linear memory cap per plugin. pub memory_bytes: usize, + /// Wait before restarting after a crash; doubles with each consecutive crash. + pub restart_backoff: Duration, + /// Consecutive crashes after which the plugin is stopped for the session. + pub max_failures: u32, + /// Instances unused for this long are reclaimed (`None`: kept). + pub idle_timeout: Option, } impl Default for Limits { fn default() -> Self { - Self { fuel: 5_000_000_000, timeout: Duration::from_secs(5), memory_bytes: 256 << 20 } + Self { + fuel: 5_000_000_000, + timeout: Duration::from_secs(5), + background: Duration::from_secs(60), + edit_soft: Duration::from_millis(50), + edit_hard: Duration::from_millis(500), + memory_bytes: 256 << 20, + restart_backoff: Duration::from_secs(1), + max_failures: 3, + idle_timeout: Some(Duration::from_secs(300)), + } } } + +impl Limits { + /// Delay before the restart that follows the `failures`-th consecutive crash. + pub fn backoff(&self, failures: u32) -> Duration { + let factor = 1u32 << failures.saturating_sub(1).min(6); + (self.restart_backoff * factor).min(Duration::from_secs(60).max(self.restart_backoff)) + } +} + +/// A highlighted range from a plugin's `syntax-provider` (UTF-8 byte offsets). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SyntaxToken { + pub start: u32, + pub end: u32, + pub kind: String, +} + +/// A structure-view symbol from a plugin's `syntax-provider` (UTF-8 byte offsets). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SyntaxSymbol { + pub name: String, + pub detail: Option, + pub kind: String, + pub start: u32, + pub end: u32, + pub name_start: u32, + pub name_end: u32, + /// Index of the enclosing symbol in the same list. + pub parent: Option, +} + +/// A foldable line range from a plugin's `syntax-provider`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SyntaxFold { + pub start_line: u32, + pub end_line: u32, + pub kind: String, +} + +/// An HTTP request from a plugin (WIT `net.request`); headers may hold +/// `{{secret:}}` placeholders for the host to fill in. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HttpRequest { + pub method: String, + pub url: String, + pub headers: Vec<(String, String)>, + pub body: Option>, + pub timeout: Duration, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HttpResponse { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +/// What a one-off process printed (WIT `process.output`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProcessOutput { + pub status: Option, + pub stdout: String, + pub stderr: String, +} + +/// Where `net:` requests may go: the URL's host when it is `https`, or +/// `http` to the loopback interface. +pub fn net_host(url: &str) -> Result { + let (scheme, rest) = url.split_once("://").ok_or_else(|| format!("{url:?} is not an absolute URL"))?; + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + let host = if let Some(v6) = authority.strip_prefix('[') { + v6.split(']').next().unwrap_or_default().to_owned() + } else { + authority.split(':').next().unwrap_or_default().to_ascii_lowercase() + }; + if host.is_empty() { + return Err(format!("{url:?} has no host")); + } + let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"); + match scheme.to_ascii_lowercase().as_str() { + "https" => Ok(host), + "http" if loopback => Ok(host), + "http" => Err(format!("{url:?}: plain http is only allowed to localhost")), + other => Err(format!("{url:?}: unsupported scheme {other:?}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn net_hosts() { + assert_eq!(net_host("https://API.example.com:8443/v1?x").unwrap(), "api.example.com"); + assert_eq!(net_host("http://localhost:11434/api").unwrap(), "localhost"); + assert_eq!(net_host("https://user:pw@h.io/").unwrap(), "h.io"); + assert!(net_host("http://example.com/").is_err()); + assert!(net_host("file:///etc/passwd").is_err()); + assert!(net_host("example.com").is_err()); + } +} + +/// The document a provider request is about. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProviderDocument { + pub path: PathBuf, + pub language: String, + pub version: u64, + pub text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Location { + pub path: PathBuf, + pub range: ide_core::Range, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CompletionItem { + pub label: String, + pub detail: Option, + /// WIT `completion-kind` name (`method`, `keyword`, …). + pub kind: Option, + pub insert_text: String, + pub replace: Option, + pub sort_text: Option, + pub documentation: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineSuggestion { + pub insert_text: String, + pub replace: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CodeAction { + pub title: String, + pub kind: Option, + pub preferred: bool, + pub edits: Vec<(PathBuf, Vec)>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineMarker { + pub line: u32, + pub icon: Option, + pub tooltip: String, + pub target: Option, +} + +// ---- declarative views (wit/views.wit) --------------------------------------- + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ViewButton { + pub id: String, + pub label: String, + pub icon: Option, + pub enabled: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ViewTreeItem { + pub key: String, + pub parent: Option, + pub label: String, + pub icon: Option, + pub detail: Option, + pub expandable: bool, + pub expanded: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ViewListItem { + pub key: String, + pub label: String, + pub icon: Option, + pub detail: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum FieldValue { + Text(String), + Multiline(String), + Checkbox(bool), + Choice { options: Vec, selected: String }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ViewField { + pub key: String, + pub label: String, + pub value: FieldValue, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ViewTable { + pub columns: Vec, + pub total_rows: u32, + pub first_row: u32, + pub rows: Vec>, + pub editable: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum ViewBody { + Message(String), + Tree(Vec), + List(Vec), + Form(Vec), + Table(ViewTable), + Html(String), +} + +/// What a view or custom editor shows. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct View { + pub toolbar: Vec, + pub body: ViewBody, + pub status: Option, +} + +/// The table rows the UI shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Viewport { + pub first_row: u32, + pub rows: u32, +} + +/// A user action in a view. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ViewEvent { + Button { id: String }, + Activate { key: String }, + Select { key: String }, + Expand { key: String }, + Collapse { key: String }, + Field { key: String, value: String }, + Cell { row: u32, column: u32, value: String }, + InsertRow { before: u32 }, + DeleteRows { rows: Vec }, + Message { text: String }, +} diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs new file mode 100644 index 000000000..5de21e760 --- /dev/null +++ b/crates/ide-plugin/src/wit_host.rs @@ -0,0 +1,691 @@ +//! The host side of the `openidea:plugin@1.0.0` world: bindings, resource +//! types and the implementation of every import. Capabilities are checked +//! here; a missing one returns `error::denied` (the plugin can degrade). + +use crate::runtime::{StoreData, clip}; +use crate::sandbox; +use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host}; +use ide_extension::Capability; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use wasmtime::component::Resource; + +pub(crate) mod bindings { + wasmtime::component::bindgen!({ + world: "plugin", + path: "../../wit", + imports: { default: trappable }, + with: { + "openidea:plugin/workspace.project": super::ProjectRes, + "openidea:plugin/editor.text-editor": super::EditorRes, + "openidea:plugin/invocation.invocation": super::InvocationRes, + "openidea:plugin/tasks.task": super::TaskRes, + }, + }); +} + +/// The optional `syntax-provider` export, bound on its own when present. +pub(crate) mod syntax_bindings { + wasmtime::component::bindgen!({ + world: "plugin-syntax", + path: "../../wit", + imports: { default: trappable }, + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/log": super::bindings::openidea::plugin::log, + "openidea:plugin/contributions": super::bindings::openidea::plugin::contributions, + "openidea:plugin/ui": super::bindings::openidea::plugin::ui, + "openidea:plugin/storage": super::bindings::openidea::plugin::storage, + "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, + "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, + "openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, + "openidea:plugin/events": super::bindings::openidea::plugin::events, + "openidea:plugin/tasks": super::bindings::openidea::plugin::tasks, + "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, + "openidea:plugin/net": super::bindings::openidea::plugin::net, + "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, + "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/views": super::bindings::openidea::plugin::views, + "openidea:plugin/settings": super::bindings::openidea::plugin::settings, + }, + }); +} + +/// The optional `providers` export. +pub(crate) mod providers_bindings { + wasmtime::component::bindgen!({ + world: "plugin-providers", + path: "../../wit", + imports: { default: trappable }, + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/log": super::bindings::openidea::plugin::log, + "openidea:plugin/contributions": super::bindings::openidea::plugin::contributions, + "openidea:plugin/ui": super::bindings::openidea::plugin::ui, + "openidea:plugin/storage": super::bindings::openidea::plugin::storage, + "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, + "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, + "openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, + "openidea:plugin/events": super::bindings::openidea::plugin::events, + "openidea:plugin/tasks": super::bindings::openidea::plugin::tasks, + "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, + "openidea:plugin/net": super::bindings::openidea::plugin::net, + "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, + "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/views": super::bindings::openidea::plugin::views, + "openidea:plugin/settings": super::bindings::openidea::plugin::settings, + }, + }); +} + +/// The optional `view-provider` export. +pub(crate) mod views_bindings { + wasmtime::component::bindgen!({ + world: "plugin-views", + path: "../../wit", + imports: { default: trappable }, + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/log": super::bindings::openidea::plugin::log, + "openidea:plugin/contributions": super::bindings::openidea::plugin::contributions, + "openidea:plugin/ui": super::bindings::openidea::plugin::ui, + "openidea:plugin/storage": super::bindings::openidea::plugin::storage, + "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, + "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, + "openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, + "openidea:plugin/events": super::bindings::openidea::plugin::events, + "openidea:plugin/tasks": super::bindings::openidea::plugin::tasks, + "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, + "openidea:plugin/net": super::bindings::openidea::plugin::net, + "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, + "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/views": super::bindings::openidea::plugin::views, + "openidea:plugin/settings": super::bindings::openidea::plugin::settings, + }, + }); +} + +use bindings::openidea::plugin::{ + contributions, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, + types as wit, ui, vcs, views, workspace, +}; + +/// A project handle: its root. +pub struct ProjectRes { + root: PathBuf, +} + +/// The invoking window's focused editor. +pub struct EditorRes { + ctx: EditorContext, +} + +/// The current call's invocation (read from [`StoreData`]). +pub struct InvocationRes; + +pub struct TaskRes { + id: u64, +} + +const MAX_EDITS: usize = 10_000; +const MAX_DIAGNOSTICS: usize = 5_000; +const MAX_REQUEST_BODY: usize = 8 << 20; +const MAX_FIND: u32 = 10_000; +const MAX_STATUS_TEXT: usize = 128; + +type Fallible = wasmtime::Result>; + +pub(crate) fn to_wit(e: HostError) -> wit::Error { + match e { + HostError::Denied(c) => wit::Error::Denied(c), + HostError::Stale => wit::Error::Stale, + HostError::NotFound(m) => wit::Error::NotFound(m), + HostError::Invalid(m) => wit::Error::Invalid(m), + HostError::Cancelled => wit::Error::Cancelled, + HostError::Failed(m) => wit::Error::Failed(m), + } +} + +/// A guest's `error` as text (`denied: net:example.com`, `stale`, …). +pub(crate) fn error_text(e: wit::Error) -> String { + match e { + wit::Error::Denied(c) => format!("permission {c} was not granted"), + wit::Error::Stale => "the document changed meanwhile".into(), + wit::Error::NotFound(m) => format!("not found: {m}"), + wit::Error::Invalid(m) => format!("invalid: {m}"), + wit::Error::Cancelled => "cancelled".into(), + wit::Error::Failed(m) => m, + } +} + +fn pos(p: wit::Position) -> ide_core::Pos { + ide_core::Pos::new(p.line, p.character) +} + +fn range_to_wit(r: &ide_core::Range) -> wit::Range { + wit::Range { + start: wit::Position { line: r.start.line, character: r.start.col }, + end: wit::Position { line: r.end.line, character: r.end.col }, + } +} + +fn table(entries: &[(&str, toml::Value)]) -> toml::Value { + toml::Value::Table(entries.iter().map(|(k, v)| ((*k).to_owned(), v.clone())).collect()) +} + +impl StoreData { + fn require(&self, cap: Capability) -> Result<(), wit::Error> { + if self.granted.iter().any(|g| g.covers(&cap)) { Ok(()) } else { Err(wit::Error::Denied(cap.to_string())) } + } + + fn client(&self) -> Option<&str> { + self.invocation.client.as_deref() + } + + fn contribute(&mut self, point: &str, decl: toml::Value) -> Result<(), wit::Error> { + let changed = self.host.contribute(point, decl).map_err(wit::Error::Invalid)?; + self.effects.contributions_changed |= changed; + Ok(()) + } + + fn push(&mut self, value: T) -> wasmtime::Result> { + Ok(self.table.push(value)?) + } + + pub(crate) fn new_invocation(&mut self) -> wasmtime::Result> { + self.push(InvocationRes) + } + + pub(crate) fn drop_invocation(&mut self, res: Resource) { + let _ = self.table.delete(res); + } +} + +impl wit::Host for StoreData {} + +impl log::Host for StoreData { + fn log(&mut self, level: wit::LogLevel, message: String) -> wasmtime::Result<()> { + let id = &self.plugin_id; + let message = clip(message, crate::runtime::MAX_TEXT); + match level { + wit::LogLevel::Trace => tracing::trace!(plugin = %id, "{message}"), + wit::LogLevel::Debug => tracing::debug!(plugin = %id, "{message}"), + wit::LogLevel::Info => tracing::info!(plugin = %id, "{message}"), + wit::LogLevel::Warn => tracing::warn!(plugin = %id, "{message}"), + wit::LogLevel::Error => tracing::error!(plugin = %id, "{message}"), + } + Ok(()) + } +} + +impl contributions::Host for StoreData { + fn register_command(&mut self, id: String, title: String, category: Option) -> Fallible<()> { + let max = crate::runtime::MAX_TITLE; + let mut entries = vec![("id", toml::Value::String(id)), ("title", toml::Value::String(clip(title, max)))]; + if let Some(c) = category.map(|c| clip(c, max)).filter(|c| !c.is_empty()) { + entries.push(("category", toml::Value::String(c))); + } + Ok(self.contribute("commands", table(&entries))) + } + + fn register_language(&mut self, definition: String) -> Fallible<()> { + Ok(self.contribute("languages", table(&[("definition", toml::Value::String(definition))]))) + } + + fn register_theme(&mut self, definition: String) -> Fallible<()> { + Ok(self.contribute("themes", table(&[("definition", toml::Value::String(definition))]))) + } +} + +impl ui::Host for StoreData { + fn show_notification( + &mut self, + level: wit::NotificationLevel, + title: String, + body: String, + ) -> wasmtime::Result<()> { + let level = match level { + wit::NotificationLevel::Info => NotificationLevel::Info, + wit::NotificationLevel::Warning => NotificationLevel::Warning, + wit::NotificationLevel::Error => NotificationLevel::Error, + }; + let max = crate::runtime::MAX_TITLE; + self.host.notify(self.client(), level, clip(title, max), clip(body, 16 << 10)); + Ok(()) + } + + fn set_status_item(&mut self, id: String, item: ui::StatusItem) -> wasmtime::Result<()> { + let item = StatusItem { + text: clip(item.text, MAX_STATUS_TEXT), + tooltip: item.tooltip.map(|t| clip(t, 1024)), + command: item.command.map(|c| clip(c, 128)), + }; + self.host.status_item(&clip(id, 64), Some(item)); + Ok(()) + } + + fn remove_status_item(&mut self, id: String) -> wasmtime::Result<()> { + self.host.status_item(&clip(id, 64), None); + Ok(()) + } +} + +impl storage::Host for StoreData { + fn get(&mut self, key: String) -> Fallible> { + if let Err(e) = self.require(Capability::Storage) { + return Ok(Err(e)); + } + Ok(self.host.storage_get(&key).map_err(to_wit)) + } + + fn set(&mut self, key: String, value: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::Storage) { + return Ok(Err(e)); + } + Ok(self.host.storage_set(&key, Some(value)).map_err(to_wit)) + } + + fn delete(&mut self, key: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::Storage) { + return Ok(Err(e)); + } + Ok(self.host.storage_set(&key, None).map_err(to_wit)) + } + + fn keys(&mut self, prefix: String) -> Fallible> { + if let Err(e) = self.require(Capability::Storage) { + return Ok(Err(e)); + } + Ok(self.host.storage_keys(&prefix).map_err(to_wit)) + } +} + +impl workspace::Host for StoreData { + fn projects(&mut self) -> Fallible>> { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + let mut out = Vec::new(); + for root in self.host.projects() { + out.push(self.push(ProjectRes { root })?); + } + Ok(Ok(out)) + } +} + +impl workspace::HostProject for StoreData { + fn root(&mut self, this: Resource) -> Fallible { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned())) + } + + fn read_file(&mut self, this: Resource, path: String) -> Fallible { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + let root = self.table.get(&this)?.root.clone(); + let real = match sandbox::resolve_in_root(&root, &path) { + Ok(real) => real, + Err(e) => return Ok(Err(wit::Error::Invalid(e))), + }; + if let Some(text) = self.host.buffer_text(&real) { + return Ok(Ok(text)); + } + Ok(sandbox::read_text(&real).map_err(wit::Error::Failed)) + } + + fn find_files(&mut self, this: Resource, glob: String, limit: u32) -> Fallible> { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + let root = self.table.get(&this)?.root.clone(); + Ok(self.host.find_files(&root, &glob, limit.min(MAX_FIND) as usize).map_err(to_wit)) + } + + fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { + self.table.delete(rep)?; + Ok(()) + } +} + +impl editor::Host for StoreData {} + +impl editor::HostTextEditor for StoreData { + fn path(&mut self, this: Resource) -> wasmtime::Result { + Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned()) + } + + fn language(&mut self, this: Resource) -> wasmtime::Result { + Ok(self.table.get(&this)?.ctx.language.clone()) + } + + fn document(&mut self, this: Resource) -> Fallible { + if let Err(e) = self.require(Capability::EditorRead) { + return Ok(Err(e)); + } + let path = self.table.get(&this)?.ctx.path.clone(); + Ok(match self.host.document(&path) { + Some((version, text)) => Ok(editor::Document { version, text }), + None => Err(wit::Error::NotFound(format!("{} is not open", path.display()))), + }) + } + + fn selections(&mut self, this: Resource) -> Fallible> { + if let Err(e) = self.require(Capability::EditorRead) { + return Ok(Err(e)); + } + Ok(Ok(self.table.get(&this)?.ctx.selections.iter().map(range_to_wit).collect())) + } + + fn apply_edits(&mut self, this: Resource, version: u64, edits: Vec) -> Fallible { + if let Err(e) = self.require(Capability::EditorWrite) { + return Ok(Err(e)); + } + if edits.len() > MAX_EDITS { + return Ok(Err(wit::Error::Invalid(format!("more than {MAX_EDITS} edits")))); + } + let total: usize = edits.iter().map(|e| e.text.len()).sum(); + if total > crate::runtime::MAX_TEXT * 8 { + return Ok(Err(wit::Error::Invalid("edits too large".into()))); + } + let path = self.table.get(&this)?.ctx.path.clone(); + let edits: Vec = edits + .into_iter() + .map(|e| ide_core::TextEdit { + range: ide_core::Range::new(pos(e.range.start), pos(e.range.end)), + new_text: e.text, + }) + .collect(); + let client = self.invocation.client.clone(); + Ok(self.host.apply_edits(client.as_deref(), &path, version, edits).map_err(to_wit)) + } + + fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { + self.table.delete(rep)?; + Ok(()) + } +} + +impl invocation::Host for StoreData {} + +impl invocation::HostInvocation for StoreData { + fn project(&mut self, _this: Resource) -> wasmtime::Result>> { + match self.invocation.workspace_root.clone() { + Some(root) => Ok(Some(self.push(ProjectRes { root })?)), + None => Ok(None), + } + } + + fn editor(&mut self, _this: Resource) -> wasmtime::Result>> { + match self.invocation.editor.clone() { + Some(ctx) => Ok(Some(self.push(EditorRes { ctx })?)), + None => Ok(None), + } + } + + fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { + // Owned by the host for the duration of the call. + let _ = rep; + Ok(()) + } +} + +impl vcs::Host for StoreData { + fn status(&mut self, project: Resource) -> Fallible> { + if let Err(e) = self.require(Capability::VcsRead) { + return Ok(Err(e)); + } + let root = self.table.get(&project)?.root.clone(); + Ok(self + .host + .vcs_status(&root) + .map(|s| { + s.map(|s| vcs::RepositoryStatus { + root: s.root, + branch: s.branch, + commit: s.commit, + upstream: s.upstream, + ahead: s.ahead, + behind: s.behind, + operation: s.operation, + changed_files: s.changed_files, + }) + }) + .map_err(to_wit)) + } + + fn fetch(&mut self, project: Resource) -> Fallible<()> { + if let Err(e) = self.require(Capability::VcsWrite) { + return Ok(Err(e)); + } + let root = self.table.get(&project)?.root.clone(); + let token = self.cancel.token(); + Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit)) + } +} + +fn kind_from_wit(k: events::EventKind) -> EventKind { + match k { + events::EventKind::FileOpened => EventKind::FileOpened, + events::EventKind::FileSaved => EventKind::FileSaved, + events::EventKind::EditorFocused => EventKind::EditorFocused, + events::EventKind::ActionPerformed => EventKind::ActionPerformed, + events::EventKind::VcsChanged => EventKind::VcsChanged, + events::EventKind::Scheduled => EventKind::Scheduled, + } +} + +impl events::Host for StoreData { + fn subscribe(&mut self, kind: events::EventKind) -> Fallible<()> { + let kind = kind_from_wit(kind); + if let Some(cap) = kind.capability() + && let Err(e) = self.require(cap) + { + return Ok(Err(e)); + } + self.host.subscribe(kind, true); + Ok(Ok(())) + } + + fn unsubscribe(&mut self, kind: events::EventKind) -> wasmtime::Result<()> { + self.host.subscribe(kind_from_wit(kind), false); + Ok(()) + } +} + +impl tasks::Host for StoreData { + fn begin(&mut self, title: String, cancellable: bool) -> wasmtime::Result> { + let token = self.cancel.token(); + let id = self.host.task_begin(self.client(), clip(title, crate::runtime::MAX_TITLE), cancellable, token); + self.open_tasks.push(id); + // The call may now run for the background budget. + let extended = self.call_started + self.limits.background; + if extended > self.deadline { + self.deadline = extended; + self.timeout = self.limits.background; + } + self.push(TaskRes { id }) + } +} + +impl tasks::HostTask for StoreData { + fn report( + &mut self, + this: Resource, + done: u32, + total: Option, + message: Option, + ) -> wasmtime::Result<()> { + let id = self.table.get(&this)?.id; + let now = Instant::now(); + // At most ~10 updates per second reach the UI. + if self.last_report.is_none_or(|t| now.duration_since(t).as_millis() >= 100) || total.is_some_and(|t| done >= t) + { + self.last_report = Some(now); + self.host.task_report(id, done, total, message.map(|m| clip(m, 256))); + } + Ok(()) + } + + fn is_cancelled(&mut self, this: Resource) -> wasmtime::Result { + let id = self.table.get(&this)?.id; + Ok(self.cancel.token().is_cancelled() || self.host.task_cancelled(id)) + } + + fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { + let id = self.table.delete(rep)?.id; + self.open_tasks.retain(|t| *t != id); + self.host.task_end(id); + Ok(()) + } +} + +impl StoreData { + /// What is left of the running call's wall-clock budget (host operations + /// can't be interrupted by the epoch callback, so they get this as timeout). + fn remaining(&self) -> Duration { + self.deadline.saturating_duration_since(Instant::now()) + } +} + +impl diagnostics::Host for StoreData { + fn publish(&mut self, path: String, items: Vec) -> Fallible<()> { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + let source = self.plugin_id.clone(); + let items = items + .into_iter() + .take(MAX_DIAGNOSTICS) + .map(|d| ide_core::Diagnostic { + range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)), + severity: match d.severity { + diagnostics::Severity::Error => ide_core::Severity::Error, + diagnostics::Severity::Warning => ide_core::Severity::Warning, + diagnostics::Severity::Info => ide_core::Severity::Info, + diagnostics::Severity::Hint => ide_core::Severity::Hint, + }, + message: clip(d.message, 4096), + source: Some(source.clone()), + code: d.code.map(|c| clip(c, 128)), + unnecessary: false, + deprecated: false, + server: Some(source.clone()), + }) + .collect(); + Ok(self.host.publish_diagnostics(Path::new(&path), items).map_err(to_wit)) + } +} + +impl net::Host for StoreData { + fn fetch(&mut self, req: net::Request) -> Fallible { + let host = match net_host(&req.url) { + Ok(h) => h, + Err(e) => return Ok(Err(wit::Error::Invalid(e))), + }; + if let Err(e) = self.require(Capability::Net(host)) { + return Ok(Err(e)); + } + if req.headers.iter().any(|h| h.value.contains("{{secret:")) + && let Err(e) = self.require(Capability::Secrets) + { + return Ok(Err(e)); + } + if req.body.as_ref().is_some_and(|b| b.len() > MAX_REQUEST_BODY) { + return Ok(Err(wit::Error::Invalid("request body larger than 8 MiB".into()))); + } + let wanted = Duration::from_millis(u64::from(req.timeout_ms.unwrap_or(30_000))); + let request = HttpRequest { + method: req.method, + url: req.url, + headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(), + body: req.body, + timeout: wanted.min(self.remaining()), + }; + let token = self.cancel.token(); + Ok(self + .host + .fetch(request, &token) + .map(|r| net::Response { + status: r.status, + headers: r.headers.into_iter().map(|(name, value)| net::Header { name, value }).collect(), + body: r.body, + }) + .map_err(to_wit)) + } +} + +impl secrets::Host for StoreData { + fn is_set(&mut self, name: String) -> wasmtime::Result { + Ok(self.granted.iter().any(|g| g.covers(&Capability::Secrets)) && self.host.secret_is_set(&name)) + } +} + +impl process::Host for StoreData { + fn run( + &mut self, + program: String, + args: Vec, + cwd: Option>, + stdin: Option, + ) -> Fallible { + if let Err(e) = self.require(Capability::Process(program.clone())) { + return Ok(Err(e)); + } + let cwd = match &cwd { + Some(p) => Some(self.table.get(p)?.root.clone()), + None => None, + }; + let token = self.cancel.token(); + let timeout = self.remaining(); + Ok(self + .host + .run_process(&program, args, cwd.as_deref(), stdin, &token, timeout) + .map(|o| process::Output { status: o.status, stdout: o.stdout, stderr: o.stderr }) + .map_err(to_wit)) + } +} + +impl views::Host for StoreData { + fn refresh(&mut self, view: String) -> wasmtime::Result<()> { + self.host.refresh_view(&clip(view, 64)); + Ok(()) + } + + fn post_message(&mut self, view: String, message: String) -> Fallible<()> { + if message.len() > MAX_VIEW_MESSAGE { + return Ok(Err(wit::Error::Invalid(format!("messages are limited to {MAX_VIEW_MESSAGE} bytes")))); + } + Ok(self.host.post_view_message(&clip(view, 64), message).map_err(to_wit)) + } + + fn open_terminal(&mut self, title: String, program: String, args: Vec) -> Fallible<()> { + if let Err(e) = self.require(Capability::Process(program.clone())) { + return Ok(Err(e)); + } + if args.len() > 256 || args.iter().any(|a| a.len() > 4096) { + return Ok(Err(wit::Error::Invalid("too many or too long arguments".into()))); + } + let cwd = self.invocation.workspace_root.clone(); + let client = self.invocation.client.clone(); + let title = clip(title, crate::runtime::MAX_TITLE); + Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit)) + } +} + +impl settings::Host for StoreData { + fn get(&mut self, key: String) -> wasmtime::Result> { + let root = self.invocation.workspace_root.clone(); + Ok(self.host.setting(root.as_deref(), &clip(key, 128))) + } +} + +impl views_bindings::openidea::plugin::view_types::Host for StoreData {} + +/// Largest message between a plugin and its view's frame. +const MAX_VIEW_MESSAGE: usize = 1 << 20; diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index dfc65c7a8d3d79af90ef9417d02010a6c83f968d..8ef7fdba7517d04e911946a1245265f37763423d 100644 GIT binary patch literal 84266 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 EAHv`*UH||9 literal 47332 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 ZZrg}M^#5D;vRv%h^xxUKKk1#8{|64Hy1W1Y diff --git a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml index daf0dbdde..964df0cd2 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml @@ -1,7 +1,10 @@ id = "misbehave" name = "Misbehave" -version = "0.0.1" -description = "Test fixture: traps, spins, allocates, probes permissions." -openidea-api = "0.1" +version = "0.0.2" +description = "Test fixture: traps, spins, allocates, probes capabilities." +engines.openidea = "^1.0" wasm = "dist/plugin.wasm" -permissions = ["project-read"] +activation = ["on-command:*"] + +[capabilities] +required = ["project.read"] diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 8f4ad0714..dd9fba2a6 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -1,25 +1,37 @@ //! Test fixture for the ide-plugin sandbox. Never shipped. wit_bindgen::generate!({ - world: "plugin", + world: "plugin-syntax", path: "../../../../../wit", }); -use openidea::plugin::host; +use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token}; +use exports::openidea::plugin::{commands, event_handler, lifecycle}; +use openidea::plugin::{contributions, invocation::Invocation, types}; struct Misbehave; -impl Guest for Misbehave { - fn activate(_ctx: Context) -> Result<(), String> { +fn err(e: types::Error) -> String { + format!("{e:?}") +} + +impl lifecycle::Guest for Misbehave { + fn activate(ctx: types::Context) -> Result<(), String> { + // A deliberately broken build (rollback tests): `version = "…-broken"`. + if ctx.plugin_version.ends_with("-broken") { + return Err("this build is broken".into()); + } for id in ["echo", "panic", "spin", "alloc", "read", "root", "insert", "language", "theme"] { - host::register_command(id, id, None); + contributions::register_command(id, id, None).map_err(err)?; } Ok(()) } fn deactivate() {} +} - fn run_command(id: String, arg: Option) -> Result, String> { +impl commands::Guest for Misbehave { + fn run(id: String, ctx: &Invocation, arg: Option) -> Result, String> { match id.as_str() { "echo" => Ok(arg), "panic" => panic!("boom"), @@ -36,18 +48,54 @@ impl Guest for Misbehave { std::hint::black_box(&chunks); } } - "read" => host::read_file(&arg.unwrap_or_default()).map(Some), - "root" => Ok(host::workspace_root()), - // Not permitted by the manifest: traps. - "insert" => { - host::insert_text("x"); - Ok(None) + "read" => { + let project = ctx.project().ok_or("no project is open")?; + project.read_file(&arg.unwrap_or_default()).map(Some).map_err(err) } - "language" => host::register_language(&arg.unwrap_or_default()).map(|_| None), - "theme" => host::register_theme(&arg.unwrap_or_default()).map(|_| None), + "root" => ctx.project().map(|p| p.root().map_err(err)).transpose(), + // `editor.write` is not granted: an error, not a trap. + "insert" => { + let editor = ctx.editor().ok_or("no editor")?; + editor.apply_edits(0, &[]).map(|_| None).map_err(err) + } + "language" => contributions::register_language(&arg.unwrap_or_default()).map(|_| None).map_err(err), + "theme" => contributions::register_theme(&arg.unwrap_or_default()).map(|_| None).map_err(err), other => Err(format!("unknown command: {other}")), } } } +impl event_handler::Guest for Misbehave { + fn handle(_events: Vec, _ctx: &Invocation) {} +} + +/// Edit-path budgets: the text says how to behave. `sleep:` busy-waits +/// that long, `spin` never returns, `panic` traps; anything else is one +/// keyword token over the whole text. +impl syntax_provider::Guest for Misbehave { + fn highlights(_language: String, text: String) -> Vec { + if let Some(ms) = text.strip_prefix("sleep:").and_then(|n| n.trim().parse::().ok()) { + let until = std::time::Instant::now() + std::time::Duration::from_millis(ms); + while std::time::Instant::now() < until { + std::hint::black_box(0); + } + } else if text == "spin" { + loop { + std::hint::black_box(0); + } + } else if text == "panic" { + panic!("highlight boom"); + } + vec![Token { start: 0, end: text.len() as u32, kind: "keyword".into() }] + } + + fn outline(_language: String, _text: String) -> Vec { + Vec::new() + } + + fn folding(_language: String, _text: String) -> Vec { + Vec::new() + } +} + export!(Misbehave); diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/.gitignore b/crates/ide-plugin/tests/fixtures/p0-probe/.gitignore new file mode 100644 index 000000000..a82021dbd --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/.gitignore @@ -0,0 +1,2 @@ +target/ +!dist/ diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.lock b/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.lock new file mode 100644 index 000000000..1cbb372cd --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.lock @@ -0,0 +1,326 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-p0-probe" +version = "0.0.1" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.toml b/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.toml new file mode 100644 index 000000000..41c48f07b --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "openidea-p0-probe" +version = "0.0.1" +edition = "2024" +publish = false + +# Standalone: not a member of the IDE workspace (different target). +[workspace] + +[lib] +crate-type = ["cdylib"] + +[features] +# Builds the `probe-full` world (with the optional exports); default is `probe-min`. +full = [] + +[dependencies] +wit-bindgen = "0.63" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/build.sh b/crates/ide-plugin/tests/fixtures/p0-probe/build.sh new file mode 100755 index 000000000..18ed6f7ba --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Builds dist/min.wasm (probe-min) and dist/full.wasm (probe-full). +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +export CARGO_TARGET_DIR="$here/target" +mkdir -p "$here/dist" +cargo build --manifest-path "$here/Cargo.toml" --release --target wasm32-wasip2 +cp "$CARGO_TARGET_DIR/wasm32-wasip2/release/openidea_p0_probe.wasm" "$here/dist/min.wasm" +cargo build --manifest-path "$here/Cargo.toml" --release --target wasm32-wasip2 --features full +cp "$CARGO_TARGET_DIR/wasm32-wasip2/release/openidea_p0_probe.wasm" "$here/dist/full.wasm" +ls -l "$here/dist" diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/dist/full.wasm b/crates/ide-plugin/tests/fixtures/p0-probe/dist/full.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a7b7215bf93375fb2233953642e7cce263cff755 GIT binary patch literal 56940 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 literal 0 HcmV?d00001 diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/dist/min.wasm b/crates/ide-plugin/tests/fixtures/p0-probe/dist/min.wasm new file mode 100644 index 0000000000000000000000000000000000000000..ac9499beafbb30e0703ff5f824dbbd114fe2f99c GIT binary patch literal 54275 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 mn$_H~cHZC~GV;5xqm_z?G5G&2i&$Kb literal 0 HcmV?d00001 diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/src/lib.rs b/crates/ide-plugin/tests/fixtures/p0-probe/src/lib.rs new file mode 100644 index 000000000..f12996fa6 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/src/lib.rs @@ -0,0 +1,68 @@ +//! P0 probe guest. Built twice (see `build.sh`): `probe-min` and, with +//! `--features full`, `probe-full` with the optional exports. + +#[cfg(feature = "full")] +wit_bindgen::generate!({ world: "probe-full", path: "wit", generate_all }); +#[cfg(not(feature = "full"))] +wit_bindgen::generate!({ world: "probe-min", path: "wit", generate_all }); + +use exports::openidea::probe::base; +use openidea::probe::host_api; +use std::sync::atomic::{AtomicU32, Ordering}; + +static CALLS: AtomicU32 = AtomicU32::new(0); + +struct Probe; + +impl base::Guest for Probe { + fn run(cmd: String, arg: String) -> Result { + let n = CALLS.fetch_add(1, Ordering::Relaxed) + 1; + match cmd.as_str() { + "echo" => Ok(arg), + // Instance state survives between calls (and is lost with the instance). + "count" => Ok(n.to_string()), + "spin" => { + let mut x: u64 = 0; + loop { + x = std::hint::black_box(x.wrapping_add(1)); + } + } + "sleep" => { + host_api::sleep_ms(arg.parse().map_err(|_| "ms")?); + Ok("slept".into()) + } + // Streams items pushed by the host until it cancels. + "task" => { + let task = host_api::current_task(); + let mut got = Vec::new(); + loop { + task.next_ready().block(); + if task.is_cancelled() { + return Ok(format!("cancelled after {}", got.join(","))); + } + if let Some(item) = task.take() { + got.push(item); + task.report(got.len() as u32, None); + } + } + } + other => Err(format!("unknown: {other}")), + } + } +} + +#[cfg(feature = "full")] +impl exports::openidea::probe::code_actions::Guest for Probe { + fn actions(path: String) -> Vec { + vec![format!("fix:{path}")] + } +} + +#[cfg(feature = "full")] +impl exports::openidea::probe::completion::Guest for Probe { + fn complete(prefix: String) -> Vec { + vec![format!("{prefix}1"), format!("{prefix}2")] + } +} + +export!(Probe); diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/wit/deps/io/poll.wit b/crates/ide-plugin/tests/fixtures/p0-probe/wit/deps/io/poll.wit new file mode 100644 index 000000000..2c81ea016 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/wit/deps/io/poll.wit @@ -0,0 +1,11 @@ +// Subset of wasi:io@0.2.0 (the host links its full 0.2.x implementation). +package wasi:io@0.2.0; + +interface poll { + resource pollable { + ready: func() -> bool; + block: func(); + } + + poll: func(in: list>) -> list; +} diff --git a/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit b/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit new file mode 100644 index 000000000..d3cbece56 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit @@ -0,0 +1,59 @@ +/// P0 probe (docs/plugin_p0.md): optional exports, async calls and +/// cancellation, and a WASI p2 task model built on `wasi:io/poll`. +/// Test fixture only; not part of the plugin API. +package openidea:probe@0.1.0; + +interface host-api { + use wasi:io/poll@0.2.0.{pollable}; + + /// A long-running job the host started for the guest. + resource task { + /// Progress report (`total` unknown while `none`). + report: func(done: u32, total: option); + /// Set by the host when the user cancels. + is-cancelled: func() -> bool; + /// Ready when the host pushed the next item, or on cancellation. + next-ready: func() -> pollable; + /// The pushed item, if any (non-blocking). + take: func() -> option; + } + + /// The task of the current call. + current-task: func() -> task; + + /// An async host import: sleeps without blocking the host thread. + sleep-ms: func(ms: u32); +} + +interface base { + run: func(cmd: string, arg: string) -> result; +} + +/// Optional exports: a plugin implements only the providers it needs. +interface code-actions { + actions: func(path: string) -> list; +} + +interface completion { + complete: func(prefix: string) -> list; +} + +world probe-min { + import host-api; + export base; +} + +world probe-full { + import host-api; + export base; + export code-actions; + export completion; +} + +/// The host binds this world; guests implement `probe-min` or `probe-full`. +world probe-host { + import host-api; + export base; + export code-actions; + export completion; +} diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs deleted file mode 100644 index 2c2572c03..000000000 --- a/crates/ide-plugin/tests/host.rs +++ /dev/null @@ -1,603 +0,0 @@ -//! End-to-end tests against real components. -//! -//! The components are checked in (`plugins/hello/dist/plugin.wasm`, -//! `tests/fixtures/misbehave/dist/plugin.wasm`) so tests need no wasm -//! toolchain. Rebuild them with `plugins/build.sh hello` and -//! `plugins/build.sh crates/ide-plugin/tests/fixtures/misbehave` after -//! changing their source or `wit/plugin.wit`. - -use ide_plugin::*; -use parking_lot::Mutex; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -type Events = Arc, PluginEvent)>>>; - -fn repo() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap() -} - -fn sample(name: &str) -> PathBuf { - repo().join("plugins").join(name) -} - -fn theme_template() -> PathBuf { - repo().join("templates/theme-plugin") -} - -/// A theme package with a light and a dark theme, plus licence files, in `dir`. -fn write_theme_pair(dir: &Path) { - std::fs::create_dir_all(dir.join("t")).unwrap(); - std::fs::write( - dir.join("plugin.toml"), - "id = \"theme-pair\"\nname = \"Pair\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\ - [[contributes.themes]]\npath = \"t/day.json\"\n[[contributes.themes]]\npath = \"t/night.json\"\n", - ) - .unwrap(); - std::fs::write( - dir.join("t/day.json"), - r##"{"id":"day","name":"Day","kind":"light","tokens":{"--bg-frame":"#eeeeee"}}"##, - ) - .unwrap(); - std::fs::write( - dir.join("t/night.json"), - r##"{"id":"night","name":"Night","kind":"dark","tokens":{"--bg-frame":"#111111"}}"##, - ) - .unwrap(); - std::fs::write(dir.join("LICENSE"), "MIT License\n").unwrap(); - std::fs::write(dir.join("NOTICE"), "Palette notice\n").unwrap(); -} - -fn misbehave_pkg() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/misbehave") -} - -struct Fixture { - host: PluginHost, - events: Events, - _tmp: tempfile::TempDir, - plugins_dir: PathBuf, - project: PathBuf, -} - -fn fixture(limits: Limits) -> Fixture { - let tmp = tempfile::tempdir().unwrap(); - let plugins_dir = tmp.path().join("plugins"); - let project = tmp.path().join("project"); - std::fs::create_dir_all(project.join("src")).unwrap(); - std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap(); - std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap(); - let project = project.canonicalize().unwrap(); - let events: Events = Arc::default(); - let sink = { - let events = events.clone(); - move |client: Option<&str>, e: PluginEvent| events.lock().push((client.map(str::to_owned), e)) - }; - let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), limits).unwrap(); - Fixture { host, events, _tmp: tmp, plugins_dir, project } -} - -impl Fixture { - fn inv(&self) -> Invocation { - Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), buffer_text: None } - } - - fn run(&self, plugin: &str, cmd: &str, arg: Option<&str>) -> Result, String> { - self.host.run_command(plugin, cmd, arg, self.inv()).map_err(|e| e.message) - } - - fn status(&self, id: &str) -> PluginStatus { - self.host.info_of(id).unwrap().status - } - - fn take_events(&self) -> Vec<(Option, PluginEvent)> { - std::mem::take(&mut *self.events.lock()) - } -} - -#[test] -fn hello_plugin_commands() { - let f = fixture(Limits::default()); - let info = f.host.install_from_dir(&sample("hello")).unwrap(); - assert_eq!(info.status, PluginStatus::Active, "{:?}", info.error); - assert!(f.plugins_dir.join("hello/plugin.toml").is_file()); - assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file()); - let titles: Vec<_> = info.commands.iter().map(|c| c.title.as_str()).collect(); - assert_eq!(titles, ["Hello: Insert Date", "Hello: Word Count of Active File"]); - f.take_events(); - - // Insert Date: returns YYYY-MM-DD and asks the invoking window to insert it. - let date = f.run("hello", "insert-date", None).unwrap().unwrap(); - assert!(date.len() == 10 && date.as_bytes()[4] == b'-' && date.starts_with("20"), "{date}"); - let events = f.take_events(); - assert!(events.iter().any(|(client, e)| client.as_deref() == Some("main") - && *e == PluginEvent::InsertText { plugin_id: "hello".into(), text: date.clone() })); - - // Word Count needs an active file. - assert!(f.run("hello", "word-count", None).unwrap_err().contains("no file is open")); - f.host.set_active_file("main", Some(f.project.join("src/notes.txt").to_string_lossy().into_owned())); - let summary = f.run("hello", "word-count", None).unwrap().unwrap(); - assert!(summary.starts_with("5 words, 2 lines"), "{summary}"); - assert!(f.take_events().iter().any(|(_, e)| matches!(e, - PluginEvent::Notification { title, level: NotificationLevel::Info, .. } if title == "Word count: notes.txt"))); - - // Unsaved buffer contents win over disk. - let mut inv = f.inv(); - inv.buffer_text = Some(Arc::new(|_: &Path| Some("just three words".to_owned()))); - let summary = f.host.run_command("hello", "word-count", None, inv).unwrap().unwrap(); - assert!(summary.starts_with("3 words"), "{summary}"); - - // Guest errors don't disable the plugin. - assert!(f.run("hello", "nope", None).unwrap_err().contains("unknown command")); - assert_eq!(f.status("hello"), PluginStatus::Active); -} - -#[test] -fn read_file_is_confined_to_the_project() { - let f = fixture(Limits::default()); - f.host.install_from_dir(&misbehave_pkg()).unwrap(); - assert_eq!(f.run("misbehave", "read", Some("src/notes.txt")).unwrap().unwrap(), "one two three\nfour five\n"); - let abs = f.project.join("src/notes.txt"); - assert!(f.run("misbehave", "read", Some(abs.to_str().unwrap())).is_ok()); - let err = f.run("misbehave", "read", Some("../secret.txt")).unwrap_err(); - assert!(err.contains("outside the project root"), "{err}"); - let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err(); - assert!(err.contains("outside the project root"), "{err}"); - assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err()); - // Without a project there is nothing to read. - let inv = Invocation { client: None, workspace_root: None, buffer_text: None }; - let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message; - assert!(err.contains("no project is open"), "{err}"); - assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy()); - assert_eq!(f.status("misbehave"), PluginStatus::Active); -} - -#[test] -fn undeclared_permission_traps_and_disables() { - let f = fixture(Limits::default()); - f.host.install_from_dir(&misbehave_pkg()).unwrap(); - let err = f.run("misbehave", "insert", None).unwrap_err(); - assert!(err.contains("permission denied") && err.contains("editor-write"), "{err}"); - assert_eq!(f.status("misbehave"), PluginStatus::Failed); - assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::InsertText { .. }))); -} - -#[test] -fn panic_is_isolated_and_plugin_can_be_restarted() { - let f = fixture(Limits::default()); - f.host.install_from_dir(&sample("hello")).unwrap(); - f.host.install_from_dir(&misbehave_pkg()).unwrap(); - f.take_events(); - - let err = f.run("misbehave", "panic", None).unwrap_err(); - assert!(err.contains("crashed"), "{err}"); - assert!(err.contains("boom"), "stderr panic message is surfaced: {err}"); - let info = f.host.info_of("misbehave").unwrap(); - assert_eq!(info.status, PluginStatus::Failed); - assert!(info.enabled, "failure is per session, not persisted"); - let events = f.take_events(); - assert!(events.iter().any(|(client, e)| client.as_deref() == Some("main") - && matches!(e, PluginEvent::Notification { level: NotificationLevel::Error, title, .. } if title.contains("was disabled")))); - assert!(events.iter().any(|(_, e)| *e == PluginEvent::CommandsChanged)); - - assert!(f.run("misbehave", "echo", Some("x")).unwrap_err().contains("not running")); - // Other plugins are unaffected. - assert!(f.run("hello", "insert-date", None).is_ok()); - - // Toggling restarts it with a fresh instance. - f.host.set_enabled("misbehave", true).unwrap(); - assert_eq!(f.status("misbehave"), PluginStatus::Active); - assert_eq!(f.run("misbehave", "echo", Some("back")).unwrap().as_deref(), Some("back")); -} - -#[test] -fn fuel_exhaustion_traps() { - let f = fixture(Limits { fuel: 50_000_000, timeout: Duration::from_secs(30), ..Limits::default() }); - f.host.install_from_dir(&misbehave_pkg()).unwrap(); - let err = f.run("misbehave", "spin", None).unwrap_err(); - assert!(err.contains("CPU budget"), "{err}"); - assert_eq!(f.status("misbehave"), PluginStatus::Failed); -} - -#[test] -fn wall_clock_timeout_traps() { - let f = fixture(Limits { fuel: u64::MAX, timeout: Duration::from_millis(200), ..Limits::default() }); - f.host.install_from_dir(&misbehave_pkg()).unwrap(); - let started = std::time::Instant::now(); - let err = f.run("misbehave", "spin", None).unwrap_err(); - assert!(err.contains("timed out"), "{err}"); - assert!(started.elapsed() < Duration::from_secs(5)); -} - -#[test] -fn memory_limit_traps() { - let f = fixture(Limits { memory_bytes: 32 << 20, ..Limits::default() }); - f.host.install_from_dir(&misbehave_pkg()).unwrap(); - let err = f.run("misbehave", "alloc", None).unwrap_err(); - assert!(err.contains("memory"), "{err}"); - assert_eq!(f.status("misbehave"), PluginStatus::Failed); -} - -#[test] -fn runtime_language_and_theme_registration() { - let f = fixture(Limits::default()); - f.host.install_from_dir(&misbehave_pkg()).unwrap(); - let lang = "id = \"zz\"\nname = \"ZZ\"\nextensions = [\"zz\"]"; - f.run("misbehave", "language", Some(lang)).unwrap(); - assert!(f.run("misbehave", "language", Some("not toml [")).unwrap_err().contains("invalid language")); - assert_eq!(f.host.languages().iter().map(|l| l.id.to_string()).collect::>(), ["zz"]); - - let theme = r##"{"id":"t1","name":"T1","kind":"light","tokens":{"--bg-app":"#fff"}}"##; - f.run("misbehave", "theme", Some(theme)).unwrap(); - assert!( - f.run("misbehave", "theme", Some(r#"{"id":"t2","name":"x","kind":"dark","tokens":{"--a":"url(x)"}}"#)).is_err() - ); - let info = f.host.info_of("misbehave").unwrap(); - assert_eq!(info.languages[0].extensions, ["zz"]); - assert_eq!(info.themes[0].kind, ThemeKind::Light); - let active = f.host.set_theme(Some(("misbehave", "t1"))).unwrap().unwrap(); - assert_eq!(active.theme.tokens["--bg-app"], "#fff"); - assert!(f.host.set_theme(Some(("misbehave", "nope"))).is_err()); -} - -#[test] -fn contribution_only_plugins() { - let f = fixture(Limits::default()); - let theme = f.host.install_from_dir(&theme_template()).unwrap(); - assert_eq!(theme.status, PluginStatus::Static); - assert!(!theme.has_code); - let ids: Vec<&str> = theme.themes.iter().map(|t| t.id.as_str()).collect(); - assert_eq!(ids, ["harbor-night"]); - let lang = f.host.install_from_dir(&sample("language-toy")).unwrap(); - assert_eq!(lang.status, PluginStatus::Static); - assert_eq!(lang.languages[0].id, "toy"); - assert_eq!(f.host.languages()[0].block_comment, Some(("(*".into(), "*)".into()))); - assert!(f.run("language-toy", "x", None).unwrap_err().contains("no code")); - - f.take_events(); - let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap(); - assert_eq!(active.theme.tokens["--bg-app"], "#14171c"); - assert_eq!(active.theme.tokens["--syn-string"], "#9ccc7a"); - assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ThemeChanged { theme: Some(_) }))); - assert_eq!(f.host.list(None).active_theme, Some(active)); - - // Disabling the plugin withdraws its theme and language. - f.host.set_enabled("theme-harbor-night", false).unwrap(); - assert!(f.host.active_theme().is_none()); - assert!(f.take_events().iter().any(|(_, e)| *e == PluginEvent::ThemeChanged { theme: None })); - f.host.set_enabled("language-toy", false).unwrap(); - assert!(f.host.languages().is_empty()); - assert_eq!(f.status("language-toy"), PluginStatus::Disabled); -} - -#[test] -fn theme_template_installs_with_its_editor_scheme() { - let f = fixture(Limits::default()); - let info = f.host.install_from_dir(&theme_template()).unwrap(); - assert_eq!(info.status, PluginStatus::Static); - assert!(!info.has_code); - assert_eq!(info.themes[0].id, "harbor-night"); - assert_eq!(info.themes[0].kind, ThemeKind::Dark); - // The scheme file is copied with the package and merged over the UI tokens. - assert!(f.plugins_dir.join("theme-harbor-night/themes/harbor-night.scheme.json").is_file()); - let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap(); - let tokens = &active.theme.tokens; - assert!( - tokens.contains_key("--bg-app") && tokens.contains_key("--syn-keyword") && tokens.contains_key("--ansi-red") - ); - assert!(tokens.len() > 150); -} - -#[test] -fn theme_packages_install_with_their_licence() { - let f = fixture(Limits::default()); - let dir = tempfile::tempdir().unwrap(); - write_theme_pair(dir.path()); - let info = f.host.install_from_dir(dir.path()).unwrap(); - assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); - let installed = f.plugins_dir.join("theme-pair"); - assert!(std::fs::read_to_string(installed.join("LICENSE")).unwrap().contains("MIT License")); - assert!(installed.join("NOTICE").is_file()); - for (theme, kind) in [("day", ThemeKind::Light), ("night", ThemeKind::Dark)] { - let active = f.host.set_theme(Some(("theme-pair", theme))).unwrap().unwrap(); - assert_eq!(active.theme.kind, kind); - } -} - -#[test] -fn theme_frame_follows_the_selection() { - let f = fixture(Limits::default()); - let dir = tempfile::tempdir().unwrap(); - write_theme_pair(dir.path()); - f.host.install_from_dir(dir.path()).unwrap(); - // Nothing to attach it to without a selected theme. - f.host.set_theme_frame(Some("#111111")).unwrap(); - assert_eq!(f.host.startup_frame(), None); - f.host.set_theme(Some(("theme-pair", "night"))).unwrap(); - assert!(f.host.set_theme_frame(Some("hsl(220, 13%, 15%)")).is_err()); - f.host.set_theme_frame(Some("#111111")).unwrap(); - assert_eq!(f.host.startup_frame().as_deref(), Some("#111111")); - f.host.set_enabled("theme-pair", false).unwrap(); - assert_eq!(f.host.startup_frame(), None); - f.host.set_enabled("theme-pair", true).unwrap(); - assert_eq!(f.host.startup_frame().as_deref(), Some("#111111")); - // A new selection drops the old theme's colour until the UI reports the new one. - f.host.set_theme(Some(("theme-pair", "day"))).unwrap(); - assert_eq!(f.host.startup_frame(), None); - f.host.set_theme_frame(Some("#eeeeee")).unwrap(); - f.host.set_theme(None).unwrap(); - assert_eq!(f.host.startup_frame(), None); -} - -#[test] -fn theme_with_broken_editor_scheme_fails_to_load() { - let f = fixture(Limits::default()); - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir(dir.path().join("t")).unwrap(); - std::fs::write( - dir.path().join("plugin.toml"), - "id = \"bad-scheme\"\nname = \"B\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\ - [[contributes.themes]]\npath = \"t/a.json\"\neditor-scheme = \"t/a.scheme.json\"\n", - ) - .unwrap(); - std::fs::write(dir.path().join("t/a.json"), r##"{"id":"a","name":"A","kind":"light","tokens":{}}"##).unwrap(); - std::fs::write(dir.path().join("t/a.scheme.json"), "{\n \"tokens\": { \"--caret\": \"url(x)\" }\n}").unwrap(); - let info = f.host.install_from_dir(dir.path()).unwrap(); - assert_eq!(info.status, PluginStatus::Failed); - assert!(info.error.unwrap_or_default().contains("a.scheme.json")); -} - -#[test] -fn state_persists_across_restarts() { - let f = fixture(Limits::default()); - f.host.install_from_dir(&sample("hello")).unwrap(); - f.host.install_from_dir(&theme_template()).unwrap(); - f.host.set_enabled("hello", false).unwrap(); - f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap(); - f.host.set_theme_frame(Some("#1f252e")).unwrap(); - - let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); - // Known before the plugins are loaded: new windows open in the theme's frame colour. - assert_eq!(again.startup_frame().as_deref(), Some("#1f252e")); - assert!(!again.wait_initial_load(Duration::from_millis(1))); - again.load_all(); - assert!(again.wait_initial_load(Duration::ZERO)); - let list = again.list(None); - let hello = list.plugins.iter().find(|p| p.id == "hello").unwrap(); - assert!(!hello.enabled); - assert_eq!(hello.status, PluginStatus::Disabled); - assert_eq!(list.active_theme.unwrap().theme.id, "harbor-night"); - assert!( - again - .run_command("hello", "insert-date", None, Invocation::default()) - .unwrap_err() - .message - .contains("disabled") - ); -} - -#[test] -fn dev_plugins_follow_the_project() { - let f = fixture(Limits::default()); - f.host.set_project_policy(Arc::new(|_| true)); - let dev = f.project.join(DEV_PLUGINS_DIR).join("toy"); - copy_dir(&sample("language-toy"), &dev); - f.host.sync_project(&f.project); - let mine = f.host.list(Some(&f.project)); - assert_eq!(mine.plugins.len(), 1); - assert_eq!(mine.plugins[0].source, PluginSource::Dev); - assert!(f.host.list(None).plugins.is_empty(), "dev plugins only show for their project"); - assert!(f.host.uninstall("language-toy").is_err()); - assert!(f.host.install_from_dir(&sample("language-toy")).is_err(), "id clash with a dev plugin"); - - // Edit + reload picks up changes. - let manifest = dev.join("plugin.toml"); - let src = std::fs::read_to_string(&manifest).unwrap().replace("Toy Language", "Toy Language (dev)"); - std::fs::write(&manifest, src).unwrap(); - assert_eq!(f.host.reload("language-toy").unwrap().name, "Toy Language (dev)"); - - std::fs::remove_dir_all(&dev).unwrap(); - f.host.sync_project(&f.project); - assert!(f.host.list(Some(&f.project)).plugins.is_empty()); -} - -#[test] -fn install_validation_and_uninstall() { - let f = fixture(Limits::default()); - let tmp = tempfile::tempdir().unwrap(); - - // Missing component file. - let pkg = tmp.path().join("broken"); - std::fs::create_dir_all(&pkg).unwrap(); - std::fs::write( - pkg.join("plugin.toml"), - "id = \"b\"\nname = \"B\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\nwasm = \"x.wasm\"", - ) - .unwrap(); - assert!(f.host.install_from_dir(&pkg).unwrap_err().message.contains("missing")); - // Not a component. - std::fs::write(pkg.join("x.wasm"), b"\0asm garbage").unwrap(); - assert!(f.host.install_from_dir(&pkg).unwrap_err().message.contains("not a valid plugin component")); - // Bad manifest. - std::fs::write(pkg.join("plugin.toml"), "id = \"B!\"").unwrap(); - assert!(f.host.install_from_dir(&pkg).is_err()); - assert!(f.host.list(None).plugins.is_empty(), "failed installs leave nothing behind"); - - // A broken package already in the plugins dir shows up as failed. - std::fs::create_dir_all(f.plugins_dir.join("junk")).unwrap(); - std::fs::write(f.plugins_dir.join("junk/plugin.toml"), "garbage = ").unwrap(); - f.host.load_all(); - let junk = &f.host.list(None).plugins[0]; - assert_eq!(junk.status, PluginStatus::Failed); - assert!(junk.error.is_some()); - - // Reinstall replaces; uninstall removes the directory. - f.host.install_from_dir(&sample("hello")).unwrap(); - f.host.install_from_dir(&sample("hello")).unwrap(); - assert_eq!(f.status("hello"), PluginStatus::Active); - f.host.uninstall("hello").unwrap(); - assert!(!f.plugins_dir.join("hello").exists()); - assert!(f.host.info_of("hello").is_err()); -} - -fn copy_dir(from: &Path, to: &Path) { - std::fs::create_dir_all(to).unwrap(); - for entry in std::fs::read_dir(from).unwrap().flatten() { - let (src, dst) = (entry.path(), to.join(entry.file_name())); - if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() } - } -} - -#[test] -fn catalog_lists_sample_packages() { - let tmp = tempfile::tempdir().unwrap(); - let broken = tmp.path().join("broken"); - std::fs::create_dir_all(&broken).unwrap(); - std::fs::write( - broken.join("plugin.toml"), - "id = \"x\"\nname = \"X\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n", - ) - .unwrap(); - std::fs::create_dir_all(tmp.path().join("junk")).unwrap(); - std::fs::write(tmp.path().join("junk/plugin.toml"), "garbage = ").unwrap(); - - let catalog = scan_catalog(&[repo().join("plugins"), repo().join("templates"), tmp.path().to_path_buf()]); - let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect(); - for id in ["hello", "theme-harbor-night", "language-toy", "x"] { - assert!(ids.contains(&id), "{id} missing from {ids:?}"); - } - let hello = catalog.iter().find(|c| c.id == "hello").unwrap(); - assert!(hello.has_code && hello.problem.is_none() && hello.change_notes.is_some()); - assert_eq!(hello.commands.len(), 2); - let theme = catalog.iter().find(|c| c.id == "theme-harbor-night").unwrap(); - assert_eq!((theme.theme_count, theme.has_code), (1, false)); - let x = catalog.iter().find(|c| c.id == "x").unwrap(); - assert!(x.problem.as_deref().unwrap().contains("plugin.wasm")); -} - -#[test] -fn restricted_dev_plugin_has_no_activation_registration() { - let f = fixture(Limits::default()); - let dev = f.project.join(DEV_PLUGINS_DIR).join("hello"); - copy_dir(&sample("hello"), &dev); - let manifest = dev.join("plugin.toml"); - let text = std::fs::read_to_string(&manifest).unwrap(); - std::fs::write(&manifest, text.split("[[contributes.commands]]").next().unwrap()).unwrap(); - assert!(PluginManifest::load(&dev).unwrap().contributes.commands.is_empty()); - let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let policy = trusted.clone(); - f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire))); - f.host.sync_project(&f.project); - let untrusted = f.host.list(Some(&f.project)); - assert!( - !untrusted.plugins.iter().flat_map(|plugin| &plugin.commands).any(|command| command.id == "word-count"), - "untrusted development plugin registered its activation witness" - ); - assert!(untrusted.plugins.is_empty()); - assert!(f.host.info_of("hello").is_err()); - - // Positive control: activation itself registers these commands, not the manifest. - trusted.store(true, std::sync::atomic::Ordering::Release); - f.host.sync_project(&f.project); - let info = f.host.info_of("hello").unwrap(); - assert_eq!(info.status, PluginStatus::Active); - assert!(info.commands.iter().any(|c| c.id == "word-count")); - trusted.store(false, std::sync::atomic::Ordering::Release); - assert!(f.host.reload("hello").is_err()); - assert!(f.host.set_enabled("hello", true).is_err()); - assert!(f.run("hello", "word-count", None).is_err()); - f.host.sync_project(&f.project); - assert!(f.host.list(Some(&f.project)).plugins.is_empty()); -} - -/// Zips the files of `dir` under `prefix` (recursively). -fn add_dir(zip: &mut zip::ZipWriter, dir: &Path, prefix: &str) { - for entry in std::fs::read_dir(dir).unwrap().flatten() { - let name = format!("{prefix}{}", entry.file_name().to_string_lossy()); - if entry.path().is_dir() { - add_dir(zip, &entry.path(), &format!("{name}/")); - } else { - zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - zip.write_all(&std::fs::read(entry.path()).unwrap()).unwrap(); - } - } -} - -fn zip_of(dir: &Path, prefix: &str, out: &Path) -> PathBuf { - let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap()); - add_dir(&mut zip, dir, prefix); - zip.finish().unwrap(); - out.to_path_buf() -} - -#[test] -fn installs_zips_with_the_package_at_the_root_or_in_one_folder() { - let f = fixture(Limits::default()); - let tmp = tempfile::tempdir().unwrap(); - for (name, prefix) in [("root.ZIP", ""), ("folder.zip", "hello-1.0/")] { - let zip = zip_of(&sample("hello"), prefix, &tmp.path().join(name)); - let info = f.host.install_from_path(&zip).unwrap(); - assert_eq!((info.id.as_str(), info.status), ("hello", PluginStatus::Active), "{:?}", info.error); - assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file()); - } - // Still runs from the installed copy, not the unpacked archive. - assert!(f.run("hello", "insert-date", None).unwrap().is_some()); -} - -#[test] -fn installs_from_a_manifest_or_a_folder() { - let f = fixture(Limits::default()); - assert_eq!(f.host.install_from_path(&sample("hello").join("plugin.toml")).unwrap().id, "hello"); - assert_eq!(f.host.install_from_path(&sample("hello")).unwrap().id, "hello"); -} - -#[test] -fn ignores_archiver_metadata_next_to_the_package_folder() { - let f = fixture(Limits::default()); - let tmp = tempfile::tempdir().unwrap(); - let out = tmp.path().join("mac.zip"); - let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap()); - add_dir(&mut zip, &sample("hello"), "hello/"); - zip.start_file("__MACOSX/hello/._plugin.toml", zip::write::SimpleFileOptions::default()).unwrap(); - zip.write_all(b"metadata").unwrap(); - zip.finish().unwrap(); - assert_eq!(f.host.install_from_path(&out).unwrap().id, "hello"); -} - -#[test] -fn refuses_zips_that_are_not_safe_plugin_packages() { - let f = fixture(Limits::default()); - let tmp = tempfile::tempdir().unwrap(); - let options = zip::write::SimpleFileOptions::default; - - let no_manifest = tmp.path().join("idea-plugin.zip"); - let mut zip = zip::ZipWriter::new(std::fs::File::create(&no_manifest).unwrap()); - zip.start_file("idea-plugin/lib/plugin.jar", options()).unwrap(); - zip.finish().unwrap(); - let error = f.host.install_from_path(&no_manifest).unwrap_err(); - assert!(error.message.contains("no plugin.toml"), "{}", error.message); - - let escape = tmp.path().join("escape.zip"); - let mut zip = zip::ZipWriter::new(std::fs::File::create(&escape).unwrap()); - zip.start_file("../../evil.txt", options()).unwrap(); - zip.write_all(b"evil").unwrap(); - zip.finish().unwrap(); - let error = f.host.install_from_path(&escape).unwrap_err(); - assert!(error.message.contains("unsafe path"), "{}", error.message); - - let link = tmp.path().join("link.zip"); - let mut zip = zip::ZipWriter::new(std::fs::File::create(&link).unwrap()); - zip.add_symlink("plugin.toml", "/etc/passwd", options()).unwrap(); - zip.finish().unwrap(); - let error = f.host.install_from_path(&link).unwrap_err(); - assert!(error.message.contains("symbolic link"), "{}", error.message); - - let garbage = tmp.path().join("garbage.zip"); - std::fs::write(&garbage, "not a zip").unwrap(); - let error = f.host.install_from_path(&garbage).unwrap_err(); - assert!(error.message.contains("not a valid plugin archive"), "{}", error.message); - - assert!(!f.plugins_dir.join("hello").exists()); -} diff --git a/crates/ide-plugin/tests/p0_probe.rs b/crates/ide-plugin/tests/p0_probe.rs new file mode 100644 index 000000000..11026d12b --- /dev/null +++ b/crates/ide-plugin/tests/p0_probe.rs @@ -0,0 +1,352 @@ +//! P0 experiments (docs/plugin_p0.md), kept as regression tests: they pin the +//! wasmtime behaviour the plugin runtime is designed around. +//! +//! Components: `tests/fixtures/p0-probe/dist/{min,full}.wasm`, rebuilt with +//! `tests/fixtures/p0-probe/build.sh`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; +use wasmtime::component::{Component, HasSelf, Linker, Resource, ResourceTable}; +use wasmtime::{Config, Engine, Store}; +use wasmtime_wasi::p2::{DynPollable, Pollable, subscribe}; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; + +mod bindings { + wasmtime::component::bindgen!({ + world: "probe-host", + path: "tests/fixtures/p0-probe/wit", + imports: { + "openidea:probe/host-api.sleep-ms": async | trappable, + default: trappable, + }, + exports: { default: async }, + with: { + "wasi:io/poll": wasmtime_wasi::p2::bindings::io::poll, + "openidea:probe/host-api.task": super::Task, + }, + }); +} + +use bindings::openidea::probe::host_api; + +/// Host side of the `task` resource: a queue the host pushes into. +pub struct Task { + queue: Arc>>, + cancelled: Arc, + notify: Arc, + progress: Arc, +} + +/// The pollable of `next-ready`: ready once an item is queued or the task is cancelled. +struct NextReady { + queue: Arc>>, + cancelled: Arc, + notify: Arc, +} + +#[wasmtime_wasi::async_trait] +impl Pollable for NextReady { + async fn ready(&mut self) { + loop { + let notified = self.notify.notified(); + if self.cancelled.load(Ordering::Acquire) || !self.queue.lock().is_empty() { + return; + } + notified.await; + } + } +} + +#[derive(Clone, Default)] +struct TaskHandle { + queue: Arc>>, + cancelled: Arc, + notify: Arc, + progress: Arc, +} + +impl TaskHandle { + fn push(&self, item: &str) { + self.queue.lock().push(item.to_owned()); + self.notify.notify_waiters(); + } + fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + self.notify.notify_waiters(); + } +} + +struct Data { + wasi: WasiCtx, + table: ResourceTable, + task: TaskHandle, +} + +impl WasiView for Data { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { ctx: &mut self.wasi, table: &mut self.table } + } +} + +impl host_api::HostTask for Data { + fn report(&mut self, task: Resource, done: u32, _total: Option) -> wasmtime::Result<()> { + self.table.get(&task)?.progress.store(u64::from(done), Ordering::Release); + Ok(()) + } + fn is_cancelled(&mut self, task: Resource) -> wasmtime::Result { + Ok(self.table.get(&task)?.cancelled.load(Ordering::Acquire)) + } + fn next_ready(&mut self, task: Resource) -> wasmtime::Result> { + let t = self.table.get(&task)?; + let ready = NextReady { queue: t.queue.clone(), cancelled: t.cancelled.clone(), notify: t.notify.clone() }; + let r = self.table.push(ready)?; + subscribe(&mut self.table, r) + } + fn take(&mut self, task: Resource) -> wasmtime::Result> { + let mut q = self.table.get(&task)?.queue.lock(); + Ok(if q.is_empty() { None } else { Some(q.remove(0)) }) + } + fn drop(&mut self, task: Resource) -> wasmtime::Result<()> { + self.table.delete(task)?; + Ok(()) + } +} + +impl host_api::Host for Data { + fn current_task(&mut self) -> wasmtime::Result> { + let h = self.task.clone(); + Ok(self.table.push(Task { queue: h.queue, cancelled: h.cancelled, notify: h.notify, progress: h.progress })?) + } + + async fn sleep_ms(&mut self, ms: u32) -> wasmtime::Result<()> { + tokio::time::sleep(Duration::from_millis(u64::from(ms))).await; + Ok(()) + } +} + +fn dist(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p0-probe/dist").join(name) +} + +struct Env { + engine: Engine, + linker: Linker, + stop: Arc, +} + +impl Drop for Env { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + } +} + +fn env() -> Env { + let mut config = Config::new(); + config.wasm_component_model(true).epoch_interruption(true).consume_fuel(true); + let engine = Engine::new(&config).unwrap(); + let mut linker = Linker::new(&engine); + wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap(); + host_api::add_to_linker::<_, HasSelf<_>>(&mut linker, |d| d).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let (weak, flag) = (engine.weak(), stop.clone()); + std::thread::spawn(move || { + while !flag.load(Ordering::Acquire) + && let Some(e) = weak.upgrade() + { + e.increment_epoch(); + drop(e); + std::thread::sleep(Duration::from_millis(5)); + } + }); + Env { engine, linker, stop } +} + +fn store(env: &Env, task: TaskHandle) -> Store { + let data = Data { wasi: WasiCtx::builder().build(), table: ResourceTable::new(), task }; + let mut store = Store::new(&env.engine, data); + store.set_fuel(u64::MAX).unwrap(); + // Yield to the executor every tick instead of trapping: long calls share the thread. + store.epoch_deadline_async_yield_and_update(1); + store +} + +const BASE: &str = "openidea:probe/base@0.1.0"; +const CODE_ACTIONS: &str = "openidea:probe/code-actions@0.1.0"; + +/// P0-1: a component that lacks optional exports still binds; presence is +/// detectable per interface, and present interfaces are callable. +#[tokio::test] +async fn optional_exports_bind_per_interface() { + let env = env(); + let min = Component::from_file(&env.engine, dist("min.wasm")).unwrap(); + let full = Component::from_file(&env.engine, dist("full.wasm")).unwrap(); + + // Presence is visible from the compiled component, before instantiation. + assert!(min.get_export_index(None, BASE).is_some()); + assert!(min.get_export_index(None, CODE_ACTIONS).is_none()); + assert!(full.get_export_index(None, CODE_ACTIONS).is_some()); + + // The whole-world binding requires every export ... + let pre_min = env.linker.instantiate_pre(&min).unwrap(); + assert!(bindings::ProbeHostPre::new(pre_min.clone()).is_err()); + + // ... but per-interface indices bind what is there. + use bindings::exports::openidea::probe::{base, code_actions, completion}; + let base_idx = base::GuestIndices::new(&pre_min).unwrap(); + assert!(code_actions::GuestIndices::new(&pre_min).is_err()); + let mut s = store(&env, TaskHandle::default()); + let inst = pre_min.instantiate_async(&mut s).await.unwrap(); + let b = base_idx.load(&mut s, &inst).unwrap(); + assert_eq!(b.call_run(&mut s, "echo", "hi").await.unwrap().unwrap(), "hi"); + + let pre_full = env.linker.instantiate_pre(&full).unwrap(); + let ca_idx = code_actions::GuestIndices::new(&pre_full).unwrap(); + let co_idx = completion::GuestIndices::new(&pre_full).unwrap(); + let mut s = store(&env, TaskHandle::default()); + let inst = pre_full.instantiate_async(&mut s).await.unwrap(); + let ca = ca_idx.load(&mut s, &inst).unwrap(); + assert_eq!(ca.call_actions(&mut s, "a.rs").await.unwrap(), ["fix:a.rs"]); + let co = co_idx.load(&mut s, &inst).unwrap(); + assert_eq!(co.call_complete(&mut s, "x").await.unwrap(), ["x1", "x2"]); +} + +/// P0-2a: with `epoch_deadline_async_yield_and_update`, a spinning guest yields, +/// so other tasks on the same (single) thread keep running. +#[tokio::test(flavor = "current_thread")] +async fn spinning_guest_yields_to_the_executor() { + let env = env(); + let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap(); + let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap(); + let mut s = store(&env, TaskHandle::default()); + let inst = pre.instantiate_async(&mut s).await.unwrap(); + let base = idx.load(&mut s, &inst).unwrap(); + + let ticks = Arc::new(AtomicU64::new(0)); + let ticker = { + let ticks = ticks.clone(); + tokio::spawn(async move { + loop { + ticks.fetch_add(1, Ordering::Relaxed); + tokio::task::yield_now().await; + } + }) + }; + let spin = base.call_run(&mut s, "spin", ""); + assert!(tokio::time::timeout(Duration::from_millis(200), spin).await.is_err(), "spin never returns"); + ticker.abort(); + assert!(ticks.load(Ordering::Relaxed) > 10, "the executor kept running other tasks while the guest spun"); +} + +/// P0-2b: dropping the call future cancels the call — and poisons the instance: +/// the next call fails with `CannotEnterComponent`. A fresh instance from the +/// same `InstancePre` is cheap (no compilation) and starts with fresh state. +#[tokio::test] +async fn cancelled_instance_cannot_be_reused_but_reinstantiates_fast() { + let env = env(); + let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap(); + let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap(); + let mut s = store(&env, TaskHandle::default()); + let inst = pre.instantiate_async(&mut s).await.unwrap(); + let base = idx.load(&mut s, &inst).unwrap(); + assert_eq!(base.call_run(&mut s, "count", "").await.unwrap().unwrap(), "1"); + + // Cancel while the guest computes. + let _ = tokio::time::timeout(Duration::from_millis(50), base.call_run(&mut s, "spin", "")).await; + let err = base.call_run(&mut s, "echo", "x").await.unwrap_err(); + assert_eq!(err.downcast_ref::(), Some(&wasmtime::Trap::CannotEnterComponent), "{err:?}"); + + // Cancel while the guest waits in an async host import: same result. + let mut s = store(&env, TaskHandle::default()); + let inst = pre.instantiate_async(&mut s).await.unwrap(); + let base = idx.load(&mut s, &inst).unwrap(); + let _ = tokio::time::timeout(Duration::from_millis(20), base.call_run(&mut s, "sleep", "5000")).await; + assert!(base.call_run(&mut s, "echo", "x").await.is_err()); + + // Re-instantiation: a new store + instance from the pre-instantiated component. + let started = Instant::now(); + let mut runs = 0; + while runs < 20 { + let mut s = store(&env, TaskHandle::default()); + let inst = pre.instantiate_async(&mut s).await.unwrap(); + let base = idx.load(&mut s, &inst).unwrap(); + assert_eq!(base.call_run(&mut s, "count", "").await.unwrap().unwrap(), "1", "state starts fresh"); + runs += 1; + } + let per = started.elapsed() / runs; + eprintln!("re-instantiate + first call: {per:?} per instance"); + assert!(per < Duration::from_millis(30), "{per:?}"); +} + +/// P0-2c: a guest that returns an error (not a trap) leaves the instance usable, +/// and an async host import does not block the thread. +#[tokio::test(flavor = "current_thread")] +async fn async_host_import_does_not_block() { + let env = env(); + let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap(); + let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap(); + let mut s = store(&env, TaskHandle::default()); + let inst = pre.instantiate_async(&mut s).await.unwrap(); + let base = idx.load(&mut s, &inst).unwrap(); + assert!(base.call_run(&mut s, "nope", "").await.unwrap().is_err()); + let other = tokio::spawn(async { tokio::time::sleep(Duration::from_millis(10)).await }); + let started = Instant::now(); + assert_eq!(base.call_run(&mut s, "sleep", "100").await.unwrap().unwrap(), "slept"); + assert!(other.is_finished(), "the other task ran during the host sleep"); + assert!(started.elapsed() >= Duration::from_millis(100)); + assert_eq!(base.call_run(&mut s, "echo", "still").await.unwrap().unwrap(), "still"); +} + +/// P0-3: streaming + cancellation on WASI p2 alone: the guest blocks on a host +/// `pollable` (which yields to the executor), consumes pushed items, reports +/// progress, and returns cleanly when the host flags cancellation — the instance +/// stays usable (cooperative cancellation, unlike dropping the future). +#[tokio::test] +async fn pollable_task_streams_and_cancels_cooperatively() { + let env = env(); + let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap(); + let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap(); + let task = TaskHandle::default(); + let mut s = store(&env, task.clone()); + let inst = pre.instantiate_async(&mut s).await.unwrap(); + let base = idx.load(&mut s, &inst).unwrap(); + + let driver = { + let task = task.clone(); + tokio::spawn(async move { + for item in ["a", "b", "c"] { + tokio::time::sleep(Duration::from_millis(10)).await; + task.push(item); + } + // Wait until the guest consumed everything, then cancel. + while task.progress.load(Ordering::Acquire) < 3 { + tokio::time::sleep(Duration::from_millis(2)).await; + } + task.cancel(); + }) + }; + let out = tokio::time::timeout(Duration::from_secs(5), base.call_run(&mut s, "task", "")).await.unwrap(); + driver.await.unwrap(); + assert_eq!(out.unwrap().unwrap(), "cancelled after a,b,c"); + assert_eq!(task.progress.load(Ordering::Acquire), 3); + assert_eq!(base.call_run(&mut s, "echo", "reusable").await.unwrap().unwrap(), "reusable"); +} + +/// Serialized compilation output loads without recompiling (P4 cache). +#[test] +fn serialized_component_round_trips() { + let env = env(); + let bytes = std::fs::read(dist("full.wasm")).unwrap(); + let t = Instant::now(); + let compiled = Component::new(&env.engine, &bytes).unwrap(); + let compile = t.elapsed(); + let blob = compiled.serialize().unwrap(); + let t = Instant::now(); + // SAFETY: the blob was produced by this engine just above. + let loaded = unsafe { Component::deserialize(&env.engine, &blob) }.unwrap(); + let load = t.elapsed(); + eprintln!("compile {compile:?}, deserialize {load:?}"); + assert!(loaded.get_export_index(None, CODE_ACTIONS).is_some()); + assert!(load < compile); +} diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 24cf12f64..c9195b404 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -40,6 +40,7 @@ pub(crate) fn detect_with_profiles( detect_make(root, &mut out); detect_just(root, &mut out); detect_godot(root, &mut out); + crate::external::detect(root, &mut out); let mut seen = HashSet::new(); out.retain(|c| seen.insert(c.id.clone())); diff --git a/crates/ide-run/src/external.rs b/crates/ide-run/src/external.rs new file mode 100644 index 000000000..0be3405d2 --- /dev/null +++ b/crates/ide-run/src/external.rs @@ -0,0 +1,93 @@ +//! Run configurations detected outside this crate: plugins' `runDetectors` +//! (docs/plugin_plan.md P3). Their ids start with [`EXTERNAL_PREFIX`]; before +//! one is started the provider names the program to run (the file approved +//! for the plugin's `process:` capability) or refuses. + +use crate::RunConfig; +use parking_lot::RwLock; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Id prefix of externally detected configurations (`plugin::`). +pub const EXTERNAL_PREFIX: &str = "plugin:"; + +pub trait ExternalDetection: Send + Sync { + /// Configurations for the project at `root`; ids start with [`EXTERNAL_PREFIX`]. + fn detect(&self, root: &Path) -> Vec; + /// The program to start for `config` (one this provider detected), or why + /// it may not start. + fn launch(&self, config: &RunConfig) -> Result; +} + +static PROVIDER: RwLock>> = RwLock::new(None); + +/// Installs (or removes) the provider [`crate::detect`] and runs consult. +pub fn set_external_detection(provider: Option>) { + *PROVIDER.write() = provider; +} + +pub(crate) fn detect(root: &Path, out: &mut Vec) { + let provider = PROVIDER.read().clone(); + if let Some(p) = provider { + out.extend(p.detect(root).into_iter().filter(|c| c.id.starts_with(EXTERNAL_PREFIX))); + } +} + +/// `config` with the program its provider approved, for an externally +/// detected configuration; `None` for every other one. +pub(crate) fn approved(config: &RunConfig) -> Option> { + if !config.detected || !config.id.starts_with(EXTERNAL_PREFIX) { + return None; + } + let provider = PROVIDER.read().clone(); + Some(match provider { + Some(p) => p + .launch(config) + .map(|program| RunConfig { command: program.to_string_lossy().into_owned(), ..config.clone() }), + None => Err("the plugin that detected this configuration is not loaded".into()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ProcessManager, StartOptions}; + + /// Answers only for projects with its own marker (tests share the provider). + struct Fake; + + const MARKER: &str = "fake-external-detection.marker"; + + impl ExternalDetection for Fake { + fn detect(&self, root: &Path) -> Vec { + if !root.join(MARKER).exists() { + return Vec::new(); + } + let mut c: RunConfig = serde_json::from_value(serde_json::json!({ + "id": "plugin:acme:x", "name": "x", "kind": "plugin", "command": "x", "args": ["a"], + "cwd": null, "env": {}, "detected": true, "isTest": false, "debugAdapter": null + })) + .unwrap(); + c.detected = true; + let other = RunConfig { id: "not-prefixed".into(), ..c.clone() }; + vec![c, other] + } + fn launch(&self, _: &RunConfig) -> Result { + Err("not allowed".into()) + } + } + + #[test] + fn external_configs_join_detection_and_launch_only_when_approved() { + set_external_detection(Some(Arc::new(Fake))); + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(MARKER), "").unwrap(); + let configs = crate::detect(tmp.path()); + assert_eq!(configs.iter().map(|c| c.id.as_str()).collect::>(), ["plugin:acme:x"]); + let err = ProcessManager::new() + .start(tmp.path(), &configs[0], &StartOptions::default(), Arc::new(ide_core::NullSink), Box::new(|_| {})) + .unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied); + assert!(err.message.contains("not allowed")); + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 1ca1a0a18..7e85a1643 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -4,6 +4,7 @@ mod config; mod detect; pub mod dotnet; pub mod envfile; +mod external; pub mod go; pub mod gradle_jvm; pub mod import; @@ -192,6 +193,8 @@ pub struct RunInfo { pub pty: bool, } +pub use external::{EXTERNAL_PREFIX, ExternalDetection, set_external_detection}; + /// Detects configs from package.json scripts, Cargo.toml targets, build.gradle(.kts), /// pom.xml, go.mod, pyproject/pytest, Makefile. pub fn detect(root: &Path) -> Vec { diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index ce900ecaa..2899c026a 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -329,6 +329,12 @@ impl ProcessManager { if config.command.trim().is_empty() { return Err(IdeError::invalid("run configuration has no command")); } + let approved = match crate::external::approved(config) { + Some(Ok(c)) => Some(c), + Some(Err(e)) => return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e)), + None => None, + }; + let config = approved.as_ref().unwrap_or(config); let plan = crate::go::plan(root, config)?; let followup = plan.as_ref().and_then(|p| p.run.as_ref()).map(|run| { let cwd = resolve_cwd(root, run.cwd.as_deref()); diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 3c1eb64f0..600d8dd65 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -544,6 +544,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi max_java: None, launch_error: None, tsserver_project: false, + pinned: None, } } @@ -553,6 +554,8 @@ pub fn server_launch_overrides(cfg: &mut LanguageServerConfig, s: &Settings) { let Some(ls) = s.language_servers.get(&cfg.name) else { return }; if let Some(command) = non_empty(ls.command.trim()) { cfg.command = command.to_owned(); + // The user's own program, not the one approved for a plugin. + cfg.pinned = None; } if let Some(args) = &ls.args { cfg.args = args.clone(); diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 167efed1b..3117fdda1 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -36,6 +36,7 @@ fn server(name: &str) -> LanguageServerConfig { max_java: None, launch_error: None, tsserver_project: false, + pinned: None, } } diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index dbcee862b..7bb741991 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -563,6 +563,27 @@ impl Documents { }) } + /// [`apply_edits`](Self::apply_edits) only if the document is still at + /// `version` (checked under the document's lock). `Ok(None)` when it + /// changed since; nothing is applied then. + pub fn apply_edits_at(&self, doc: DocId, version: u32, edits: &[TextEdit]) -> IdeResult> { + self.with_doc(doc, |st| { + if st.doc.version() != version { + return Ok(None); + } + if st.read_only { + return Err(read_only_error(&st.path)); + } + if edits.is_empty() { + return Ok(Some(version)); + } + let (set, applied, bytes) = st.doc.apply_edits_detailed(edits)?; + self.patched(doc, st, version, set, &applied, &bytes); + self.set_dirty(doc, st, true); + Ok(Some(st.doc.version())) + }) + } + fn patched( &self, doc: DocId, diff --git a/docs/plugin_p0.md b/docs/plugin_p0.md new file mode 100644 index 000000000..4c374dd71 --- /dev/null +++ b/docs/plugin_p0.md @@ -0,0 +1,159 @@ +# 插件系统 P0 验证结论 + +对应 `docs/plugin_plan.md` 第 5 节 P0。实验代码保留为回归测试: +`crates/ide-plugin/tests/p0_probe.rs`,探针组件在 `crates/ide-plugin/tests/fixtures/p0-probe/` +(`build.sh` 生成 `dist/min.wasm` 与 `dist/full.wasm`)。环境:wasmtime 49.0.5、wasmtime-wasi 49(p2)、 +wit-bindgen 0.63、`wasm32-wasip2`,Apple Silicon,debug 构建(release 数字括注)。 + +## 1. 可选导出能否按接口绑定 —— 可以 + +测试:`optional_exports_bind_per_interface`。 + +- `Component::get_export_index(None, "openidea:probe/code-actions@0.1.0")` 在**编译后、实例化前**就能判断 + 组件有没有导出某个接口。安装时据此算出插件实现了哪些 Provider,写进贡献索引,启动时不需要实例化。 +- `bindgen!` 生成的整 world 绑定(`ProbeHostPre::new`)要求所有导出都在,缺一个就失败; + 但每个导出接口各有一个 `GuestIndices::new(&instance_pre)`,缺失时返回 `Err`,存在时正常 `load` 和调用。 +- 结论:**不需要退回“基础 world + 多个组合 world”**。宿主用一个包含全部可选导出的 world 生成绑定, + 只用到每个接口的 `GuestIndices`,不用整 world 的 `*Pre`。插件作者侧:SDK 为每个 Provider 提供 + feature 或宏,只导出实现了的接口(wit-bindgen 按 world 生成,SDK 内置几个常用组合 world 即可, + 宿主不关心组合方式)。 + +对设计的影响:无,4.3 节的“可选导出”成立。 + +## 2. async 调用、取消与实例复用 + +测试:`spinning_guest_yields_to_the_executor`、`cancelled_instance_cannot_be_reused_but_reinstantiates_fast`、 +`async_host_import_does_not_block`。 + +- wasmtime 49 里 `Config::async_support` 已废弃且无效果,async 能力由 `async` cargo feature 和调用方式决定。 +- `store.epoch_deadline_async_yield_and_update(1)` + `call_async`:死循环的 guest 每个 epoch tick 让出一次, + **单线程**执行器上的其他任务照常推进。长调用不再独占线程。 +- async 宿主导入(`imports: { "...sleep-ms": async }`)等待期间不阻塞线程。 +- guest 返回 `Err`(不是 trap)后实例照常可用。 +- **取消(丢弃 future)后实例不能复用**:下一次调用返回 `Trap::CannotEnterComponent`。 + 无论取消发生在 guest 计算中还是 async 宿主导入的等待中,结果相同。原因:wasmtime 在 fiber 被 + dispose 时把错误传回 `catch_traps`,组件 store 被标记为 `trapped`(`may_enter() == false`)。 + 这是 wasmtime 的有意行为,不能绕开。 +- **重建很便宜**:从已有的 `InstancePre` 新建 store 并实例化,加上第一次调用,约 **50µs**(release 约 40µs)。 + 代价在于丢失实例内存里的状态,而不是时间。 + +结论与对 4.5 节的修订: + +1. 取消 = 丢弃 future + 丢弃 store;下次调用前从 `InstancePre` 重建。不需要冷编译。 +2. 两段式超时仍然保留,但理由从“避免冷启动”改为“保留实例状态”: + - **软超时**(编辑路径 50ms):调用方不再等待、结果丢弃,调用在 actor 内继续执行完,实例和状态保留; + 同一插件的后续调用排队,新请求到来时旧请求如仍在排队则直接丢弃(不进入 guest)。 + - **硬超时**(编辑路径 500ms,命令 5s):丢弃 future,重建实例,计入熔断计数。 +3. 用户主动取消(例如 AI Completion 的旧请求)优先走**协作式取消**(见第 3 节),实例不受影响; + 协作式取消在宽限期内没有返回时,再升级为硬取消。 +4. 插件需要跨调用保留的状态一律走 `storage`;实例内存只当缓存。SDK 文档写明这一点。 +5. fuel 保留作为死循环兜底;与 epoch 让出同时启用没有冲突。 + +## 3. WASI p2 上的流式与任务模型 —— 可行,不依赖 p3 + +测试:`pollable_task_streams_and_cancels_cooperatively`。 + +- 宿主定义 `task` 资源,方法 `next-ready() -> pollable` 返回 `wasi:io/poll.pollable`(宿主用 + `wasmtime_wasi::p2::subscribe` 包装自定义 `Pollable`)。guest 调 `pollable.block()` 时,wasmtime 在 + async 宿主里等待该 future,执行器线程不被占用。 +- guest 循环“等待 → 取数据 → `report` 进度 → 检查 `is-cancelled`”,宿主推送数据并在最后设置取消标志; + guest 正常返回,**实例保持可用**。 +- 流式输出(例如 AI Completion 逐块返回、Kubernetes 日志)用同一模式的反方向:宿主提供 + `output-stream` 风格的资源(`write(chunk)` 是 async 宿主导入),或者 guest 通过 `task.emit(chunk)` 推送; + 两者都只需要 p2 资源 + pollable。 +- 结论:4.3 节 `tasks` 接口定为: + ```wit + resource task { + report: func(done: u32, total: option); + is-cancelled: func() -> bool; + /// 取消或有新输入时就绪,供 guest 在等待时让出。 + cancelled: func() -> pollable; + } + ``` + `net` 的流式响应体直接复用 `wasi:io/streams.input-stream`,不自造类型。 + +## 4. `syntax`、`index` 接口 + +依据:`ide-syntax`(`StructureService`:`node_at`、`syntax_errors`、`locals`、`identifiers_named`、 +`structural_ranges`,全部基于 `ide-language` 的 `SyntaxCache` 增量树,0 基行、UTF-16 列)和 `ide-index` +(`ProjectIndex`:`find_files`、`find_symbols`、`symbols_named`,后台 worker + redb 持久化)。 +Solidity 与 MyBatis Navigator 两个原型需要的能力如下,接口按它们的最小需求定稿。 + +### 4.1 `syntax` + +两个方向,分开设计: + +- **读宿主的树(import)**:适用于宿主有 grammar 的语言(MyBatis Navigator 读 Java、XML)。 + ```wit + interface syntax { + resource tree { + /// 0 基行、UTF-16 列;返回包含该位置的最内层命名节点。 + node-at: func(pos: position) -> option; + /// tree-sitter 查询语法;只返回被捕获的节点,最多 `limit` 个。 + query: func(source: string, range: option, limit: u32) -> result, error>; + errors: func(limit: u32) -> list; + } + record node { kind: string, range: range, text: string, parent-kind: option } + record capture { name: string, node: node } + /// 打开的文档用增量缓存树;未打开的文件现场解析(受 `project.read` 约束)。 + parse: func(path: string) -> result; + } + ``` + 不暴露节点句柄的游走 API(`parent`/`children` 逐个跨边界调用代价高);查询一次返回结果列表。 +- **插件提供语法树(export,P3)**:宿主没有 grammar 的语言(Solidity)。宿主不加载插件提供的原生 + grammar;插件在 Wasm 里自带解析器,导出 `syntax-provider`,按需返回**扁平结果**而不是树: + ```wit + interface syntax-provider { + highlights: func(text: string) -> list; // range + 语义类别(映射到 --syn-*) + outline: func(text: string) -> list; + folding: func(text: string) -> list; + } + ``` + 宿主把结果转换成现有的 `OutlineItem`、`FoldRange`、`SyntaxHighlights`,编辑器侧无感知。 + 这条路径在编辑路径上,适用 4.5 节的编辑路径预算。 + 原因:tree-sitter 的 Wasm grammar 需要 `tree-sitter` 的 wasm 特性和额外的 wasmtime 实例, + 且 grammar 不能表达大纲和折叠;扁平结果把实现方式留给插件。 + +### 4.2 `index` + +- **查宿主的符号索引(import)**: + ```wit + interface index { + find-symbols: func(query: string, types-only: bool, limit: u32) -> list; + symbols-named: func(name: string, kind: option, limit: u32) -> list; + find-files: func(query: string, limit: u32) -> list; + /// 插件自定义索引的查询,见下。 + lookup: func(index: string, key: string, limit: u32) -> list; + } + record entry { key: string, value: string, path: string, range: range } + ``` +- **插件自定义索引(export + 声明)**:MyBatis Navigator 要“Mapper 接口方法 ↔ XML 语句 id”的跨文件映射, + 宿主的符号索引不认识 XML 语句。做法: + ```toml + [[contributes.indexes]] + id = "statements" + files = ["**/*Mapper.xml", "**/*Mapper.java"] + version = 1 # 改变索引格式时递增,宿主据此重建 + ``` + 插件导出 `indexer.index-file(path, text) -> list`;宿主在**后台**(复用 `ide-index` 的 worker + 调度和 redb 存储,按插件 id + 索引 id 分表)对匹配文件调用,文件变化时增量更新,插件禁用或卸载时删表。 + 插件运行时只能 `lookup` 自己的索引。索引任务是后台任务,适用后台预算并上报进度。 + +对设计的影响:4.3 节的 `syntax`、`index` 按上面两节定稿;新增 `contributes.indexes` 扩展点(P6)。 + +## 5. 文档修正 + +`plugins/README.md` 第 4 节原文暗示插件语言里的 `language-servers`、`debugger` 会生效。实际上插件语言 +只经 `languages` 命令进入前端的语言列表(文件检测、注释、缩进、借用的 CodeMirror 模式),不进入后端 +`LanguageRegistry`,因此 LSP、DAP、后端语言检测都不生效。README 已改为如实说明,P3 完成后再恢复。 + +## 6. 对计划的修订汇总 + +| 位置 | 原设计 | 修订 | +| -------------------- | ------------------------ | ------------------------------------------------------------ | +| 4.3 可选导出 | 待验证,备选组合 world | 按接口 `GuestIndices` 绑定,安装时用 `get_export_index` 记录 | +| 4.5 超时 | 两段式用于避免冷启动 | 重建约 50µs;两段式用于保留实例状态;用户取消优先协作式 | +| 4.3 `tasks` | `report`、`is-cancelled` | 增加 `cancelled() -> pollable` | +| 4.3 `syntax`/`index` | 待定 | 见第 4 节 | + +以上修订已同步到 `docs/plugin_plan.md`。 diff --git a/docs/plugin_plan.md b/docs/plugin_plan.md new file mode 100644 index 000000000..180b40f5c --- /dev/null +++ b/docs/plugin_plan.md @@ -0,0 +1,601 @@ +# 插件系统重构计划 + +状态:P0–P8 完成(P4 release 实测在预算内),WIT 冻结为 `openidea:plugin@1.0.0`。涉及 `crates/ide-plugin`、`crates/ide-host`、`wit/`、 +`src-tauri/src/commands/plugin.rs`、`src/features/plugins`、`src/workbench/contributions.ts`。 + +## 1. 目标 + +- 新增贡献类型时不改内核:领域 crate 或前端 feature 注册一个扩展点即可。 +- 插件能做出实质功能:Provider、事件、长任务、视图、自定义编辑器、外部工具链。 +- 第三方代码默认隔离,能力需声明并由用户逐个插件授权。 +- 已安装插件不拖慢启动:没有被触发的插件不实例化 Wasm。 +- 本地和远程项目使用同一套宿主,从 P3 起每阶段都在远程项目上验收。 +- 用 10 个官方插件验证设计,只走公开 SDK。 + +### 非目标 + +- 不把第一方 feature 改造成插件。它们和插件共用注册表即可。 +- 不兼容 VS Code 插件。 +- API 1.0 不支持插件间的代码依赖,只能通过命令 id 协作。 +- 不提供服务端市场,先用静态索引。 + +## 2. 现状 + +### 已有,可直接复用 + +| 能力 | 位置 | 本计划中的用途 | +| ----------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| 项目信任 | `crates/ide-host/src/trust.rs`;`create_host` 通过 `set_project_policy` 接入,未受信项目走 `restrict_project` | 项目插件加载的前置门槛 | +| when 上下文求值 | `src/platform/context.ts` | 菜单、键位、状态栏的 `when` 条件,不另造表达式语言 | +| 后台符号索引 | `crates/ide-index` | `index` 接口在其上开放,不从零设计 | +| epoch + fuel 限额 | `crates/ide-plugin/src/runtime.rs` | 保留作为兜底,调用模型改为 async(见 4.5) | +| 性能预算 | `perf/budgets.json` | 新增插件相关指标 | + +### 问题 + +| 问题 | 证据 | 后果 | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| 贡献类型写死 | 新增一类要改 `Contributions`、`Registrations`、`PluginInfo`、`CatalogPlugin`、`static_contributions`、`files()`、`bridge.ts` | 扩展成本线性增长 | +| 内部扩展点没开放 | 前端有 48 个 `register*` 函数,插件只能贡献命令、语言、主题 | 插件做不出实质功能 | +| 插件语言是半成品 | `PluginHost::languages()` 只被 `commands/app.rs` 的 `languages` 命令使用,没有进入 `LanguageRegistry`;语法树只能来自内置 grammar | `plugins/README.md` 写的 `language-servers`、`debugger` 实际不生效 | +| 只有拉模型 | guest 只导出 `activate`、`deactivate`、`run-command`,同步调用,单次上限 5s,超时即 trap | 无事件、无 Provider、无长任务、无取消 | +| 启动全量激活 | `load_all` 逐个编译并激活,没有预编译缓存 | 插件越多启动越慢 | +| 信任只有项目一级 | 受信项目里 `.openidea/plugins/*` 全部自动加载,无逐插件确认;首次迁移时最近项目被整体标为受信;已安装插件声明即授权 | 开放进程或网络后,打开一个受信仓库就会执行其中任意插件 | +| 宿主位置不对 | `PluginHost` 在 `src-tauri`,`ide-host` 和 `openidea-server` 都没有 | 远程项目无法在项目所在机器运行插件 | +| 安装非原子 | `lib.rs` 安装时先 `remove_dir_all` 旧目录再 `rename` | 中途失败插件丢失;无版本回滚 | +| 分发不完整 | 无打包格式、签名、市场 | 无法形成生态 | +| 编辑无版本校验 | `insert-text`、`replace-selection` 是发完不管的事件 | 存在竞态,插件也拿不到结果 | + +## 3. 目标架构 + +```text + 主 WebView(只跑第一方代码) + 贡献投影层:已校验的声明 → 现有 register*(不会失败) + 声明式视图渲染器(src/ui)| 沙箱 iframe(逃生口) + │ 快照/增量 + 视图树 ▲ 用户事件 +────────┼────────────────────────────┼────────── 本地 IPC 或远程协议 + ide-host(本地与 openidea-server 共用,运行在项目所在机器) + ExtensionRegistry:扩展点 + 按插件记账 + CapabilityBroker:授权账本 + 项目信任 + ActivationManager:激活事件 + 空闲回收 + WasmRuntime:每插件一个 actor,async 调用 + 分级预算 + │ 只通过能力接口 + project / editor / syntax / index / lsp / net / process / storage + │ 需要本机的能力(secrets、通知、剪贴板) + 本地代理(客户端侧) +``` + +依赖方向:`ide-core` ← `ide-extension`(manifest、注册表、记账、能力模型,不依赖 wasmtime)← +各领域 crate(实现扩展点)与 `ide-plugin`(Wasm 运行时)← `ide-host`(组装)。 + +### 放置规则 + +现在就定下来,不等到远程阶段: + +1. **Wasm 一律运行在项目所在机器**(本地项目在本机,远程项目在 `openidea-server`)。 +2. **UI 只做声明投影**:插件不直接接触 WebView,视图树和用户事件通过协议往返。 +3. **`Placement` 属于扩展点,不属于插件**:`Ui` 类声明(菜单、键位、主题、图标)投影到客户端; + `Workspace` 类声明(语言、LSP、运行配置)在宿主侧生效。 +4. **只有本机才有的能力走本地代理**:`secrets` 存在客户端的系统钥匙串,宿主按需转发; + `net` 从项目所在机器发出(与 LSP 一致),在授权页上注明。 +5. 编辑路径 Provider 在远程模式下的预算另计网络往返,单独设指标。 + +## 4. 关键设计 + +### 4.1 扩展点 + +```rust +pub trait ExtensionPoint: Send + Sync { + fn id(&self) -> &'static str; // "languages" + fn placement(&self) -> Placement; // Ui | Workspace + /// 只解析和校验,无副作用;同时算出这条声明需要的能力。 + /// Ui 类扩展点也在这里做完全部校验(JSON schema),前端投影不允许失败。 + fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result; +} + +pub struct Prepared { + pub capabilities: Vec, // 带 LSP 的语言 → process: + pub commit: Box Result + Send>, +} +``` + +- 原子生效:全部 `prepare` 成功才开始 `commit`;任一 `commit` 失败,把已 commit 的 `Disposable` 逆序撤销。 +- 前后端一致:所有校验都在 Rust 侧完成,前端只做投影。投影抛错时记为缺陷,不做部分生效。 +- 干净卸载:贡献记在所属插件名下,禁用或卸载时统一 dispose。 +- 前向兼容:不认识的 `contributes.` 告警并跳过,不让整包失败。 +- 前端投影:`defineUiPoint("menus", decl => registerMenuGroup(...))`,复用现有注册函数; + `when` 条件交给 `src/platform/context.ts` 求值。 +- `LanguageRegistry` 改为“内置 + 插件覆盖层”的可变结构;内置 `languages/*.toml` 走同一扩展点。 + +### 4.2 能力分级 + +按实现形式分: + +| 级别 | 形式 | 用途 | +| ----------- | --------------------------- | --------------------------------------------------------- | +| L0 声明 | 纯数据 | 主题、图标主题、语言、片段、键位、模板、设置 schema、i18n | +| L1 逻辑 | Wasm 组件 | 命令、Provider、事件订阅 | +| L2 视图 | 声明式视图树;必要时 iframe | 工具窗、面板、自定义编辑器 | +| L3 外部进程 | LSP、DAP、CLI | 语言工具链 | + +按风险分(决定授权页怎么展示): + +| 风险 | 能力 | 授权方式 | +| ---- | -------------------------------------------------------------------- | -------------------------------------------- | +| 低 | `project.read`、`editor.read`、`storage`、事件订阅(不含按键) | 安装页列出,一次确认 | +| 中 | `editor.write`、`vcs.write`、`net:`、`secrets`、按键与输入事件 | 安装页单独列出并说明用途 | +| 高 | `process:`、`fs.read:<项目外路径>` | 单独弹窗,明确提示“等同于在本机执行任意代码” | + +`process:` 说明: + +- 它是完全的代码执行权限,参数无法有效限制(例如 `git -c core.sshCommand=...`),不把它包装成细粒度权限。 +- 二进制在授权时按绝对路径解析并写入账本;运行时路径或文件哈希变化要重新确认。不在运行时按 PATH 查找, + 防止仓库在 PATH 前面放同名程序冒充。 +- 声明也算能力:带 `language-servers.command` 的语言包同样需要 `process:` 授权,否则语言注册修好后就成了沙箱绕过口。 + +`net:` 由宿主的 HTTP 客户端实现,每一跳重定向都校验主机;不向插件暴露原始 socket。 + +默认用声明式视图树(tree、list、form、toolbar),由 `src/ui` 渲染,自动跟随主题和无障碍规范。 +iframe 只用于富内容:严格 CSP、仅 `postMessage`、不暴露 Tauri API。第三方代码不进主 WebView。 + +### 4.3 WIT API + +开发期版本号用 `openidea:plugin@0.2.0`,**P8 远程验收通过后才定为 1.0**(已于 P8 冻结为 `1.0.0`)。 +视图和远程两块最可能逼出破坏性改动,提前承诺 1.0 没有意义。 +目前只有样例和主题包,0.1 不写适配层;0.2.x 期间允许破坏性修改;1.0 之后 minor 只做增量,major 由宿主保留旧版适配。 + +```wit +package openidea:plugin@0.2.0; + +interface workspace { resource project { root: func() -> string; + read-file: func(path: string) -> result; } } +interface editor { resource text-editor { + apply-edits: func(version: u64, edits: list) -> result<_, error>; } } +interface syntax { resource tree { node-at, query, errors } parse: func(path) -> result; } +interface index { find-symbols, symbols-named, find-files, lookup /* 插件自定义索引 */ } +interface events { subscribe: func(kind: event-kind) -> subscription; } +interface tasks { resource task { report: func(done: u32, total: option); + is-cancelled: func() -> bool; + cancelled: func() -> pollable; } } +// 另有 ui、vcs、net、process、storage、secrets + +interface commands { run: func(id: string, ctx: borrow, arg: option) + -> result, error>; } +// 可选导出:code-actions、completion、inline-completion、run-detector、view-provider、 +// syntax-provider、indexer … +``` + +`syntax`、`index`、`tasks` 的定稿与理由见 `docs/plugin_p0.md` 第 3、4 节。 + +- 窗口和项目作为句柄传入,去掉隐式的 `Invocation` 调用态。 +- P5 实施的偏差:`events.subscribe` 不返回 `subscription` 资源,订阅记在宿主、会话内有效,因为实例会被 + 空闲回收而订阅要继续生效(`on-event:` 声明同样效果);`tasks.task` 暂无 `cancelled() -> pollable`, + 调用是同步的线程 actor,`is-cancelled` 轮询即可,等 P6 流式接口一起加;guest 侧语法提供者用只导出 + `syntax-provider` 的 `syntax` world 与 `plugin` 合并(wit-bindgen 对同一导出接口会生成重名宏)。 +- P6 实施的偏差:Provider 不靠探测可选导出,而由 manifest `[[contributes.providers]]` 声明种类和语言, + 宿主只调用声明匹配的插件(未匹配的不加载);导出放在 `providers` 接口、`plugin-providers` world。 + `net.fetch` 一次返回完整响应(≤ 8 MiB),没有流式 body;内联补全的取消由宿主中断调用完成 + (`call_cancellable`,宽限 250ms),仍不需要 `pollable`。密钥不经 guest:请求头写 `{{secret:}}`, + 宿主发出前替换(这就是“本地代理”的做法);远程项目里由服务器按需向桌面端请求。 +- 权限失败返回 `error::denied`,不再 trap,插件可以降级运行。 +- 安装时读取组件 import 列表并与 manifest 比对,使用了未声明接口的拒绝安装。 +- 编辑带文档版本号,版本不符返回 `error::stale`,由插件决定重试。 +- 坐标继续遵守 0 基行号和 UTF-16 列。 + +### 4.4 Manifest + +```toml +id = "acme.rust-tools" # 带发布者命名空间 +version = "1.2.0" +engines.openidea = "^0.2" +wasm = "plugin.wasm" +activation = ["on-language:rust", "on-command:acme.check"] + +[capabilities] +required = ["project.read", "editor.write"] +optional = ["net:crates.io", "process:cargo"] + +[[contributes.commands]] +id = "check" +title = "%check.title%" + +[[contributes.menus]] +menu = "editor.context" +command = "check" +when = "editorLang == rust" # 由 src/platform/context.ts 求值 + +[[contributes.languages]] +path = "languages/rust.toml" + +[contributes.settings] +schema = "settings.schema.json" # 自动生成设置页并接入设置搜索 + +[[contributes.views]] +id = "crates" +location = "toolWindow.right" +``` + +菜单、键位、状态栏只靠声明显示,用户首次触发时才激活插件。 +激活事件:`on-command`、`on-language`、`on-file`、`on-view`、`on-schedule`、`on-startup-finished`。 +`optional` 能力在用户拒绝后插件照常加载,调用时返回 `error::denied`。 + +### 4.5 运行时 + +- 启动只读缓存的贡献索引,不实例化 Wasm。 +- 用 `Component::serialize` 把编译结果落盘,以 wasm 哈希加引擎版本作为键;引擎升级后后台重新编译。 +- ~~调用改为 async~~(P4 实施时改为线程 actor,见下一条):async 方案要把宿主绑定全部改成 async, + 而一个实例同一时刻只执行一个调用,换来的只是少几个线程。 +- 每个活动插件一个 actor 线程独占 `Store`,调用方把任务投进邮箱、在回复通道上等待,不再跨调用持锁; + 软超时由调用方停止等待实现,硬超时和取消由 store 的 `epoch_deadline_callback`(每个 epoch tick 检查 + 截止时间和取消标志)中断。fuel 保留作为死循环兜底。空闲回收后线程随之结束。 +- 超时与实例复用(P0 已验证):丢弃 future 后实例被 wasmtime 标记为不可再进入,必须重建;从 `InstancePre` + 重建约 50µs。编辑路径 Provider 用“软超时丢结果、硬超时才重建”的两段式,目的是保留实例状态; + 用户主动取消优先走协作式取消(`task.is-cancelled` / `cancelled()`),宽限期后再硬取消。 +- 分级预算:编辑路径 Provider 软超时 50ms(丢弃结果),硬超时 500ms(重建实例);命令 5s; + 后台任务时限更长,但必须上报进度。 +- 故障恢复:指数退避重启,连续失败才熔断,不再一次 trap 就停用整个会话。 +- 空闲后销毁实例,持久状态通过 `storage` 接口保存;`on-schedule` 到点时重新激活,不阻止回收。 +- 每插件统计激活耗时、调用延迟、错误数,在设置页显示,并写入 `perf/budgets.json`。 + +性能预算(P4 已用 release 实测确认,数字见 P4 完成情况): + +| 指标 | 初值 | +| ------------------------------------------- | ------ | +| 安装 50 个插件时 `startup.readyMs` 增量 | ≤ 20ms | +| 启动阶段实例化的 Wasm 数 | 0 | +| 有预编译缓存时单插件冷激活 | ≤ 30ms | +| 编辑路径 Provider p95(本地) | ≤ 50ms | +| 编辑路径 Provider p95(远程,扣除网络 RTT) | ≤ 50ms | +| 已回收插件的常驻内存 | 0 实例 | + +### 4.6 信任与授权 + +- 授权账本:记录插件 id、版本、包哈希、已授权能力,以及高风险能力对应的二进制绝对路径和文件哈希。 +- 安装时展示能力清单,确认结果写入账本;更新后能力有增加时要重新确认,能力不变时静默升级。 +- 项目插件 `.openidea/plugins/*`:项目受信只是前提,每个插件还要单独确认后才加载。 + 首次迁移标为受信的项目也遵守这一条。 +- 开发插件:“链接开发插件”按**目录路径**授权,不按包哈希授权,避免每次构建都弹窗;能力有增加时仍然重新确认。 +- 已安装插件“声明即授权”的旧行为,在账本上线时一次性迁移:已有插件只自动授予低风险能力,其余首次使用时再确认。 + +### 4.7 分发 + +- 包格式:单文件 zip,内含 manifest、wasm、资源、签名。 +- 签名:Ed25519。信任根是官方索引里登记的发布者公钥;发布者的 id 前缀(`acme.`)与公钥绑定; + 发布者自行轮换密钥,旧密钥签发的版本继续有效,直到被吊销。 +- 未签名包:只能通过“从文件安装”或“链接开发插件”安装,安装页显示明确警告。 +- 吊销:静态索引附带吊销和下架列表,宿主每天检查一次;命中的插件禁用并通知用户。 +- 目录:`plugins///` 加 active 指针,支持原子升级和回滚;数据放 `data//`。 +- 市场:静态索引(Git 仓库 + JSON + CDN)。 + +### 4.8 SDK 与工具 + +“只走公开 SDK”是验收前提,所以 SDK 本身就是交付物: + +- `openidea-plugin` Rust crate:封装 wit-bindgen 生成的绑定,提供命令、Provider、视图的辅助类型。 +- 脚手架:`new`(模板)、`build`(组件化并校验 import 与 manifest 一致)、`link`(链接开发插件)、`pack`(打包与签名)。 +- mock host:插件作者在 `cargo test` 里不启动 IDE 就能测命令和 Provider。 +- 官方插件全部使用这套工具构建,宿主测试也通过 mock host 覆盖。 + +### 4.9 否决的方案 + +| 方案 | 否决原因 | +| ------------------------------------ | -------------------------------------------------- | +| JS 扩展宿主进程 | 没有真正的沙箱,要多维护一个 Node 进程,API 面巨大 | +| 第三方 JS 直接进主 WebView | 能调用全部 Tauri IPC,完全绕过权限 | +| Rust `dylib` 原生插件 | ABI 不稳定,没有隔离 | +| 第一方 feature 全部插件化 | 大量重写,换不来用户价值 | +| 通用 JSON-RPC `handle(method, json)` | 丢掉类型安全和 WIT 契约 | +| 一开始就定为 API 1.0 | 视图和远程没验证,大概率要破坏性修改 | +| `process:` 运行时按 PATH 解析 | 仓库可以在 PATH 前面放同名程序冒充 | + +代价:Wasm 生态较小。可提供 TS SDK(`jco componentize`)缓解,但单包体积会明显增大。 + +## 5. 实施阶段 + +每阶段都可以单独发布。从 P3 起,每阶段的验收同时在本地和远程项目上运行。 + +### P0 验证 + +- [x] 验证 wasmtime 49 能否按接口绑定可选导出;不行就退回“基础 world + 多个组合 world”(可以,按接口绑定) +- [x] 验证 async 调用:epoch 让出、丢弃 future 取消、**取消或超时后实例能否复用**(不能复用,重建约 50µs) +- [x] 验证 WASI p2 上的流式和任务模型(`pollable` + `task` 资源),API 不依赖 p3 +- [x] Solidity、MyBatis Navigator 最小原型,基于 `ide-syntax` 和 `ide-index` 定稿 `syntax`、`index` 接口 +- [x] 修正 `plugins/README.md` 中不生效的语言能力说明 + +结论见 `docs/plugin_p0.md`。 + +验收:四个验证项都有书面结论;结论推翻了第 4 节设计的,先改文档再进入 P1。 + +### P1 内核 + +- [x] 新增 `ide-extension`:manifest、`ExtensionPoint`、注册表、记账、commit 失败回滚 +- [x] 迁移命令、主题、语言到扩展点 +- [x] 前端贡献投影层,`when` 接入 `src/platform/context.ts` +- [x] 宿主移入 `ide-host`,`src-tauri` 只保留适配 +- [x] 安装改为先写临时目录、再原子替换,不先删旧版 +- [x] SDK crate、`new`/`build`/`link` 脚手架、mock host +- [x] 官方插件:Material Icons + +验收:现有 `crates/ide-plugin/tests/host.rs` 场景全部通过;新增 `iconThemes` 不改内核; +Material Icons 用 SDK 脚手架构建。 + +完成情况:`host.rs` 迁为 `crates/ide-host/tests/plugins.rs`,原场景全部保留并通过,`hello` 改用 SDK +重新构建;`iconThemes` 只在 `ide-host/src/plugins/{icons,points}.rs` 注册,`ide-extension` 未改。 +SDK 在 `sdk/rust/openidea-plugin`(`Plugin` trait、`register_plugin!`、原生目标上的 `mock::MockHost`); +脚手架是 `ide-host` 的第二个二进制 `openidea-plugin`(`new --template command|theme|icon-theme|language`、 +`build`、`check`、`link`/`unlink`/`links`),`check` 走与安装相同的校验并拒绝导入 WIT 以外接口的组件。 +Material Icons 在 `plugins/official/material-icons`,由 `new --template icon-theme` 生成, +106 个原创图标,文件夹图标同样生效(有角色的目录保留内置图标)。规模测试:3000 个 SVG 的主题 +安装约 1.2s、启动加载约 0.28s(release;debug 为 1.7s / 0.6s),启动时逐个重新净化,P4 的贡献索引缓存应消除这部分。 + +### P2 授权 + +- [x] 授权账本、安装确认页、能力变更时重新确认 +- [x] 项目插件逐个确认;开发插件按路径授权 +- [x] 已安装插件的授权迁移 +- [x] 风险分级展示;`process:` 绝对路径解析和哈希校验 + +验收:打开受信仓库时,未确认的项目插件一律不执行;能力增加时要求重新确认; +替换授权过的二进制后,调用被拒绝并提示重新确认。 + +完成情况:账本在 `ide-extension/src/ledger.rs`(`grants.json`,按已安装 id 或目录路径记账);三条验收 +分别由 `plugins.rs` 的 `untrusted_or_unconfirmed_project_plugins_never_run`、 +`new_capabilities_require_consent_again`、`replaced_binaries_are_refused` 覆盖。0.1 授权只自动迁移低风险能力, +其余在首次调用时弹出确认(`ConsentRequested`)。语言声明推导出的 `process:` 视为可选,拒绝后语言照常可用。 + +### P3 语言全链路 + +- [x] `LanguageRegistry` 覆盖层,插件语言进入语言检测和 LSP +- [x] LSP、DAP 声明,接入 `process:` 授权 +- [x] 插件提供语法树 +- [x] 开放 `ide-run` 的运行配置检测 +- [x] 官方插件:Solidity + +验收:纯声明插件能启动语言服务器,本地和远程都可用;拒绝 `process:` 后语言高亮照常、LSP 不启动; +把一个内置语言定义迁成插件,跑通 DAP。 + +完成情况: + +- **覆盖层**:`LanguageRegistry::attach_plugins` 挂上 `PluginLanguages`,按 revision 惰性重建,项目 fork 自动跟随; + 内置和用户定义在 id、扩展名、文件名上都优先,插件不能抢占。插件语言变化时发 `LanguagesChanged`, + 宿主重新检测已打开文档、重配 LSP,前端刷新语言表。 +- **`process:` 授权**:服务器和调试器只在授权后出现在有效配置里,并带 `ProgramPin`(授权时的绝对路径 + - blake3);`ide-lsp`、`ide-dap` 启动前校验,文件被替换即拒绝(`PermissionDenied`)。用户在 + Settings › Language Servers 自己改了命令则不再受插件授权约束。 +- **远程**:客户端把插件语言和已授权程序名经 `plugin_sync_languages` 推给 `openidea-server`,服务器在本机 + `PATH` 上解析并钉住(`plugin-pins.json`),撤销授权即删除钉住;连接建立和语言/授权变化时同步。 +- **语法树**:WIT 新增可选导出 `syntax-provider`(`plugin-syntax` world,扁平 token/symbol/fold,UTF-8 字节偏移); + 运行时按接口绑定,旧组件不受影响;`ide-language` 的 `ExternalSyntax` 在没有内置 grammar 时接管, + 结果按文档版本缓存;SDK 提供 `SyntaxProvider` 和 `register_plugin!(T, syntax)`。远程项目暂不运行插件 Wasm(P4/P5)。 +- **运行配置**:`ide-run` 的 `ExternalDetection` 钩子 + `runDetectors` 扩展点(标记文件 → 命令), + 命令同样是 `process:` 能力,启动前校验钉住的程序。 +- **Solidity**:`plugins/official/solidity`,SDK 构建,手写词法器提供高亮、结构视图、折叠;声明 + Nomic Foundation 语言服务器和 `solc --lsp`(回退),Foundry / Hardhat 运行配置。 +- **DAP 迁移**:`ide-dap` 测试把内置 `gdscript.toml` 作为插件语言加载(无内置定义),经钉住的程序跑通调试会话, + 替换程序后拒绝启动。 + +### P4 运行时 + +- [x] 贡献索引与惰性激活 +- [x] 预编译缓存 +- [x] ~~async 调用~~线程 actor、分级预算、熔断、空闲回收 +- [x] 每插件统计;4.5 节的预算写入 `perf/budgets.json` + +验收:安装 50 个插件时达到 4.5 节预算,启动不实例化 Wasm。 + +完成情况: + +- **贡献索引**:`ide-extension::ContributionIndex`,已安装副本(有 `.package-hash`)的 UI 声明校验结果按 + 包哈希 + 扩展点 + 声明 + IDE 版本落盘(`plugins/.cache/contributions`),启动直接复用,不再逐个净化 SVG; + 链接和项目插件不进索引。后台预热时清理不再使用的条目。 +- **预编译缓存**:`PluginEngine::compile_cached`,键为模块 blake3 + `precompile_compatibility_hash` + (`plugins/.cache/components`),安装校验时顺带写入,启动完成后后台补齐并清理。 +- **运行时**:`ide-plugin::Actor`(见 4.5),`Limits` 增加 `edit_soft`/`edit_hard`/`restart_backoff`/ + `max_failures`/`idle_timeout`。trap 后丢弃实例,下次使用时按 1s、2s… 退避重建,连续 3 次才标记 Failed; + 正常返回即清零。用户取消(`PluginHost::cancel`)不计入熔断。`PluginHost::tick`(桌面端每 5s) + 负责回收空闲 5 分钟的实例、上报无人等待的崩溃、触发到期的 `on-schedule`(激活后照样会被回收)。 + “从 InstancePre 重建”改为从预编译缓存重新实例化,冷激活同样很便宜,回收后不必常驻已编译组件。 +- **统计**:激活次数与耗时、调用数、错误数、最慢与近 128 次 p95、崩溃、编辑路径超时、回收次数, + 设置页显示。`crates/ide-host/benches/plugins.rs` 测 50 个插件启动加载、冷激活、编辑路径调用, + 预算在 `perf/budgets.json` › rust › `plugins/*`,`pnpm perf:rust` 检查。 +- **实测**(2026-11-01,release bench profile,Apple Silicon,`cargo bench -p ide-host --bench plugins`, + criterion 均值及 95% 区间): + + | bench id | 实测 | 预算 (`perf/budgets.json`) | 占比 | + | ---------------------------------------------- | ---------------------- | -------------------------- | ---- | + | `plugins/load_50`(50 插件建宿主 + 加载) | 16.93ms(16.51–17.49) | 20ms | 85% | + | `plugins/cold_activation`(预编译,激活+调用) | 0.313ms(0.293–0.337) | 30ms | 1% | + | `plugins/syntax_highlights_2k_lines` | 1.69ms(1.67–1.73) | 50ms | 3% | + + 50 插件为 20×hello、10×solidity、10×material-icons、10×纯声明插件;启动不实例化 Wasm 由 bench 内断言保证。 + 三项均在预算内,验收通过。预算值不收紧:`load_50` 已占 85%,另两项是面向用户的体验上限而非回归门限, + 余量留给更慢的机器和远程场景。`load_50` 量的是插件宿主加载耗时,作为 `startup.readyMs` 增量的上界近似; + 语法高亮测的是整篇 2k 行单次调用,比编辑路径的单次 Provider 调用更重。 + +### P5 事件与任务 + +- [x] WIT 拆分、资源句柄、事件、任务、存储、VCS +- [x] 带版本校验的编辑接口 +- [x] `hello` 迁移到 0.2 API +- [x] 官方插件:String Tools、Key Promoter、Git Toolbox + +验收:三个插件只用公开 SDK 完成;Git Toolbox 的定时 fetch 在空闲回收后仍能按时触发。 + +完成情况: + +- **WIT 0.2**(`wit/`,按接口分文件):`types`(`error` 变体)、`log`、`contributions`、`ui`(通知、状态栏项)、 + `storage`、`workspace`(`project` 资源、`projects()`)、`editor`(`text-editor` 资源)、`invocation`、`vcs`、 + `events`、`tasks`;导出 `lifecycle`、`commands`(`run(id, borrow, arg)`)、`event-handler`, + 可选 `syntax-provider`。缺能力一律返回 `error::denied`,不再 trap。0.1 组件不再运行(无适配层,见 4.3)。 +- **编辑**:`apply-edits(version, edits)` 在宿主经 `Documents::apply_edits_at` 于文档锁内比对版本, + 不符返回 `stale`;成功即一次 `DocPatched`,编辑器里是一步撤销。命令调用时前端带上焦点编辑器的路径、 + 语言和选区(UTF-16 列)。 +- **事件**:前端按插件关心的种类(`plugin_event_kinds`)上报打开/保存文件、焦点编辑器、动作(带来源和快捷键)、 + 仓库变化;宿主按插件和窗口排队,运行中的插件即时投递,空闲插件攒 2s 再唤醒。订阅经 `subscribe` 或 + manifest `on-event:`,整个会话有效、跨空闲回收。`on-schedule` 到点投递 `scheduled`。 +- **任务**:`tasks.begin` 进入前端后台任务列表(可取消,取消同时取消宿主侧的 fetch),打开任务的调用改用 + 60s 后台预算;任务不跨调用。 +- **存储**:`plugins/.data//storage.json`,1 MiB,卸载时删除。**VCS**:`ide-vcs` 读状态,fetch 仅在受信项目。 +- **SDK**:`Invocation`/`Project`/`TextEditor`/`Task` 句柄、`transform_selections`、`text` 坐标工具; + mock host 有带版本的编辑器、存储、订阅、任务、VCS。脚手架模板随之更新。 +- **官方插件**(`plugins/official/`):String Tools 26 条命令分 4 组、多光标一步撤销;Key Promoter 由 + `on-event:action-performed` 激活、计数存 storage;Git Toolbox `on-schedule:300` 抓取所有项目、状态栏显示 + 分支和 ↑↓。宿主端到端测试 `official_*`,验收由 `official_git_toolbox_fetches_on_schedule_after_idle_reclaim` + (本地 bare 远端、回收后 `tick` 到点、状态栏变为 `main ↓1`)覆盖。 + +### P6 Provider 与网络 + +- [x] 通用 Provider:诊断、快速修复、补全、内联补全、导航、行标记 +- [x] 网络、密钥(经本地代理)、一次性进程 +- [x] 官方插件:Checkstyle、AI Completion、MyBatis Navigator + +验收:三个插件只用公开 SDK 完成;AI Completion 在连续输入时旧请求能被取消,达到编辑路径预算; +Checkstyle 在远程项目上读到的是远端文件。 + +完成情况: + +- **WIT**:新增 `io.wit`(`diagnostics.publish`、`net.fetch`、`secrets.is-set`、`process.run`)、 + `providers.wit`(`completion`、`inline-completion`、`code-actions`、`definition`、`line-markers`), + `workspace.project.find-files`(`ignore` + glob,遵守 `.gitignore`)。偏差见 4.3。 +- **Provider**:manifest `[[contributes.providers]] kinds/languages` 声明;宿主 `provide_*` 分发, + 结果并入现有 LSP 操作(补全列表、⌥⏎ 快速修复、⌘B、内联补全),新增 `plugin_line_markers` 操作和编辑器 + 行标记栏(有标记时才出现)。编辑路径走 50ms 软 / 500ms 硬预算;内联补全不受 50ms 限制,但请求被取代即中断。 +- **网络**:共享 reqwest 客户端;仅 https(`localhost` 可 http)、按 `net:` 授权、拒绝跨主机重定向、 + 8 MiB 上限、随调用取消。**密钥**:`[[contributes.secrets]]` 在 设置 › 插件 中录入,存 macOS 钥匙串 + (其他平台为 0600 文件);guest 读不到值。**进程**:只运行 `process:` 授权时固定的程序(路径 + 哈希), + 工作目录为受信项目根,4 MiB 输出,取消或超时即杀。**诊断**:按插件和文件替换,进 Problems 视图。 +- **远程**(放置规则 1、4):`openidea-server` 内置插件宿主(`plugins::remote_host`)。桌面端在连接时和插件 + 变化后(800ms 防抖)同步已启用的代码插件:先发摘要和授权,再只上传服务器缺少的包;服务器按桌面端授权 + 记录,`process:` 在服务器 `PATH` 上解析并固定。窗口的运行时命令(运行命令、事件、状态栏项、任务、行标记) + 与桌面命令同名,远程窗口自动路由到服务器;插件事件以 `plugin` 通知回到该窗口;密钥由服务器以 + `plugin_secret` 请求向桌面端按需索取。项目插件(`.openidea/plugins`)暂不在远程运行,服务器侧 storage + 与桌面端不同步。 +- **官方插件**:AI Completion(Ollama / OpenAI,密钥只发往 api.openai.com);Checkstyle(保存时检查, + 新保存取消旧任务,三种快速修复,项目级配置 `checkstyle.xml` / `config/checkstyle/checkstyle.xml` 优先); + MyBatis Navigator(接口 ↔ XML 跳转、行标记、XML 中 statement id 补全)。 +- **验收**:`official_ai_completion_cancels_superseded_requests`(连续输入时旧请求在宽限内被中断,结果在编辑 + 路径预算内);`official_checkstyle_checks_on_save`;`official_mybatis_navigates_between_interface_and_xml` + (p95 远低于 50ms);远程验收由 `remote_projects_run_the_desktops_code_plugins_on_the_server` 覆盖: + 经远程协议同步 Checkstyle 与 AI Completion,保存只存在于服务器上的文件,Checkstyle 在服务器上运行并读到该 + 文件,诊断经协议回到客户端;密钥向客户端索取;桌面端停用后服务器卸载。 + +### P7 UI 贡献 + +- [x] 菜单、键位、状态栏、设置 schema +- [x] 声明式视图、自定义编辑器 +- [x] 最后做 iframe +- [x] 官方插件:Kubernetes、CSV Editor + +验收:示例插件能做出带树视图的工具窗;CSV Editor 与文本视图双向同步并共享撤销; +两个插件在远程项目上都可用。 + +完成情况: + +- **L0 声明**(`plugins::ui_points`,全部在 Rust 侧校验,前端只投影):`menus`(编辑器、项目视图、编辑器标签 + 的上下文菜单,以及原生 Tools 菜单的动态槽 `pluginTools`)、`keymaps`(插件默认键位排在预设和用户覆盖之下, + 已被占用的按键丢弃;必须带 Ctrl/Alt/Meta 或功能键)、`statusBar`(声明即显示,代码用同 id 的 + `set-status-item` 更新)、`settings`(JSON schema 子集:boolean/string/number/integer、enum、默认值;生成 + 设置页、进设置搜索、项目可覆盖;值存在 `Settings.plugins[]`,插件经 `settings.get` 读取,类型不符回落到默认值)。 + 菜单、键位、状态栏只能引用本插件声明过的命令。 +- **L2 视图**(`wit/views.wit`):视图是数据——工具栏、body(tree、list、form、table、message、html)、状态行; + 可选导出 `view-provider.render/handle`,用户操作以 `view-event` 回传,代码用 `views.refresh` 请求重绘。 + WIT 不能递归,树是带 `parent` 的扁平列表,`expandable` 的节点展开时才取子节点;表格只取可见行(`viewport`), + 大文件不整体传输。`views` 成为工具窗,显示即激活(`on-view`)。 +- **自定义编辑器**:`customEditors` 按文件名模式接管打开(`option` 只出现在 Open With);编辑器标签挂到该文件的 + 共享文档上(`attachDocument`),插件经带版本的 `apply-edits` 改文档,所以文本标签同步显示、撤销是同一条 + 历史(有文本标签时记在它那里,否则记在自定义编辑器自己的历史),保存、脏标记、关闭提示都走文档; + “Open as Text” 打开文本编辑器。 +- **iframe**:`html` body 放进 `sandbox="allow-scripts"`(不透明源)的 `srcdoc`,CSP 只允许内联脚本、样式和 + `data:` 图片,无网络、无 IDE API;只经 `openidea.postMessage/onMessage` 与插件交换消息(`view-event::message`、 + `views.post-message`)。边界:声明式 body 能表达的一律用声明式,html 只用于富内容。 +- **终端**:`views.open-terminal(title, program, args)` 在调用窗口打开终端标签运行 `process:` 固定的程序, + 长连接流(`kubectl logs -f`)和交互会话(`kubectl exec -it`)都走它,不另加流式进程接口。 +- **远程**:`plugin_view_render`、`plugin_view_event` 与桌面命令同名,远程窗口路由到服务器;`viewChanged`、 + `viewMessage`、`openTerminal` 经 `plugin` 通知回到窗口,终端在服务器上运行。 +- **官方插件**:CSV Editor(RFC 4180、引号与换行、短记录补齐、插入/删除行、表头与分隔符设置、按版本缓存解析); + Kubernetes(命名空间 → Pods/Deployments/Services 逐级加载、状态摘要、日志和 shell 在终端、context/ + 命名空间/日志行数/shell 设置、Tools 菜单刷新)。`hello` 增加带树视图的工具窗和菜单、键位、状态栏声明。 +- **验收**:`sample_plugin_tool_window_with_a_tree`(声明在代码加载前就列出,显示即激活,展开触发 refresh, + 停用后声明全部撤销);`official_csv_editor_edits_the_shared_document`;`attachment.test.ts` 的共享撤销用例 + (无文本标签与有文本标签两种情况);`official_kubernetes_browses_the_cluster_and_opens_logs`;远程验收 + `remote_projects_get_plugin_views_and_custom_editors`(表格编辑的是服务器上的文档,集群用服务器的 kubectl, + 日志终端开在远程窗口)。 + +### P8 分发与 1.0 + +- [x] 包格式、签名、发布者公钥登记、吊销列表 +- [x] 版本目录、原子升级、回滚 +- [x] 静态市场索引与市场页 +- [x] 冻结 WIT,发布 `openidea:plugin@1.0.0` + +验收:升级失败能自动回滚;命中吊销列表的插件被禁用;10 个官方插件只改 `engines.openidea` 和 WIT 包版本即可切到 1.0。 + +完成情况: + +- **包与签名**(`ide_extension::package`):`-.oiplugin` 是 zip(manifest、声明引用的文件、组件、 + 许可证文件、可选的 `SIGNATURE.json`);解包拒绝越界路径、链接和超限内容。签名是 Ed25519,签的是 + `openidea-plugin/1\n\n\n<摘要>`,摘要覆盖 manifest 与包内文件。`openidea-plugin keygen`、 + `pack --key --publisher`、`index`(由一批包生成索引,保留已有吊销和旧公钥)。 +- **信任与吊销**:静态索引(`index.json`,格式 1)登记发布者及其公钥(信任根;发布者 `acme` 只能发布 + `acme.*`)、插件条目(含摘要)和吊销(某些版本或全部版本;公钥可标为吊销)。签名不成立(被改过、 + 不是该发布者的公钥、越权前缀、公钥已吊销)的包拒绝安装;无签名包从磁盘安装时提示。宿主保存最近一次读到 + 的索引(`market-index.json`),启动时、每次刷新(桌面端每天一次)都重新评估:命中的插件停用并给出原因、 + 通知用户,`set_enabled` 无法绕过;解除吊销后自动恢复。 +- **版本目录与回滚**(`plugins::install`):`///` 加 `active`、`previous` 指针,新版本 + 写完后才原子替换指针;更新后**新版本代码首次启动失败**(实例化失败、`activate` 出错或 trap)时自动回滚到 + 被替换的版本、删掉失败的版本、恢复旧的授权记录并通知用户;加载即失败的更新在安装时就回滚。设置页可手动 + “回滚到 …”。旧的平铺安装在下次更新时迁入版本目录;中断的迁移由 `recover` 复原。 +- **市场页**:设置 › 插件 › Available 填入索引 URL(`https://…/index.json` 或本地路径)后列出市场插件, + 安装/更新时下载、对照索引条目核对摘要和发布者签名,再走同一个授权安装页;已安装插件显示“由 … 签名 / + 未签名”。没有配置市场时仍显示本机可用的包。 +- **冻结 WIT 1.0**:包名改为 `openidea:plugin@1.0.0`,宿主 `ENGINE_VERSION`/`API_VERSION` 为 1.0;0.x 组件 + 不再运行(与 4.3 的约定一致:1.0 之后 minor 只做增量,major 由宿主保留旧版适配)。 +- **验收**:`a_failing_update_rolls_back_automatically`(`2.0.0-broken` 首次启动失败 → 回到 1.0.0、失败版本 + 删除、用户收到通知、之后正常;成功的更新可手动回滚;重启后仍是生效版本); + `signed_marketplace_plugins_install_and_revocations_disable_them`(签名安装、篡改与越权签名被拒、版本吊销 + 与公钥吊销都会停用且重启后仍生效、解除后恢复);`installs_from_before_versioning_keep_working_and_move_on_update`; + 10 个官方插件的源码未改,只把 `engines.openidea` 改为 `^1.0`、用 1.0 的 SDK 重新构建,`openidea-plugin + check` 全部通过,宿主与远程测试全部通过。 + +## 6. 官方验证插件 + +第二列是功能原型,不进入产品名、代码和提交信息;不移植原插件的代码。 + +| # | 官方插件 | 功能原型 | 级别 | 主要检验 | 阶段 | +| --- | ----------------- | ---------------------------------- | ------- | -------------------------------------------------------------------------------- | ---------------- | +| 1 | Material Icons | Atom Material Icons | L0 | 新扩展点只需一处注册;上千个 SVG 的净化、包体积、首屏加载;SDK 脚手架 | P1 | +| 2 | Solidity | Solidity | L0 + L3 | 声明式语言包走通 LSP;`process:` 授权;开放 `detect_*`;插件语法树 | P0 原型,P3 完成 | +| 3 | String Tools | String Manipulation | L1 | 大量命令声明和菜单分组;未激活也能显示;多光标编辑合并撤销;SDK 体验 | P5 | +| 4 | Key Promoter | Key Promoter X | L1 | 高频事件批量投递,避免频繁唤醒;按键事件属于中风险授权;持久化统计 | P5 | +| 5 | Git Toolbox | GitToolBox(不含内置已有的 blame) | L1 + UI | 定时 fetch 与空闲回收的配合;VCS 读写权限;状态栏刷新 | P5 | +| 6 | Checkstyle | CheckStyle-IDEA | L1 + L3 | 一次性进程;诊断和快速修复;保存时触发并取消旧任务;项目级设置;远程 | P6 | +| 7 | AI Completion | GitHub Copilot | L1 | 按键级延迟和取消;流式网络;密钥经本地代理;内联补全从 LSP 专用改为通用 Provider | P6 | +| 8 | MyBatis Navigator | MyBatisX | L1 | Mapper 与 XML 互跳、行标记、基于 `ide-index` 的跨文件索引、SQL 补全 | P0 原型,P6 完成 | +| 9 | Kubernetes | Kubernetes | L2 + L3 | 声明式树视图的能力上限;日志长连接流;`kubectl exec` 接内嵌终端;项目外访问授权 | P7 | +| 10 | CSV Editor | CSV Editor | L2 | 自定义编辑器双向同步、共享撤销、大文件;划定声明式视图与 iframe 的边界 | P7 | + +候补:Grep Console(运行控制台过滤,首选)、Translation(简单网络场景)、PlantUML(预览 + 外部渲染)。 +DAP 不在这 10 个之内:P3 把一个内置语言定义迁成插件来测。 + +### 验收规则 + +1. 只用公开 SDK:代码放在 `plugins/official/`,不依赖内部 crate,宿主里没有按插件 id 判断的逻辑。 +2. 缺 API 就加通用扩展点,并记录有没有第二个使用者;只有一个使用者的 API 要重新审视。 +3. 10 个全部安装后,启动不实例化 Wasm,指标符合 4.5 节预算。 +4. 自动化测试覆盖禁用和卸载:菜单、视图、语言、进程都清理干净。 +5. 每个插件都跑 panic、超时、拒绝授权三类测试:IDE 不受影响,插件能降级运行。 +6. 从 P3 起,每个插件都在远程项目上跑一遍。 + +## 7. 风险与待定项 + +| 项 | 影响 | 处理 | +| ------------------------------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `syntax`、`index` 接口 | 决定语言类和框架类插件能否成立 | 已在 P0 定稿(`docs/plugin_p0.md` 第 4 节),P3、P6 实现时验证 | +| 取消或超时后实例能否复用 | 决定编辑路径 Provider 的成本 | P0 结论:不能复用,重建约 50µs;用两段式超时保留状态 | +| 可选导出的绑定方式 | 影响 Provider 的接入形式 | P0 结论:按接口 `GuestIndices` 绑定 | +| 流式和任务模型 | AI Completion、Kubernetes 依赖 | P0 结论:p2 资源 + `pollable` 可行 | +| 远程模式下编辑路径的延迟 | 补全类 Provider 在远程项目上可能不可用 | P4 起单独设远程指标;不达标时允许 Provider 声明“仅本地” | +| `on-schedule` 与空闲回收的关系 | Git Toolbox 依赖 | P4 实现,P5 用 Git Toolbox 验证 | +| 项目外文件访问:`fs.read:` 还是只给 `process:` | 影响能力模型 | 已定(P7):Kubernetes 只要 `process:kubectl`,kubeconfig 由 kubectl 自己读,插件不读项目外文件;`fs.read` 仍保留为高风险能力,暂无使用者 | +| 发布者密钥丢失或泄露 | 影响已发布版本的可信度 | 吊销列表 + 发布者重新登记,P8 写成流程文档 | +| Wasm 单包体积(尤其 TS SDK) | 影响分发和内存 | P4 纳入性能预算观测 | diff --git a/perf/budgets.json b/perf/budgets.json index 25b40729b..21ad2c6e5 100644 --- a/perf/budgets.json +++ b/perf/budgets.json @@ -1,5 +1,5 @@ { - "$comment": "Upper bounds, lower is better. ui: `.` from `pnpm perf` (medians of the measured runs; enforced at CPU 1x only). Latencies are input → start of the first frame showing the result, so ~16.7 ms is 'next frame'. rust: criterion bench id → mean in microseconds, from `pnpm perf:rust`.", + "$comment": "Upper bounds, lower is better. ui: `.` from `pnpm perf` (medians of the measured runs; enforced at CPU 1x only). Latencies are input → start of the first frame showing the result, so ~16.7 ms is 'next frame'. rust: criterion bench id → mean in microseconds, from `pnpm perf:rust` (`plugins/…`: the runtime budgets of docs/plugin_plan.md 4.5).", "regressionTolerance": 0.2, "regressionFloor": 1, "ui": { @@ -83,6 +83,10 @@ "search_5k/literal": 100000, "search_5k/regex": 100000, - "search_5k/dense_limit": 20000 + "search_5k/dense_limit": 20000, + + "plugins/load_50": 20000, + "plugins/cold_activation": 30000, + "plugins/syntax_highlights_2k_lines": 50000 } } diff --git a/perf/rust.ts b/perf/rust.ts index a79889f52..345383960 100644 --- a/perf/rust.ts +++ b/perf/rust.ts @@ -16,7 +16,7 @@ import { evaluate, loadBudgets, printReport, type ScenarioResult } from "./lib/r const ROOT = resolve(import.meta.dirname, ".."); const TARGET = resolve(ROOT, process.env.CARGO_TARGET_DIR ?? "target"); const OUT = join(ROOT, "target", "perf"); -const PACKAGES = ["ide-text", "ide-language", "ide-index", "ide-search"]; +const PACKAGES = ["ide-text", "ide-language", "ide-index", "ide-search", "ide-host"]; const { values: args } = parseArgs({ options: { diff --git a/plugins/README.md b/plugins/README.md index fd1a745b7..c9d97c5fc 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,87 +1,201 @@ # Writing OpenIDEA plugins OpenIDEA plugins are sandboxed **WebAssembly components** (wasmtime, component -model, WASI preview 2), in the style of Zed extensions. A plugin can register -commands (they become IDE actions you can search for and bind to keys), read -project files, insert text into the editor, show notifications and contribute -themes and languages. Plugins that only contribute themes or languages need no -code at all. +model, WASI preview 2). A plugin can register commands (they become IDE actions +you can search for and bind to keys), read project files, edit the focused +editor, react to workbench events, run background tasks, show notifications +and status bar items, keep data in its own storage, read and fetch git +repositories, provide completion, quick fixes, navigation and gutter markers, +call web services and run approved programs, show tool windows and custom +editors, and contribute menus, shortcuts, settings, themes, file icon themes +and languages. Plugins that only contribute data (themes, icons, languages, +menus, settings) need no code at all. ``` plugins/ -├─ build.sh # builds Rust plugins into components -├─ hello/ # Rust plugin: two commands -└─ language-toy/ # manifest-only: a language definition +├─ hello/ # Rust plugin built with the SDK: commands, a tree tool window +├─ language-toy/ # data only: a language definition +└─ official/ + ├─ material-icons/ # data only: a file icon theme + ├─ solidity/ # language + syntax provider + run configurations + ├─ string-tools/ # 26 text commands, versioned multi-cursor edits + ├─ key-promoter/ # action events, storage + ├─ git-toolbox/ # on-schedule fetch, tasks, VCS, status bar item + ├─ checkstyle/ # one-shot process, diagnostics, quick fixes + ├─ ai-completion/ # inline completion over the network, secrets + ├─ mybatis/ # navigation, gutter markers, completion across files + ├─ kubernetes/ # tree tool window, terminals, settings + └─ csv-editor/ # custom editor sharing the text document +sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host) ``` -The API is defined in [`wit/plugin.wit`](../wit/plugin.wit) (`openidea:plugin/plugin@0.1.0`). +The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.0.0`, world +`plugin`); the design and roadmap are in +[`docs/plugin_plan.md`](../docs/plugin_plan.md). -## 1. The package +## 1. Quick start + +The `openidea-plugin` tool scaffolds, builds, checks and links plugins: + +```sh +cargo install --path crates/ide-host --bin openidea-plugin # or: cargo run -p ide-host --bin openidea-plugin -- … + +openidea-plugin new my-plugin # Rust command plugin (SDK) +openidea-plugin new my-theme --template theme # also: icon-theme, language +cd my-plugin +cargo test # logic, against the SDK's mock host +openidea-plugin build # → dist/plugin.wasm, then `check` +openidea-plugin link # load this folder in the IDE +``` + +`build` runs `cargo build --release --target wasm32-wasip2` (installing the +target through rustup if needed), copies the component to the manifest's +`wasm` path and runs `check`. `check` validates the package exactly as +_Install Plugin from Disk…_ would — manifest, every contribution, the SVG +sanitiser, the component — and that the component imports nothing but the +plugin API and WASI. It prints what the user will be asked to grant. + +`link` adds the folder to `links.json` in the IDE's plugins directory +(`$OPENIDEA_PLUGINS_DIR`, default `/ai.l0ng.openidea/plugins`). Linked +plugins are read from the folder itself, so the edit loop is `build` then +Settings › Plugins › _Reload_. `unlink` and `links` undo and list. + +## 2. The package A plugin is a directory with a `plugin.toml`: ```toml -id = "hello" # [a-z0-9][a-z0-9_-]*, unique -name = "Hello" -version = "0.1.0" # semver -description = "Inserts today's date and counts words." +id = "acme.word-tools" # [a-z0-9][a-z0-9_-]*, optionally with dotted namespaces +name = "Word Tools" +version = "1.2.0" # semver +description = "Counts and transforms words." authors = ["You "] -homepage = "https://example.com" # optional -openidea-api = "0.1" # API version this plugin targets -wasm = "dist/plugin.wasm" # omit for contribution-only plugins -permissions = ["project-read", "editor-read", "editor-write"] +homepage = "https://example.com" # optional +license = "MIT" # optional +change-notes = "1.2.0 — …" # shown under What's New +engines.openidea = "^1.0" # the plugin API this package needs +wasm = "dist/plugin.wasm" # omit for data-only plugins +activation = ["on-language:markdown"] # optional, see below + +[capabilities] +required = ["project.read", "editor.write"] +optional = ["net:api.example.com"] -# Optional static contributions (active even before / without code): [[contributes.commands]] -id = "insert-date" # [a-z0-9][a-z0-9._-]* -title = "Hello: Insert Date" -category = "Hello" # group in Search Everywhere / Keymap - -[[contributes.themes]] -path = "themes/my-theme.json" - -[[contributes.languages]] -path = "languages/my-lang.toml" +id = "count" # [a-z0-9][a-z0-9._-]* +title = "Word Tools: Count Words" +category = "Word Tools" # group in Search Everywhere / Keymap +when = "editorFocus" # optional: same expressions as built-in actions ``` -All paths are relative to the package directory and may not leave it. +All paths are relative to the package directory and may not leave it. A +manifest is validated as a whole: if one contribution is invalid, nothing is +installed or loaded. Contribution types the IDE does not know (a newer API) are +skipped with a warning. -### Permissions +Manifests in the 0.1 format (`openidea-api = "0.1"`, `permissions = [...]`) +still load (their permissions become required capabilities; contributions +work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK. -| Permission | Unlocks | -| -------------- | ------------------------------------------------- | -| `project-read` | `workspace-root`, `read-file` (project root only) | -| `editor-read` | `active-file` | -| `editor-write` | `insert-text`, `replace-selection` | +### Contribution points -`log`, `register-command`, `show-notification`, `register-language` and -`register-theme` are always available. Calling a gated function without its -permission **traps**: the plugin is stopped and the user is told which -permission is missing. +| Point | Declaration | Code needed | +| --------------- | -------------------------------------------------------------------------------------------------- | ----------- | +| `commands` | `id`, `title`, `category?`, `when?` | to run them | +| `themes` | `path` (theme JSON), `editor-scheme?` | no | +| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | +| `languages` | `path` (`languages/.toml` format, see §6) | no | +| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no | +| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | +| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | +| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no | +| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no | +| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | +| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no | +| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`) | yes | +| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`) | yes | -## 2. Writing code (Rust) +Declared contributions are live as soon as the plugin is enabled — commands +show in Find Action, menus and the status bar, shortcuts work, themes and icon +themes can be applied, settings pages are there — **before** any plugin code +is loaded. Menus, shortcuts and status bar items refer to the plugin's +declared commands. A plugin shortcut never takes a keystroke the IDE (or the +user) already uses; the user can rebind it in the keymap editor. -`wasm32-wasip2` produces a component directly, so all you need is rustup: +### Activation -```sh -rustup target add wasm32-wasip2 -``` +Code is instantiated lazily: when one of the plugin's commands runs, or when +an event in `activation` fires: -`Cargo.toml`: +| Event | Fires when | +| --------------------- | ------------------------------------------------------ | +| `on-command:` | the command runs (implied for every command) | +| `on-language:` | a file of that language is opened (`*`: any) | +| `on-file:` | a project file matching the glob is opened (`**`) | +| `on-view:` | one of the plugin's views is shown | +| `on-schedule:` | every `secs` seconds (at least 60): `Event::Scheduled` | +| `on-event:` | a workbench event of that kind (see _Events_) | +| `on-startup-finished` | shortly after the workbench is ready (use sparingly) | + +Until then the plugin is listed as _Inactive_ and costs nothing at start-up. +An instance unused for 5 minutes is reclaimed (its `deactivate` runs) and the +plugin is _Inactive_ again until its next event; `on-schedule` activates it +when due but does not keep it alive. Keep nothing in memory you can't rebuild +in `activate`. + +### Capabilities + +Code can only do what its capabilities allow, and the user approves them when +installing (or when a new version asks for more). Low-risk capabilities are +listed; high-risk ones are called out as equivalent to running code on the +machine. Users can revoke optional capabilities in Settings › Plugins. + +| Capability | Risk | Unlocks today | +| ------------------- | ------ | ----------------------------------------------------------------------------------- | +| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` | +| `editor.read` | low | `TextEditor::document`, `selections` | +| `editor.write` | medium | `TextEditor::apply_edits` | +| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes) | +| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) | +| `storage` | low | `host::storage` (1 MiB per plugin) | +| `events` | low | file opened/saved, editor focused, repository changed events | +| `events.input` | medium | `action-performed` events (which actions you run, and how) | +| `process:` | high | `Project::run_process`, language servers, debug adapters, runs | +| `net:` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) | +| `secrets` | medium | `{{secret:}}` in request headers, `host::secret_is_set` | + +`process:` capabilities are also derived from what the contributions start: a +language's `language-servers` and `debugger` commands and a run detector's +`command`. Those are optional — refusing one keeps the rest of the plugin +working. On grant the program is resolved to an absolute path and its content +hash is recorded; it is checked again right before every start, and a replaced +file is refused until the user approves it again. In a remote project the +server resolves and pins the program on its own machine (see _Remote +projects_). `fs.read:` can be declared and granted; it unlocks nothing +yet. + +Logging, notifications, status bar items, tasks and run-time contributions +are always available. Calling a gated function without its capability returns +`Error::Denied("")` — the plugin keeps running and can degrade +(Git Toolbox still shows the branch without `vcs.write`). + +## 3. Writing code (Rust SDK) + +`openidea-plugin new` writes this for you. `Cargo.toml`: ```toml [package] -name = "my-plugin" +name = "openidea-plugin-word-tools" version = "0.1.0" edition = "2024" -[workspace] # keep it out of any parent workspace +[workspace] # keep it out of any parent workspace [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] -wit-bindgen = "0.63" +openidea-plugin = { path = "…/sdk/rust/openidea-plugin" } [profile.release] opt-level = "s" @@ -93,24 +207,23 @@ panic = "abort" `src/lib.rs`: ```rust -wit_bindgen::generate!({ world: "plugin", path: "path/to/openidea/wit" }); +use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin}; -use openidea::plugin::host::{self, LogLevel, NotificationLevel}; +#[derive(Default)] +struct WordTools; -struct MyPlugin; - -impl Guest for MyPlugin { - fn activate(_ctx: Context) -> Result<(), String> { - host::register_command("shout", "My Plugin: Shout Selection", Some("My Plugin")); - Ok(()) - } - - fn deactivate() {} - - fn run_command(id: String, arg: Option) -> Result, String> { - match id.as_str() { +impl Plugin for WordTools { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result, String> { + let editor = ctx.editor().ok_or("no file is open")?; + match id { + "count" => { + let words = editor.document()?.text.split_whitespace().count(); + host::notify(NotificationLevel::Info, "Word count", &format!("{words} words")); + Ok(None) + } + // Every selection (a cursor: empty) in one undoable change. "shout" => { - host::replace_selection(&arg.unwrap_or_default().to_uppercase()); + editor.transform_selections(|s| s.to_uppercase())?; Ok(None) } _ => Err(format!("unknown command {id}")), @@ -118,37 +231,196 @@ impl Guest for MyPlugin { } } -export!(MyPlugin); +register_plugin!(WordTools); ``` -Build (copy `build.sh` or run it with your plugin's path): +On `wasm32-wasip2` the SDK calls the IDE; on your machine it calls the SDK's +mock host, so the logic is unit-tested with `cargo test`: -```sh -plugins/build.sh hello # → plugins/hello/dist/plugin.wasm -plugins/build.sh /path/to/my-plugin +```rust +#[test] +fn counts_and_shouts() { + let host = openidea_plugin::mock::MockHost::install(); + host.open_editor("/project/a.md", "markdown", "one two three", &[(0, 4, 0, 7)]); + WordTools.run_command("count", &host.invocation(), None).unwrap(); + assert_eq!(host.notifications()[0].body, "3 words"); + WordTools.run_command("shout", &host.invocation(), None).unwrap(); + assert_eq!(host.editor_text(), "one TWO three"); +} ``` -Any language with component-model tooling works (e.g. `componentize-py`, -`jco componentize`, TinyGo): the only contract is the `plugin` world. +The mock enforces capabilities like the IDE (`MockHost::install().with_capabilities(&[...])`), +keeps a versioned editor document (`type_in_editor` makes edits stale), and +records notifications, status items, storage, subscriptions, tasks, fetches, +commands, languages, themes and log lines. -### Host functions +Any language with component-model tooling works (`componentize-py`, +`jco componentize`, TinyGo): the only contract is the `plugin` world in +[`wit/`](../wit/) (`plugin-syntax` adds the syntax provider). Build it yourself, +then run `openidea-plugin check`. -| Function | Notes | -| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `log(level, msg)` | Goes to the IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. | -| `register-command(id, title, category?)` | Becomes action `plugin..`; call again to rename. Max 256 commands. | -| `show-notification(level, title, body)` | Balloon in the invoking window. | -| `workspace-root()` | Root of the project the command runs in. | -| `active-file()` | Absolute path of the focused editor's file. | -| `read-file(path)` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB. Unsaved editor contents win over disk. | -| `insert-text(text)` | Inserts at every cursor of the active editor (cursor ends after the text). | -| `replace-selection(text)` | Replaces every selection and selects the new text. | -| `register-language(toml)` | Same format as `languages/.toml`. | -| `register-theme(json)` | See _Themes_ below. | +### The API + +| Rust SDK | WIT | Notes | +| ------------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Plugin::run_command(id, ctx, arg)` | export `commands.run` | `ctx` is the window: `ctx.project()`, `ctx.editor()`. `Err(msg)` shows _" failed: msg"_; the plugin keeps running. | +| `Plugin::handle_events(events, ctx)` | export `event-handler.handle` | Batches of subscribed events, oldest first, per window. | +| `Plugin::activate` / `deactivate` | export `lifecycle` | Per instance: after a crash or idle reclaim a new one is activated. | +| `TextEditor::document()` → `{version, text}` | `editor.text-editor.document` | `selections()` are those when the command ran (0-based lines, UTF-16 columns). | +| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. | +| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. | +| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). | +| `Project::vcs_status()`, `vcs_fetch()` | `vcs` | Fetch runs git, so only in trusted projects; call it inside a task. | +| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. | +| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. | +| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. | +| `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. | +| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). | +| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. | +| `host::register_command`, `register_language`, `register_theme` | `contributions` | Run-time contributions; declaring them in `plugin.toml` is better (they show before the code loads). Max 256 commands. | +| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. | +| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. | +| `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. | +| `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. | +| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. | +| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. | +| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. | +| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. | `run-command` receives an optional string argument: `executeAction("plugin.hello.insert-date", "arg")` from another feature, or nothing when run from Search Everywhere / a shortcut. -Returning `Err(msg)` shows _"<title> failed: msg"_; the plugin keeps running. + +### Events + +| Kind | Capability | Delivered | +| ------------------ | -------------- | ------------------------------------------------------------------------- | +| `file-opened` | `events` | a file opens in an editor | +| `file-saved` | `events` | after the file is written | +| `editor-focused` | `events` | the focused editor changes (path, or none) | +| `vcs-changed` | `events` | a repository's state changed (commit, checkout, fetch…; its root) | +| `action-performed` | `events.input` | an IDE action ran: id, title, source (keyboard, menu, toolbar…), shortcut | +| `scheduled` | — | the `on-schedule` period elapsed | + +Events are batched: a running plugin gets them within a fraction of a second, +an idle one collects them for 2 s before it is woken, so frequent events +(Key Promoter's actions) don't keep it alive. + +### Providers + +Declare what the plugin provides in the editor; only plugins whose +declaration matches the file's language are called (and loaded): + +```toml +[[contributes.providers]] +kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions" +languages = ["java", "xml"] # editor language ids; "*" for any +``` + +Then `register_plugin!(MyPlugin, providers)` and implement +`openidea_plugin::providers::Providers` for it: `completion`, +`inline_completion`, `code_actions`, `definition`, `line_markers`. Each gets +the document (path, language, version, text). Results join the language +servers' (completion lists, ⌥⏎ quick fixes, ⌘B, gutter icons; a code action +carries edits for any files, applied as one change). Editor providers run +under the edit-path budget: 50 ms, after which the result is dropped, and an +interruption at 500 ms. Inline completion is exempt from the 50 ms (it may +need the network) but is cancelled as soon as the user types on: check +`Task::is_cancelled` or just let the host interrupt the call. + +### Network and secrets + +`host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host +the plugin was granted with `net:<host>`; redirects to another host are +refused and responses are limited to 8 MiB. Requests are made from the +machine the project is on. + +Secrets declared under `[[contributes.secrets]]` are entered by the user in +Settings › Plugins and kept in the system keychain. The plugin never reads +them: it writes `{{secret:<name>}}` in a request header and the host fills it +in on the way out (`secrets` capability). `host::secret_is_set(name)` tells +whether the user has entered one. + +### Views and custom editors + +A view is data: a toolbar, a body — `Tree`, `List`, `Form`, `Table`, +`Message` or `Html` — and a status line. Declare it, then +`register_plugin!(MyPlugin, views)` (or `(MyPlugin, providers, views)`) and +implement `openidea_plugin::views::Views`: + +```rust +impl Views for MyPlugin { + fn render(&mut self, view: &str, viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error> { + Ok(View::new(Body::Tree(vec![TreeItem::new("a", "A").expandable(self.open)])) + .button(Button::new("refresh", "Refresh").icon("refresh"))) + } + fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> { + if let ViewEvent::Expand(_) = event { + self.open = true; + host::refresh_view(view); + } + Ok(()) + } +} +``` + +The IDE draws the view with its own components (theme, keyboard, speed +search) and sends what the user does: buttons, select / activate / expand / +collapse of items, form fields, table cells, inserted and deleted rows, and +`html` messages. A tree is a flat list whose items name their `parent`; an +`expandable` item gets its children when it is expanded. A table gets only +the rows it shows (`viewport`): report `total_rows` and return those. + +Showing a view activates the plugin (`on-view:<id>`). `views` become tool +windows; a `customEditors` file (`priority = "default"`) opens in the +plugin's editor, or through _Open With_ for `option`. For a custom editor +`ctx.editor()` is its document: read it with `document()` and change it with +`apply_edits(version, …)` — the text editor of the same file follows, +Undo / Redo are one history with it, and Save, the dirty mark and the close +prompts are the document's. _Open as Text_ opens the text editor. + +`Html` is the escape hatch for content the declarative bodies can't show. It +runs in a sandboxed frame — an opaque origin under a CSP that allows only its +inline scripts and styles and `data:` images, no network, no IDE API — and +talks to the plugin only through `openidea.postMessage(text)` / +`openidea.onMessage(fn)` (`ViewEvent::Message`, `host::post_view_message`). + +### Settings + +```toml +[contributes.settings] +schema = "settings.schema.json" +``` + +```json +{ + "properties": { + "context": { "type": "string", "title": "Context", "default": "" }, + "tailLines": { "type": "integer", "title": "Log lines", "default": 200 }, + "shell": { "type": "string", "enum": ["sh", "bash"], "default": "sh" } + } +} +``` + +Types are `boolean`, `string` (optionally `enum`, `multiline`), `number` and +`integer`, each with a `default`. The IDE generates the page under +Settings › Plugins (searchable; projects can override values) and the plugin +reads values with `host::setting*`; a stored value of the wrong type falls +back to the default. + +### Remote projects + +Plugin code runs where the project is. When a window opens a remote project, +the desktop sends its enabled code plugins and their grants to +`openidea-server`, which runs them next to the files: `read_file`, +`find_files`, `run_process`, `fetch` and diagnostics all act on the remote +machine, and `process:` programs are resolved on its `PATH`. Changes on the +desktop (install, disable, grants) follow within a second. Secrets stay in the +desktop's keychain; the server asks for one when a request header needs it. +Status items, notifications, tasks, views, custom editors and terminals of +remote plugins show in that window; a custom editor edits the remote +document. +Project plugins (`.openidea/plugins`) don't run in remote projects yet, and a +plugin's storage on the server starts empty. ### Sandbox and limits @@ -157,15 +429,23 @@ environment, no network, no inherited stdio — only clocks and randomness from WASI, plus the host functions above. Every call into the plugin is bounded: - **CPU:** a fuel budget (≈ 5·10⁹ wasm instructions) per call; -- **time:** 5 s wall clock per call (epoch interruption); +- **time:** 5 s wall clock for commands, activation and deactivation; editor + providers (syntax, completion, code actions, navigation, line markers) get + 50 ms — a later result is dropped, but the instance keeps running — and are + interrupted at 500 ms; - **memory:** 256 MiB of linear memory. -A panic, trap, permission violation or exceeded limit stops the plugin and -marks it _Failed_ for the session (with the reason in Settings › Plugins); -toggling it off and on, or _Reload_, starts a fresh instance. The IDE itself is -never affected. +Each active plugin runs on its own thread; calls are queued, never run +concurrently in one instance. A panic, trap, permission violation or exceeded +limit drops the instance: the user is told once, and the plugin starts afresh +the next time it is needed, after a pause that doubles with each crash in a +row (1 s, 2 s, …). Three crashes in a row mark it _Failed_ for the session +(with the reason in Settings › Plugins); toggling it off and on, or _Reload_, +starts it again. A call that returns normally resets the count. Settings › +Plugins shows activations, call latency (p95), crashes, late editor results +and reclaims. The IDE itself is never affected. -## 3. Themes +## 4. Themes ```json { @@ -183,42 +463,159 @@ values (no `url()`, `;`, braces); `//` and `/* */` comments are allowed. A manifest theme entry may add `editor-scheme = "themes/x.scheme.json"` (editor colours, merged over the theme). Pick a theme in Settings › Plugins › _Apply_. -Start from [`templates/theme-plugin`](../templates/theme-plugin); the full guide, -token reference and IntelliJ / VS Code porting tables are in +Start with `openidea-plugin new <dir> --template theme` (or +[`templates/theme-plugin`](../templates/theme-plugin)); the full guide, token +reference and IntelliJ / VS Code porting tables are in [`docs/plugins/themes.md`](../docs/plugins/themes.md). -## 4. Languages +## 5. File icon themes + +```toml +[[contributes.iconThemes]] +id = "material" +label = "Material Icons" +path = "icons/theme.json" +``` + +`theme.json` maps names to SVGs (paths relative to the package): + +```json +{ + "iconDefinitions": { "rust": "icons/rust.svg", "folder-src": "icons/folder-src.svg" }, + "file": "file", "folder": "folder", "folderExpanded": "folder-open", + "fileExtensions": { "rs": "rust", "d.ts": "typescript-def" }, + "fileNames": { "cargo.toml": "cargo" }, + "folderNames": { "src": "folder-src" } +} +``` + +Files match by exact name, then the longest extension (`a.test.ts` tries +`test.ts`, then `ts`), then `file`. Folders match by name, then +`folder`/`folderExpanded`; folders with a role (source root, excluded, module) +keep their built-in icons. Names are case-insensitive. Up to 5000 icons of at +most 64 KiB each. Every SVG is sanitised on install: allow-listed elements and +attributes only, no scripts, event handlers, external references, `url()` or +entities. Apply a theme in Settings › Plugins; see +[`official/material-icons`](official/material-icons) for a complete one. + +## 6. Languages `languages/<id>.toml` files use the IDE's own language format; see any file in -[`languages/`](../languages) (`id`, `name`, `extensions`, `filenames`, -`first-line`, `line-comment`, `block-comment`, `indent`, `ui-mode`, -`framework-ui-modes`, `language-servers`, `debugger`). `language-toy` is a -minimal example. `[[framework-ui-modes]]` entries (`ui-mode`, `root-markers`) +[`languages/`](../languages). `language-toy` is a minimal example, +[`official/solidity`](official/solidity) a complete one. + +Plugin languages join the IDE's language registry like built-in ones: file +detection (`extensions`, `filenames`, `first-line`), comments, indentation, +`ui-mode`, `framework-ui-modes`, and the `language-servers` and `debugger` +they declare (each needs its `process:` grant). A plugin cannot replace a +built-in or user definition: their ids, extensions and file names win (users +can still map files to a plugin language in Settings › File Types). In a +remote project the definitions are sent to the server, which starts the +servers there. + +`[[framework-ui-modes]]` entries (`ui-mode`, `root-markers`) replace `ui-mode` for the files of a framework's projects: the first whose root marker is in a directory above the file, up to the project folder, applies (see [`languages/html.toml`](../languages/html.toml), Angular templates). Markers are matched like a language server's `root-markers`. -## 5. Installing and developing +### 6.1 Syntax for languages without a grammar -- **Install:** Settings › Plugins › _Install Plugin from Disk…_ (or the action - _Install Plugin from Disk…_) and pick the package folder. The manifest and the - files it references are copied to `<app-data>/plugins/<id>/`. -- **Dev plugins:** put (or symlink) the package in `<project>/.openidea/plugins/<name>/`. - It loads when the project is opened, is listed with a _dev_ tag, and _Reload_ - (or the _Reload Plugins_ action) picks up a rebuilt `plugin.wasm` without - restarting the IDE. +The IDE parses its built-in languages with tree-sitter. For a plugin language +it has no grammar for, the plugin's component can export `syntax-provider` +(world `plugin-syntax`): flat highlight tokens, structure-view symbols and +folds for a whole document, in UTF-8 byte offsets. With the SDK: + +```rust +use openidea_plugin::syntax::{Fold, Highlight, Symbol, SyntaxProvider, Token}; + +impl SyntaxProvider for MyPlugin { + fn highlights(&mut self, language: &str, text: &str) -> Vec<Token> { … } + fn outline(&mut self, language: &str, text: &str) -> Vec<Symbol> { … } // optional + fn folding(&mut self, language: &str, text: &str) -> Vec<Fold> { … } // optional +} + +register_plugin!(MyPlugin, syntax); +``` + +It runs on the editing path (once per document version, cached), so keep it +fast — a hand-written lexer is typical (`syntax::bracket_folds` helps with +folding). Add `activation = ["on-language:<id>"]` so the component loads with +the first file. Results are validated (clamped to the text, overlaps dropped). + +### 6.2 Run configurations + +```toml +[[contributes.runDetectors]] +id = "forge-test" +name = "forge test" +files = ["foundry.toml"] # offered in projects with any of these at the root +command = "forge" # one program; needs process:forge +args = ["test"] +test = true # results go to the test tree +``` + +The configuration appears among the detected ones while `process:<command>` is +granted, and starts the approved program. + +## 7. Installing and developing + +- **Install:** Settings › Plugins › _Install Plugin from Disk…_ (a package + folder) or _Install Plugin from Package File…_ (a `.oiplugin`). The install + page lists every capability and whether the package is signed by a publisher + the marketplace trusts; nothing is copied until you accept. +- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/` + with an `active` pointer that is switched only once the new version is in + place. An update whose code fails to start the first time is rolled back to + the version it replaced automatically (you're told why); _Roll Back to …_ + in the plugin's details does it by hand. Data (`storage`) is per plugin, not + per version. +- **Linked plugins:** `openidea-plugin link <dir>` (§1). Listed with a _linked_ + tag; capabilities are granted per folder. +- **Project plugins:** put (or symlink) the package in + `<project>/.openidea/plugins/<name>/`. They load only in trusted projects and + only after you approve their capabilities. - Commands appear in Search Everywhere › Actions under their category, and can be bound in Settings › Keymap like any other action. -## 6. Testing +## 8. Distribution -The host's integration tests (`crates/ide-plugin/tests/host.rs`) load the -checked-in `hello` and test-fixture components; rebuild them after changing the -WIT file: +A **package** is a zip named `<id>-<version>.oiplugin`: the manifest, the +files it references, the component, licence files and, when signed, +`SIGNATURE.json` (Ed25519 over the plugin id, version and the digest of its +files). ```sh -plugins/build.sh hello +openidea-plugin keygen acme.key # once; keep the file secret +openidea-plugin pack plugins/acme-tools --key acme.key --publisher acme +openidea-plugin index market/ --publisher acme --name "ACME" --key acme.key +``` + +A **marketplace** is static: an `index.json` and the packages next to it, on +any web server or folder. It lists publishers and their public keys (the trust +root: publisher `acme` owns plugin ids `acme.*`), the plugins with their +digests, and revocations. Settings › Plugins › Available reads it from the URL +you set; installing downloads the package, checks it against the index entry +and the publisher's key, then shows the install page. + +- A package whose signature doesn't hold (changed after signing, a key that + isn't the publisher's, another publisher's id prefix, a revoked key) is + refused. Unsigned packages install from disk with a warning. +- **Revocation:** the index can revoke plugin versions (or all versions) and + publisher keys. The IDE reads the index daily; revoked plugins are disabled + with the reason and the user is notified — also after a restart, before + their code could run. A publisher rotates keys by adding the new one; older + keys stay valid until revoked. + +## 9. Testing the host + +The host's integration tests (`crates/ide-host/tests/plugins.rs`) load the +checked-in `hello` and test-fixture components. Rebuild them after changing the +SDK or the WIT file: + +```sh +cargo run -p ide-host --bin openidea-plugin -- build plugins/hello +cargo run -p ide-host --bin openidea-plugin -- build plugins/official/solidity plugins/build.sh crates/ide-plugin/tests/fixtures/misbehave -cargo test -p ide-plugin +cargo test -p ide-host --test plugins ``` diff --git a/plugins/hello/Cargo.lock b/plugins/hello/Cargo.lock index 2e617884f..2ba582e73 100644 --- a/plugins/hello/Cargo.lock +++ b/plugins/hello/Cargo.lock @@ -95,12 +95,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "openidea-plugin-hello" +name = "openidea-plugin" version = "0.1.0" dependencies = [ "wit-bindgen", ] +[[package]] +name = "openidea-plugin-hello" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + [[package]] name = "prettyplease" version = "0.3.0" diff --git a/plugins/hello/Cargo.toml b/plugins/hello/Cargo.toml index 10ed4602c..28d8c501f 100644 --- a/plugins/hello/Cargo.toml +++ b/plugins/hello/Cargo.toml @@ -8,10 +8,10 @@ publish = false [workspace] [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] -wit-bindgen = "0.63" +openidea-plugin = { path = "../../sdk/rust/openidea-plugin" } [profile.release] opt-level = "s" diff --git a/plugins/hello/dist/plugin.wasm b/plugins/hello/dist/plugin.wasm index 3cc43a85126ebc7d7e8f5fff4cba581231343d82..5570638caa5ca1daf6ad28ae8ea44d7bd3d3f346 100644 GIT binary patch literal 132571 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 literal 70241 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 diff --git a/plugins/hello/plugin.toml b/plugins/hello/plugin.toml index 458abb4b6..54e273038 100644 --- a/plugins/hello/plugin.toml +++ b/plugins/hello/plugin.toml @@ -1,17 +1,23 @@ id = "hello" name = "Hello" -version = "0.1.0" -description = "Sample plugin: inserts today's date and counts words in the active file." +version = "0.3.0" +description = "Sample plugin: inserts today's date, counts words in the active file and shows a tool window with a tree." authors = ["OpenIDEA contributors"] change-notes = """ +0.3.0 — a Greetings tool window (tree view), an editor context menu entry, a shortcut and a status bar item. +0.2.0 — plugin API 0.2: edits are versioned, one undo step for every cursor. 0.1.0 — first release: Insert Date and Word Count of Active File commands. """ -openidea-api = "0.1" +engines.openidea = "^1.0" wasm = "dist/plugin.wasm" -permissions = ["project-read", "editor-read", "editor-write"] +# Loaded the first time one of its commands runs. +activation = ["on-command:*", "on-view:greetings"] + +[capabilities] +required = ["editor.read", "editor.write"] # Static declarations make the commands visible before the plugin activates; -# the plugin may also register more at runtime via `register-command`. +# the plugin may also register more at runtime with `register_command`. [[contributes.commands]] id = "insert-date" title = "Hello: Insert Date" @@ -21,3 +27,26 @@ category = "Hello" id = "word-count" title = "Hello: Word Count of Active File" category = "Hello" + +# Declarative UI: shown without loading the plugin's code. +[[contributes.menus]] +menu = "editor.context" +command = "word-count" + +[[contributes.keymaps]] +command = "insert-date" +key = "Ctrl+Alt+D" +mac = "Meta+Alt+D" + +[[contributes.statusBar]] +id = "hello" +text = "Hello" +tooltip = "Word count of the active file" +command = "word-count" + +# A tool window whose tree the plugin renders (view-provider). +[[contributes.views]] +id = "greetings" +title = "Greetings" +icon = "plugin" +location = "right" diff --git a/plugins/hello/src/lib.rs b/plugins/hello/src/lib.rs index e1009fd76..da009057b 100644 --- a/plugins/hello/src/lib.rs +++ b/plugins/hello/src/lib.rs @@ -1,41 +1,50 @@ -//! Sample OpenIDEA plugin. Build with `../build.sh hello` (see plugins/README.md). +//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK. +//! Build with `openidea-plugin build plugins/hello` (see plugins/README.md). -wit_bindgen::generate!({ - world: "plugin", - path: "../../wit", -}); +use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin}; +use std::collections::BTreeSet; -use openidea::plugin::host::{self, LogLevel, NotificationLevel}; +#[derive(Default)] +struct Hello { + /// Expanded languages of the Greetings tree. + expanded: BTreeSet<String>, +} -struct Hello; +/// The Greetings tree: languages, then their greetings (loaded when expanded). +const GREETINGS: &[(&str, &[&str])] = &[ + ("English", &["Hello", "Good morning", "Hi"]), + ("Deutsch", &["Hallo", "Guten Morgen", "Servus"]), + ("日本語", &["こんにちは", "おはよう"]), +]; -impl Guest for Hello { - fn activate(ctx: Context) -> Result<(), String> { +impl Plugin for Hello { + fn activate(&mut self, ctx: &Context) -> Result<(), String> { host::log(LogLevel::Info, &format!("hello {} activated", ctx.plugin_version)); - host::register_command("insert-date", "Hello: Insert Date", Some("Hello")); - host::register_command("word-count", "Hello: Word Count of Active File", Some("Hello")); Ok(()) } - fn deactivate() { + fn deactivate(&mut self) { host::log(LogLevel::Info, "hello deactivated"); } - fn run_command(id: String, _arg: Option<String>) -> Result<Option<String>, String> { - match id.as_str() { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + let editor = ctx.editor().ok_or("no file is open in the editor")?; + match id { "insert-date" => { + // Replaces every selection (inserts at every cursor), as one undo step. let date = today(); - host::insert_text(&date); + editor.transform_selections(|_| date.clone())?; Ok(Some(date)) } "word-count" => { - let path = host::active_file().ok_or("no file is open in the editor")?; - let text = host::read_file(&path)?; + let text = editor.document()?.text; + let path = editor.path(); let words = text.split_whitespace().count(); let lines = text.lines().count(); let name = path.rsplit(['/', '\\']).next().unwrap_or(&path); let body = format!("{words} words, {lines} lines, {} characters", text.chars().count()); - host::show_notification(NotificationLevel::Info, &format!("Word count: {name}"), &body); + host::notify(NotificationLevel::Info, &format!("Word count: {name}"), &body); Ok(Some(body)) } other => Err(format!("unknown command: {other}")), @@ -43,12 +52,56 @@ impl Guest for Hello { } } +impl Views for Hello { + fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> { + if view != "greetings" { + return Err(Error::NotFound(view.into())); + } + let mut items = Vec::new(); + for (language, words) in GREETINGS { + let open = self.expanded.contains(*language); + items.push( + TreeItem::new(language, language).icon("folder").detail(&words.len().to_string()).expandable(open), + ); + if open { + for w in *words { + items.push(TreeItem::new(&format!("{language}/{w}"), w).parent(language).icon("text")); + } + } + } + Ok(View::new(Body::Tree(items)) + .button(Button::new("collapse", "Collapse All").icon("collapse-all").enabled(!self.expanded.is_empty())) + .status(&format!("{} languages", GREETINGS.len()))) + } + + fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + match event { + ViewEvent::Expand(key) => { + self.expanded.insert(key); + } + ViewEvent::Collapse(key) => { + self.expanded.remove(&key); + } + ViewEvent::Button(b) if b == "collapse" => self.expanded.clear(), + ViewEvent::Activate(key) => { + if let Some((language, word)) = key.split_once('/') { + host::notify(NotificationLevel::Info, word, &format!("A greeting in {language}")); + } + return Ok(()); + } + _ => return Ok(()), + } + host::refresh_view(view); + Ok(()) + } +} + +register_plugin!(Hello, views); + /// Today's UTC date as `YYYY-MM-DD` (WASI wall clock). fn today() -> String { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); + let secs = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); let (y, m, d) = civil_from_days(secs.div_euclid(86_400)); format!("{y:04}-{m:02}-{d:02}") } @@ -67,4 +120,61 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { (y, m, d) } -export!(Hello); +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + #[test] + fn civil_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + } + + #[test] + fn word_count_reads_the_focused_editor() { + let host = MockHost::install(); + host.open_editor("/project/notes.txt", "text", "one two\nthree\n", &[]); + let out = Hello::default().run_command("word-count", &host.invocation(), None).unwrap(); + assert_eq!(out.as_deref(), Some("3 words, 2 lines, 14 characters")); + assert_eq!(host.notifications()[0].title, "Word count: notes.txt"); + } + + #[test] + fn insert_date_replaces_every_selection_at_once() { + let host = MockHost::install(); + host.open_editor("/project/a.txt", "text", "a\nXX b", &[(0, 1, 0, 1), (1, 0, 1, 2)]); + let date = Hello::default().run_command("insert-date", &host.invocation(), None).unwrap().unwrap(); + assert_eq!(host.editor_text(), format!("a{date}\n{date} b")); + assert_eq!(host.editor_version(), 2, "one change"); + } + + #[test] + fn the_greetings_tree_loads_children_when_expanded() { + let host = MockHost::install(); + let mut p = Hello::default(); + let tree = |p: &mut Hello| match p.render("greetings", None, &host.invocation()).unwrap().body { + Body::Tree(items) => items, + other => panic!("{other:?}"), + }; + assert_eq!(tree(&mut p).len(), 3); + p.handle("greetings", ViewEvent::Expand("Deutsch".into()), &host.invocation()).unwrap(); + let items = tree(&mut p); + assert_eq!(items.len(), 6); + assert_eq!((items[2].label.as_str(), items[2].parent.as_deref()), ("Hallo", Some("Deutsch"))); + assert_eq!(host.refreshed_views(), ["greetings"]); + p.handle("greetings", ViewEvent::Activate("Deutsch/Servus".into()), &host.invocation()).unwrap(); + assert_eq!(host.notifications()[0].title, "Servus"); + p.handle("greetings", ViewEvent::Button("collapse".into()), &host.invocation()).unwrap(); + assert_eq!(tree(&mut p).len(), 3); + } + + #[test] + fn without_editor_write_the_command_fails_cleanly() { + let host = MockHost::install().with_capabilities(&["editor.read"]); + host.open_editor("/project/a.txt", "text", "", &[(0, 0, 0, 0)]); + let err = Hello::default().run_command("insert-date", &host.invocation(), None).unwrap_err(); + assert!(err.contains("editor.write"), "{err}"); + assert_eq!(host.editor_text(), ""); + } +} diff --git a/plugins/official/ai-completion/.gitignore b/plugins/official/ai-completion/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/ai-completion/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/ai-completion/Cargo.lock b/plugins/official/ai-completion/Cargo.lock new file mode 100644 index 000000000..e7bc7ab92 --- /dev/null +++ b/plugins/official/ai-completion/Cargo.lock @@ -0,0 +1,334 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-ai-completion" +version = "0.1.0" +dependencies = [ + "openidea-plugin", + "serde_json", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/ai-completion/Cargo.toml b/plugins/official/ai-completion/Cargo.toml new file mode 100644 index 000000000..255622aa5 --- /dev/null +++ b/plugins/official/ai-completion/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "openidea-plugin-openidea-ai-completion" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..ee5484557795be388c4406c49825feec70c63114 GIT binary patch literal 165922 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�LzWT zcjLV1EgrMQDyTS|Ejxst25_f2XCSZ93ZFyXpmey?iSA0+K!s4=X|7a3POPBGQ2c*I zz2ar%pmAZ{jEt?R7^X@k{n#P#0idpGVOK1Y7woLmrV=-hO$mr0{hYvX(FNzLX*$Om zk6*|E^E@dxQ5Azx$}vC}X=-@P<=44O+l<$(Un6;uhqJbRO}G56ftGveHlA%Q>m;HY zU`qB>KbFv7L@gT7l&%|1V_jfBmH-CqjCA$Ot^Hhr9eAG1GPZ_(E}<bYNOTA~mE6xI zBv#mdE<rB7el9_UL3~f)SO?GZz58<sDuvBu>@4)xx&A<oAaCLESr_FC2WJ(2<<(1k z*apDM!~*)2g(KnYS;3Y(qNfq}GX~h^OE6|hQPn~9_~Ws!a#rwMYR36Pu4YWlQCE}p z&I+DqA6>VuXqd0KB;Rk{BotoOVl@)uQ$$_OAL{7&FWq=#0c?{25Z0mGd&Ca~<wUow zf_YuAcciY%9)RQ}!KfYWcU?#ZnCBg>(3nsQ`i0Ld5<a+y+d0*V%%5>PC)pDc*?Z@w z=!5$$3=FBrYX|GQQ|uE@*X6wA?0c;jtT?K!5CxZmV3BjV9w_U`x!g(_IhQkkA#(PV za&%l#qN7d6BfO-`TQUC&_52cmC<=wd6AP*Ci63;okUV##LneF}HkD}t0XK&2<#+<D ze&FGTu!WCmSUOqP0!!?4J;miWd3m6HYIc%D>*N=5avU%Ax7mI((>g5=vhm%m)3fDz zd0Bhq?iTLv+IVN{DIIxuiXrspgW&30oZ2}xTRzh|6>Ly>d3H)RuK%CEvYoJl0CX>Z z{z`aYEQ4Ju*{QlPNJlVGo@S>i)p(j{NIgmQ3rPV`?S&l3Cj~$)@*+C(TiZry3Uos% z{t$>XXmKUIXRBJLuv)JuLP(`nRR~_GNNQOVCUvxecF?ay?bEXr4tDkE-H*d|l1chj zF=HUrb&7g%=z<mmz|#B>Ef#kfHkoTseh+LCiwT>j7$!9I(;4C^f^bZil^H~sX%S3- z-V$!J(;3)GfLzf&&C9It9M1^PQ{k1I<{xCI3DGM|zdK~P*>yvTA@&#}=65`dWzy_4 zVP{;LoQ5nosX{hTu0ZgZC_gP@PF-*;i8N+i972SzD)_SF6L2W7Y70Srqn(|WzSm(G z@yAE=hvnymQs^27VqJckM6)(c*%E1bR2^&SbYT&lG)m5)=Oy0(?Ct=WT(-)0@TZ?c zdfx1WE0f$E9@mDK&|t6@`@(}@BaBte@eyvt9LZ(4Sy!G9b5%F|Wu3-@=Dwhiw%W2D z0yD`7`GND@-A<(m?cG*Q%M>*&B0WqbV(Kn^$h%FQcD!;doC`ND2D$XOiW4VG<NBm| zaP8#GAbDorxX3In9#{V1Q$fqH1vSh=wZRtRX>!!A$Vbrcg7C<%tk~nH{PO`Qrk?>p z!c}T=3(PC)MnHmVB^RTDp{6B^QLK#Bv|ksoN7s^mErc??Ex}p!9KENRmwNh6u?dQl z@X@=zFoQmP)O}Fi>7%E@X;87YTL)6<2IasF@t}+tmyYXGDZ4&^Il~HRzVB|4*s3b8 zsMwec%w&gfM~J%nXb#5zKce{nUcTo9X%6hHz{wx3`J?r1DCDV+I${d_>x~$%^Mttk zbDa^_pBWH_aymm0YzibnWYQLMxg+J&ksy7Q{AxK9PImDt{`E$M?O1wLLrxc#b<zWV zR1-mMV?Z=e8kJj1I#4G~EgIDDzviHr%;+B!%F!!YGlx3~6@K{eC$3}=A_!5A5d<}Q zvsbH!Lw}OqcwYCvhlBIR8s3s~_y+g80ey0(y<$$+;=Jh&T`T%?hptt3Xzdp>elF-> zJ-t9M;PJZF`Fo;zn8u?GTKNL25R+Q_InJzF!~$t>Q&@xr=86w<?`>T1OB*}*dYG>q z60%b*C|{~|d(P*z5vQ*dQ;v*g!0%RpD&nM97J-uXapuB@oYii2!a`0Nv?Anm0PY|R za4-<HT=Ui=a<PBTcNgY&x!+|7^Fw{V%V4JQ-&^vb$dY)1z$J|5OaT|aX!KKHzeBFh z&uCQh-^Wo1i=WX5`+r8mOjcI+ymq6bW#LbuI&y5D|9D%^$3G2wlJv<MSQCXP4@kc; zLgG}S!z6@QyBSSJ3da)?H0gr;6>KR7=x~I#F51t`cl*sdCV}KA#QE0eo^CO`=09S~ z5@>mcin+Lqi%hX{!ML~b9h8OOUg_aL=^AZW0S0;^qId9F8ZKf`#rfPnaz45r@td_v zmo98ew@vTcHf1syns(sqDGN8^HZcGmWERpK5ro14lA0QVTpRr$qK2R^k^6$wY?WW3 zS8?an{^Avz^i|&yMK};3Q0rk)NAiL~xvF{4xTaEG{aBtC@$@N<OHS{U77)gJ|0yek z-=kTV(Er4dJa17U%6~)N4fjok%7rx`io;IPQ+`U1rd9=FAui}|DBya>Nmex`qAIyN zthrrT8;ouWMqvc*>YFgHb|>>{b4#uw*y^0`ZpWvqCGgcbl-ZBQb=U*FNH6GXf*$`- z(4(*geO-KEK-N2|NCR1sRaVkt+486fJU1>q;~_C*4e3Ys<o6BbKU9_>|Em7d3f{Nd z>0iVf#R=#I`dy0y0UpjTw}>laDP+CP9xX`?{ssV$J{uN|qO4)IVE>hNxj%T3Uft3j znt;*2`RVunZfy>bY{EGIMld&<2hU+TlR?`^%7a#pSM7?shRzC36BC{l==>@Cc+NkS zr<!uXQ*CmA5oI(mwh|xMP~759+bGSYy*p%$?`jkI99E2-bUA*@BDXvN8AjfDxCH(x zW1N(#tuX@e;vmcAx}y}j30W~g`Pf#$gy9!VfM$tc03%C#m3()_(IpYLiObHJ3S-C} zUD+ZA#`1Zq@E_1#S#iYf!U8l!=AedlL@{hll_8KEusL9u#lRMiDMCB>QGrSEV})18 zJ*uOP;xvZq1^q+rZ2U_4ZW{{cxK?dFR55MlGkG~n$}_S*mh<7Aaz02id7yT(GJThn z1dqL0K4uApDw2#f0JlSniEPda6hJY#r@Wott74fx{p_M82@^__8+4gItDytz@s?E4 zC;Ui0xgVFb@BFxdzN?tBS!%_V?&SQK479VRb23_tBlIA905eT;I*n=e1`WBzXbMlb zu~E==5g7gpGFa^xi`d~VE7jXjJy`u3S+j;6)$@<t+aGjqztg?_Zuj<wb}Nok511-c zUizrL9Q7~L|7nloJ!P=@we+9t!&4W1koL)j)9=}5S}wJ%dhT=9NFT8Rrw=HQetYRp zKeA7!^wmngZ?DZ}h3VgkS(Y`H`9kTp-1G8IrSuW^#+Uz&dy1)(cFTOwCMWNXx~D#I z#AC1OkQUZ2iy}SJpZxlz=4)-W4l5;;n70Y0C=b%ZawBZM$99b%Z7g@fDwBv}2Z=CW z&7*ndhi{09jkG#ObDTZRku6%>oNS0Rl;tEz5`paj2Sj@XX)|cjaj@sd1&~WF;PObN zQc)_EO{VDBl3r29%hWGgSniRuFk0<sRor}$Q?req4Wda2YEy^;$M9N^zD?b?xGT-@ zg^lO;cxjPq0Du{~mi?)fvGh+2l`t%Ty5nfRV2uHKN3CW-<J0=}S2Js*g~He21~J17 z2Ag}@)!p=r>!utCh@Z)?MTOv@Nw`oaP_hL=wfZ-n1a^Fytilo??3GZh=|dI4c<1xV z%+{1>m15v12~CbOJ%BXv|8rNmU&UjABu~4u>Pp#`=K*K1Ww$0%c*LZj7??lIS%WV` zW7%Xkw3M`QhdewthOj#ZeoxfK0Pq@v1%hr2Sf~!vHG|q*3puU8DHj%~Yl0d%6x4V* zf?DvxM^J|UOHlJ<P~%uXkI@!T!#@T!h$*0kWjatF4r47{>8zUUnq0bxrk?exc5+pg z{07$TwURu4mU=XG@RtR$F@AoM`ADRx@^z*;-8^SLhJw0Ql|+K3_{T^hyKfAvdDW6> zW%-!5LN~wmZj5G;-TpI!@IdDmRgrAZY!L+4KSTOx9E4E8J^x%>Z^xgQdc62AxU#ta z@#DdNHB5w42i*iwq{(&t7?4hR7IR|V!YO7pOmqJNE2<D7h429FZB)Rq?0S*g#z@|4 zx<1xUmfm-5>RI)vXI)JNSSRnC`ugj35~Ux2mZKc)q^+(NP+WlXV>6tR)3&()+|^qF z`YCWjpp5N!V(Tc*hh?N$k3k@4nxygm(EnPoVJmrW7QXlLc4aEN`W6xOmNo~J^511y zyj#9|f(H!g&@(WQ>?L}|rceD{@uLIQ7@Z+Mu}hh_AL7yDn@#E?$YVeJZc0fQrB(ZZ z5A=c4V~Xt5jba3>v(Z4(^`ahaQ}NnHtNCapbOtfKU%k);r??X@xPSyR37R^tx>hoJ zU3a^FMnFL8pLi$}s_5J{M~4vWAmL^P>;IG5)e+`in}k+F`lZd_WLWq)*Vi%AE8U}5 zQkH@h8Ih=iTuOS`j(BExoeV}q9Kv5;gP*ao?u9p3j-MwLC!v*OW%3(iW`<mY3IK1= zBTf4FP2i&HWDvCWMx@~h{~L_b0K0}LtvgoinCcXd7XB`k(D7dC?z_gM?)`@tYv(tq zuf4YX@@FA}ON&L50Mf~f5p%;`H^iMhL(q7sioV<7O~Bp2Oj#a06D&C>ua3ZZ#DE<G zqpzL`b~2P2%VP+Qizu7NE{Kn~Nf{4f4>b4oLvwE#n!k?2dJ$BV&X6w%@9CE}6gJy| z?Wo=)XJJ%}<2hP&#~$_eP+oB*m<wh_r91FfbNiIAwubUwpJA14h?N-1U(VK{ylQ*Y zp%QhKz8|LL2J0{30Msy3j-rLuRmvxkp@wGcdqB~rw+Va7<pgBOuzEBJ5g{kLS8PA4 zz%omN7bii5(6FQ!fjbvxz(|zhJ3D}6iH2p@wX&BY!!F7kPz$p&zQ+@WUbXd1%Y4<W zk4*hr<UtESQF3;N5=4>e9r{sXDZ6Df7h2iE;eMRi3?{ltY<e~q*kiLeUZbWG4W#e2 zvHMjzRK}OkbO(1jKQ7*^!kvXJjb9YA-D=Dhc_!oP8Hond5BlbKw}D2`HS4($BLn9| z<69xzVMYX{0isF5K#e-WCVUgFM3Z=YuqqbV@ICZPV3Ca`9^2fNvCU$E-R)svyJ6S{ z3$#P|hC|+fibUoh+!pY`7eY83!73D0bCvufD{u;o8~UU}6}4(K#z3shRSBbn-5DiV zw(BX;xG(A+E%H$LxHuc8ja+$;gEN+f>gYDfhLR^o;C#F$lMS*2tzmFu$O@foSaPyO zlHE4*vIj~ftm&{T8=+)_wI8iv$Sw_rY_MalLCYLTLuI8bSRx?*iuDstM6@V}W)Y0v zcY-0lc?n<H*Z^HMvB@ep?#Qic1HSl3SWIts#OH0nfp~|V6wxbgI)by2|0g~_=G4n? z9Wo8*II-M<x#Sw!L&fUS#^+1eT;Fj_952P<l~&@lAhW+F30#da%~8fEc-}Mv0>{Za z^$gcA<Lj_n-<{R$r@~GDCm@{zF6Il4(PC?FDhhrPH{ck9TRFzkFD_)^WQ;5{RZFaF zfIwAEs_S3%xtcAl!rc~~pi0hwu~5MiTm-N`gw8TM5+qt!&xiHiq%MP)0M_&t<yvP^ z-r(@*F&^e0b{L-kq}yBxkMVB{nTi|G$0mnWJ=EKT6CF7uT9+I;ba^=x|4HjHvR*la zy}NP<d-TiUqKYLstav%B$d~HMq2#Cq+1y)@8F>Y0cV6sxXJS_|YRS?KDPLQo5*Oi@ zushf-3YtR3p%-=FSfgX1nTto+Kzf91uZwAmfy&b`NX!YHm+LJzVF*e~8d&ydcyczC z)g?w~CG|)GQU%fGF#T`GT}`0PQq?u``cNwhhC@@s?~&*@c2p2-DMt*_*@PrqHscLe z?XU<{@w~zN^Q0kcOQtR_z*2IVbSt9H5kp#w_)R!SrXsuN_z~F1OVT=C1wVcsO6Srb z>4K}8KWJ+l9_DWo7|9@-c+-QyXUndR0bdssId1~O7~Z!}%f(QnVa@>%>;)D;+K43` zl84Q*NO*+$gVvT}#1Tc`B6aES6#%h?PrjJEbOs>5j26JJhO^bRr%|~9+1N7i02>F@ zg8VdNYZex&HWa6JM|I_Q_4sRirla$m6U|iP7F!8g?e4y6d^z+H2g(4kQaCCVZ{il1 zSxfSUGyxVxLRRS*ZlsT>*a^!U6nr`{<Co-BRNevZtY>+<Gcm9`^9|&2h`m9a+`x>M zarKBK#FmBsiIm@b^AV|HOmlvl^E<s_@Tw!o54jQ<CGP4e@or@*ebyK~9D7!Fd3uEc zb519lzf_D(p1I7ud_9d3zY2tF2Ms%u5nvo@<iL&4_P)jw_D$9`b9a;)k0#|&NhHmI zM}6ygkK48xndc^6mJd)jV{Qeo_jVi!Ao5Wfrc(G;iO0OzakK6472CmO?0Gltf=cc( z{bZKCn=pua6>%jmT>BdxuDl}tQYrP^n<jJ2;FDzH5$zcmkR%Jy`9b=2<8wgcbODAW z7%fc(amaK8=pvgFa%R{#KpC?(?g|Mv5ucVNHf~{esYzE~4eIA!xZp;zco>_6w=7h( zl%>bEAtP~KMAW2_{vX@vA<Gv6kmKwk40G@hCcuK>N<tSq1{)4AeI98yi{dqB`ttsA zjejh%JIqjd1tx)DmXQk%x&%lppq0fI|M8mkz$FB>z)#%fnvJ^MN9vyNtfg#@IhQoz zLqdhpu>&vhD?WKHLPBU@n~1G@XK18`2n(yUAj2;Og2;_=L4jEw!etH7YO)(pKw1Uk zV2QrPz*!MZRYaxTO(icU67|CLL*j#I(%fN78_FFXL;t8_7EY@-`|+Su>@=Z!O-{vP z*-(6-q*$!-WlzHU5I6Q{xt~!#N|snZ#-Qm(jSWk=$wuQ`;sH-2F~Ji(3SD{iZsw(N zb=XZWchh8G8qJPdTBOE2XvFHegX%&$He?UwBvVRBf;gk7mY~ahXH?>LpPv?AdELP0 z;wRC%$+E`lrOLb7E_B*XBX&#==Kz-ew{r_%741JF#<p~Zo>;D`C#_HsOqvwNAJZ2U z3jMdDJd}9Zt20ze;%XOsdO6`gF|_O01J`@wb;{v-pg>RPUJC{X))=xv(`h@AoN#AT zSZE1Ed<K%{m%_UFspJxK7<oQ_{lY@RUS#?NU)UpKWl)XL!UzIdeu`sY?QcrIC~$21 zRwu!eMU78~aDs<P=O{)FC>U*lT;3Y#9N{9GlINpSU1WY4zVO7Id6Uwxkw*L}&*$Pl zTUE*XEHL*^zktOZb^&)-ci06OU9~=;T5fH>YLP*tJ$M51sJ(wT>$4Jr&6zH%@pz;Y zV*Jx+RO{~2kfY{D5l!l!;1z1Qc+`qKN)ZXe>~4nl48H_*V7M*}L)@(1!LTe)KZe0C z2LLFlf&;Xh*CQAbb_M}Kr0X0QNBfI?`$nL33$?t>N>?uH>=)MaFXM@Z^boEKw6?ol z*X(g!$@UQhCTBnCG5cRm|1|hR-~KmQagF)TzvElhxQyGqI{C^?N91C}>DP=br1XaS zN5pQE8aur-jb6+DnX7(vIX`bkVCBQlWifiB6?}b=p$FfpCM`i)kl8R~lPDezk;_B! zwMCG?7`?cqW|UtMJu|Fp6SpH0gDExg4cAE09nPXgI7<d{s|_OTP8+<#*F*=Te=Dfv zzGYJf|1-FYq}25_y-8oRm)cZ?DQS;QV^8EZOuz1G>sK^U@_6}h8H;Czb-zU?3K(@X zc}APC5sc<k>N|^yFCigD;q&D>Aosf8+R4@)vyM?mRcuz-P9w5En!1dgKZUwA8F$G9 z+*j#irmji)H_5}5QG#?X!0!^z1Gz{UNk3f6D^n0`{np7Q4yecu<oSk%5iv@}8LbP> zXZZi3`4(45dv}NoTEzUV7=_9rA6Q9zq_Y;WgRGznmTd}N=<ZM-TihM$LoM1g*CCHR z9qIDT0&5%`puO|#AwZ%J5_(9lK!LUKq}G_)9lP$>aLBvhg`0w9Gz<u)Mj4Q@aoD3p zL+K8}Vnk8nC0*3=M8|bTQTOu<<f@bpiY2`)r82bvpxwC!MoJojV;1DGxr_ufUaNLE za>I7G8WBtdgfmhA;6MEz8mLtMejbN5#ap=$JiUYqO5%KQgO|f!`s5YE-YHku?zXyg zB=640g%|&7yP{3;h!aG6=`&NjVu0>fI$*c_v(Y`V_bTG<jHfnIgab6yvr!0T9zt;= zFk$-FMk-&UJFth#0|UnMbb_0~y~T}EBxF}>tT7JV(qWdK!BWW&mP*RKs*(oVGn_?( zWdsz3q7DlVDU25bJYc-=7r3<_#lvwi6q+`WlV&6AXpZ&L9JK5-2WLPz&B5eNWOHlU zYS$;&*79bv-@aw0+Ylfre^VA1uccM0O$#w*9|>_soT=!DJA}xl%8$={$VqkpP#d!^ zC`vK&RM&mzdVJDh#wl`_EJn0(@ESkllScNEueMXJ8nd7a%$?E)RvFKbFy;q09g!sp z(@(2LG-+<FA3en=N<ff)#+vgUhY&-U?+r+A1C7RCL_O|l;#?imOr{c*UAKuO`w_zs zR*2Joaeb?h`<Erq05{Ceg6+JljwV4IG7G1)M>GBSAt1&}S}w0Cus_I*nq}S&462Yw zx%ymW!@q`P?sclyC@xo;X?l7b>UqbeXNNB;(-QHFEb$SW6!tTM=9p>Q4tOKOqDdKa zshtZ&@*OCQ<H#|1T;1ztlzjkfgVZk{LH&yV-6Lrdw3lZFpz64hjSrW8ZX7PCIK&zy z?ev(S6-JXW2+YW`LL=Q+5FpPB8XAEGQ6WTHo1iUBC=O|4P50N<1wngg^)*7MoMFrC zYINvMnG?uP9jSWPT$fv%0|tWrL1{f0>%qUj`-*rYs=u=OS~b@WNfj=;NUfd~jAU4C zo|8-5bJJSZG-_V;fC?$HC(G0pRcy^S_oM7Tbw$8VR^N^J03xlT=s3jM=vta!{pnc$ zv&YZ+|4t#;<B3pcZ#RyFvrBP>xZ5W;T)1W@5WqU}AuN4h{O=+QR@Y(OQJjRb$|!`o z<@`qxP_H{P?k`?KohprBJ^J-Rs3y9%kU0H<T0uf^8%wcMNx|+o!7(@;e1ec)U+L*3 z_Bz1Up$6$66lzqvHc0|X5H$h|zY8tedsWE_iOFv(a6!(K{HqqiBnpu<Uw@`Tqyz~h zpppBZ<ZtD-?kdUf@NflBKFMFlW4$soG*lg`)rX`jFxK@pnh+Bs2%>c1kfQ(*euk}S z3xR~Ej)Xbka`U+ANelE>tRGK@XD+U1Cl4J9P+YwJ{!G;$t6p<)y~^>xIlQ=Day<3y z_bqx68Xr$R7EAY!51U|ulYY+l-SReMl}et)=iLXTJPUy$!*R_>_ouLWlev!HG7NRn zhWz3UtzOV;^mk=<ZhPkg<)7rhbK%F<@ezOnw|6GJPyJ;qLX&LEun=nyJK<)*U(7hQ zZR>Jv^=oBhhyi%Sr>Ak|r}A)q=FBiy{hMZh_+dByV)pL5K2HJ4Mtb%1Fqmc9gh%v> zdt~Y_*dqDSd@1g@W(!6If?;C-h<rISY|2bx@6QZJT^_Di+HBCa^`eq5Tt@iyc+9%s zkMI+3vUd3b>pA?pM|EC9#!?MiL721ZK>1Tch$BGIWbe07&>AKV(gzk@-D|i)q&Y6> zT)uO6GNSt&933pTSDA~-76oOwr8k)b#2XNbv9He^8TO>ApD3xSdDW9@7z5p|WBFS{ zo=ybDqLa9^OBPQj4!Z53la-AM!Z2k?)h?ZwT<)P${kZ5f0!bkfQR@Pcl0*9AKZX^F z`fM|<w3pLlH9u}5KhES*O!Y29v$TL#(CB?^#)vnpdtd@5|F#(kauC|FtF_;@u3Rj} zNfY^*{zaG(ljBYG(OnK80f_Mi#~UQzs0&TC0}Xri`k_%|B^Yo)2_t+HskYkJ)$inN zi?v*2d?^RFv2eu|8AcYmK+MAW<UBbhzNcg6iA_?aXBKrV`>OX{G_c!1oW$uC-*aPU zalDQf>LNq33{b@j>QlbieL=d12^MS&3_F1R&18gTv`zp!bWx&}_|wx$Oy5H*c``k; z3UU}C{UgyHxNP06Dmk@v>HrAMHuXbzJZLOIC^UKqgMJ7-0z2Gf_#)3x7WF}J9gpo( zdZX7iD9oX0W2Ay+FmS%`5IPL9Z54CHN(Z5>U1g3H@J<>F1IP}QsBf?mqY;5+iI0vR zy!LLQj<kr5q<)t8gi|walW%fw(na%cTYCJv$U8}q@&be~i0$#N2mdJ@#b<_!m@LWO zHI|K^u2EwBL5;pPDJ>#3g^ePU$u*f(wBM3Ek!pLkJGDSt@RT{dh8S1tIaMy6gdX89 z5k$Kc)hKK`r>I%cRA9dFibI%89r>%kFn`S(ZoNO4V?oetzHr4W4q?vhLYR28cF+^- z5WHx&mTu%jEaswc4UJRZG7`HUFnbcq%XIMF(%o>*1r1%Mm5~Nl)g47l#R9JCBDe^C znUMs7A|8Ll&FU2#NBrwj6NUw~PdZWVg~Ni)_5hq)1}j8qEaf7KJ3BbFvsCbKGCO)H zCsP_tX2cgHOPP6s81R0p=5S?woNLo1SZTrxt$b_O$OX^?FHEX5Lkd6PmG8zMvqetC zzFv49>lw=Pkbuq#*)a}RzFum}c9|ymSZ94KiCwqQz$OQYppgyJXUrY3@__DQ&0Upu zi22nyfX@O022^1hOkR=r3B=;rL05Lgg}^Dc!)$AHQdZ-bn;Q98Ob0`{qW;{K8riuk z+gdMG*GGAS>7#;^W?4i+Nrewuw}M;81#7FW><%=$RV=gyGV+76#AqkysJitn9sjg( zYiWs5Z-7Zxf>KLp#@b+0vsV>O402aYTiG5&fM06bisTA3(t-lE8rZ&B_#nzbt-L8^ z?~92+K^odhT>7o$f35fkOxi5Ci(Z9O!9@H95ViaavO}suGo4QO4yphW-XY&*d74nr z)ikL_v8>jaR8w-KQ?U%4u$C=?&ZUQNUZgCTYEihYqstfrI2}--JDkuBhmt2Btt4JT z(Yz7NEJc@6ztR)m_Ns0R3ZRpaZM@1vKEn&xeDWt^8(0EQfSCqJ=i_yxrPDSGGydr) zd;nJewK#zc_yE+w%k){xf6~td5$^@faq@s;8LLuo1FL-8Jm;k(%LpXMhFVu!XGY`1 z5~#I&#LZCtguveD%Wppn0#ahtp_ohT52i0h;)Hpv#AF5^IR{Z}2~ZT98Yqq-y{z}i zm$qw|59Az}4)q(4R<MwZ=is~8EAA|hI9d(DN)#u>4uqEA;`Bwa2v9tB(-B-1^r|IQ z83vy@k<#+j(soP$Y_=&8`cMB%%!#`qR4V8Rd|p|Q7fr_bqt+zO?hoBfuo7Ka$%2)G z`d}Jka36{QtWjhaEi8quKh5+W0nrO6%jgF;0wqHRiPrja4?2h1x?DIcBE=YkWvUn8 z+tRg13~s`sVkV)35s)*uBOJikW^6!1F`w}FJ<W8{8ZOm{WwdPt&|i3HBdokkCgzzV z;kjVm3=K+gW2ACODBmbpY3qjd`3&AOn=-+exUA7r9y~`gf0?Vl$`xR~%dgeYBwnh7 zJ0GNE(p%KKMQUw3%r|oWrUJ6`OZvv|wA-m-5mnX^zZ*GZ(WJFHTmghaD*y)2uooM> z^rN$ZXcY?()UF}v0YCu<4~LhVL2voPWTHu%Pjo&(032nXAeh4NH#0idu6+c~ypG1Z z;;#5~O=yRckqhmE<iZ*;p-M(oY1(h~kW(_I!D|Y_WL*PbmKmiDw!Eac)f|}&Af)sD z&G&oSIj%M^e8F>=7tqQ{2%kJ#5iRs00TTiSJj`mo4qrQ%DZJnocX~cPj-Ft^FyD#S z26=EA$-_J#auW`*Jq9m_scGB-red^Sh<C*2<*vn&p4z>Gw4%pEmZ;rH#3hXn+Zo^o zmR5Aum*1r*2BI=sB(-YD?%n=MzhSZ?;KtVaGf?Bv%0Nc+dRpr^SXQhq(su|mJQsRT ztgm*PgS4D5Gf<P7<qVFxyHfi?rC<@v-&Bax>gm<sv9G&Nuw&0J<<^dF4#dQR2GK92 z*GD~mc3g-rg5-}m#CYJ!AvPWcA`8t-Dh<%bv*i5>2_qTlB}}XRN)m=>vu)J;LzrG3 zJJi*my0aCMkmG2kgAWf&kYCXwKgZb&NJ<K^QO2jl(Qm1B7xa}D&q<Z^{`=q4aNjM7 zK^4G9g@<t*hWoEf0|lA}N4H?GoiESIXlZGYxk=EiIW)gZM$4ZHsU#Mw=1<(5TS$i4 zRdfE_#XdXM9m<j--nHsq_CqnN&UoOI|DRa(2L;(tt$LdjmQbyw<XWpCan&?EQo(uy zzfRrZ-3e0ucQ+l8XIZj7@+!3^jk<MNGnv@L44GOjd<`j7!ot|8vo3-IB|V^pR@ty! zv8S)2ITvzEj!SP{Rq1Zu={g3^6R_o=a=KvPvjQ#s7BjXLkaiib<1WZnJ^^<@lfK}S zL@VtRgv;S8oXK8;N~vR+QysU3u-QbiWJas}nMTr*Ij`x&vF6>7g7DPh=G#U#6e$NZ zu&ke5ZmjMvJmGG?N@!@GfkJ*T0Um@L=R0v>mMdl?A}dY`_{W<drt8wwYjsgn#Oe2( z?65E*;`A?#Gefw>BBqofF_#1PkZl15l!Bv#y6RGb>|impq>mAMhtLDD^Qk<{!p>lr z<@|I}X#6PCC@l6;N$Bs=so=v!LlrM~tVH<levi(W;8cnK<@}yUOIugbAKvfroFexG zW@96!{%CQR(~sPEiSteo2R@n5aqg23KTXMc4@P3AiDQz^I)y=EM+Qw|M?za-H-y+} zCz{(R!%{BIGJ3J=dO?WYakx{gS}OTcX1<Z0ZYxW-Q5v+y3q;>_*1#`#I?iS!RbB%q zhQiFFd!+&T9IFpk5_jfGe7O%7w5q>wJaL|#duRcmoJWky-$<_rvktppuuY<qvj&3D zRt_j@YI!6l-wFUw{nE*HQ9MaV?lQ*gpoa>vK5GXS7iQJ<Q;RR*Lh|%{lvJF4rRdST zJDOG4XuoL1UnafXZ+r!vGor!s{&J~eXG5H4<ZGmEt+H+_rnzFp%7cbBSq!a$4q{;E zxLqvbfan~KRN>TAgYyLiI1W}(S`f%&Q)}M#+3<_fu%|ykNjNE%<||1oC(Zb3A#7K* z4%Z$oYuB_A*Y5YXSx$Ps6svv%3kK66ZcOT0hF<XO*Cr-|>@w~6usi)inEl*|R!iG2 zmk6ssn<c+!uqT|f@{4R!$Ih*dSh=c>xuYv}Yr9V9v<KIW!(rUvyBKSSCVn}yTf!(x z(-7c|Z3^Z@&9%Y)O#x^TVpeSoe%-yhlm+7A6eDFBO(Jl5ml!}FJHL?GxFuX-f=^LQ z`llU^!PsMvCHz<#c-3*{<s>wm%fNT0FQ7*8D>2H{OLl|w?_47m^1A)T)&$Ir<;L{t z7mSUK`&%xXrN(~*&Ip4p<rSrWuQms5!O>wlOfB2Sp2i2mzHM&F8kfj%K^o=W(ixz9 z!Tg{GDCbarQh6+#^A~G+iBy;Q2pWGyCt>hjD-P^EWY$o4PXNx!<>EbzAV-b&c92kl z+@OfSOPu~#9T&{2wO{LflIggP5qziBhVmqeqDirUi)MBABpxGpB<%4=3w4GS(F6|{ zCU`V|!VeBq%?WDRL{^MS%OcCk*`Y$%8dl|i2sx`dRgLXg7zm`9i7NSTq~}@Al2y=Q zNVXJ<EleSuZrsWyX?lZg$)dW0%j|%mSi70}KZv_Ku1wPSt2nL<J`2n1UHD03;o>K& z@cacO%c5*o4*1k`noSHrB>2PBHU70`wg~R@rL>g@{T5_l7I8lIcSIypNV)wGF2d@! zT!E~qgJL44<d6cwibyu|g>FL<Gr1aUr;Z!);yTngZ|1#IkB3*b>eP22VSKwl5gq81 zv!Q&Li3^I<r{46zZ{DZ>PkS+alpbF+qVq&5OnE6^t>sOI0g4P?o+Z}F0U7wL3I?#< zHb%_48>1;4h@rQ!Cs9AVQv5;;m44U}I@lGb+G!OrG05NyFZ~D{J;WrXej%<;F?*&* z?E)xMxABGE87R-4B2V`p=zz4APQ_&WgNcn^Xuh8Q+EuO0WK{g@2l(L?)8v{nXIT`B zw{5AWj%Ba~oSJNPD`jCZ$9W7_O!>z~X`n<JSY*T?#WIZa5!Yd-BHUf9d$H}Hj7xKy zVg&KBTm3HeN`~{8je$8=)u~X`F6x+vV6EB&w>Ekeu#0K6wkWYlrW}+@gn9-74}cQq zLFKo-=5B)M;^a0-d*{=G?o%=4U;cF0(x2|@{nQoL#+@Zw#KOmDjZ@Z<{3Q+{?r;sZ zhBfq#Yn1P_A+Slyc|*{V0z)9sy9PEbU~R}6nPqTgv@=EK)|B~@8&8ZE`?`^3DmJMf z*AGzYptk$n0E{7(#c*B2bKRrNN{8kij=QNkkP$j$h_j_>`Pnk3e{F|4FYy1Qc8h<| zDKeeKu?3pGtT3mY^9x0OK7d~ra&Jd)t)!wVd8Rqc3KP##)s_4Vo658exmqn5%6;$~ za>Y2H%w%B<UpTVNn7z=ZW7MYmC)?;|)(c>&npv+D+g157YQG_vv2?4WChQlhGxQK! z6*M8BWuu~Em@lw(l3hgh1vO=}{=8-ybrF<Sp8drAAJhz+ct2t?tJK)yu2n=M=^MCS z7gT4R?&D$4G*0+$L?O*G_fg0sSc>Rq6f!I=D!DWQ{7m~%wqhR`=sH3!d3K@rT<Z&j zN9PP`(Lrf{DakA`it`Iaas*c1qp&XhWP%sE8I7-sT0Q>YX9n&~_nfz+gp}k0R<yn8 zFmh>c`ao6`;Rm1t2m!y6pXF6iNg|B-u<iZ$R!bfCBx<y5u!RaU&5$#CxfJoa%%}#9 z?JVNxh?7D}i9NMs!;;-Pb_ll$D}-h7Yiae#AA^~p-hjmRit(BRDg-Dt(ONyIQ*T@~ za5of^tt=ut%xXp^+W=)2(px7m(j*C%?4ca*iTo+f?jfx#Vt=)rSZFnsCvZn6xIBTq z#6JXi))dYjXL{M;9pFck!A5+v#va!<1r>c$*gV6cusFo(avbuT#2{|iRr{?nJEcQ8 zO!H`DWE-q(vUQh!`3>ZeVl2qfxIT)&brHXlKS{b0?MUGM8e=;Lg7v_Vi{arjKs0pQ z)17dB79qv4$iJa0d<!&F+A)jj)SQ7$oTuLv%!)KLnT;TJzkILrMZ>T%@{PRPh`z-s zVghywC+RoZwW;9$9tk+!lp|@}KDqc~pO}*EwEqqs;-!)YmhLXK!n;I4OsvNAo#Q(c z#t=6nYNg$BuZ_b-vD^c+RJ>hw5{?kzEP_(6_aQt8N!xYjAG4{<4#&4d5vNA6%Z&0Z zC5D^PWIP#7@~c4RM7Kw9XWOIZu`biDeDMb}`(P1QSh!Kzsp~-(-jAeAt`EOz%{uFB zB+q6+yIX#5n1)>ppCDfZ%;KpV3NQG4nvc6q@r&TEW%7b0zL*!SNaoBs0nA=Kxx%B~ z6^=807fea6CiFFahYv(n10(&u=qHl09#3X|2+4E1E-Ef)IqG2Oy=bkP{y<I1KzI1` z^SyqrCr1u@0Rp%mYm;TST?dL_(8MB#e$7%v3<Jiob&FhnnPp626htBjNYrY{$kjDu zSp)V<m?2A!JA_u7&9=58NL9Im6j=W+qV<u>1ur#JyLVP4d=4`-8{(f2vr@FwN<+a4 z8GiujbSEhC&l9%<`n|+@t`G(ahiq=(*FixSUGK>n3MKhLQMojlsPQ=D2YJ#T89iY5 zUi1$JPFkHFt8}GJkzn&_7v~bC%jQJSoU@p5p|{MfbZaOnqve8qWwvz6bO1q1Ehgqc zcK8-F8S_e|O*jJZpjhe;6e%PDMPS&US;5($kebneB8#Wd)EFVn3jm-AfN6e@0;WBz zXG@NFHgz)5cypx0<pKTg6PG0kk3Z%ZMgSU+SP%x<=I118!pog_lmYNn&k6j0+iV{& zzi7g|(K(0j{nk}Kyzi?AZ-O(E{0{i4ul^m;^A%05{w<J8T*6OD&-`4h#gb%Vp2rB$ z7St8DuoF@`K;tnjV3ZRlL3~QRqg{QIhv9n%UrNoT$~SW6pHvAomsQ`)uL=`k!ul8q zsNH7}J|PEBA8qU=Miw@Xx%2)^FF+b?Pc|vzOdv9$s$hDJV9=;mEvRPVQI&~^BaoKM zNC1avEAzubr$HQP?2r)`tLbOQEi0iPl7Yew+@kbi(wbpu+!gjL0Il>|vn&>Wo2HY> zAU2npU}vc5kYc^AJI1FtDa8q9EUv>y>!jPx(LGBc9DM8G=PdB#2|70m9pu~CX|Tr{ zf434<$=nF_-G9#S!6GVlXRBA>VvNw7BDkxFd2I2c_BvApRQqjW8B63vXN4|q-y5?N zte#g1%T`H$lM&DhhNgovwg0DGaVz`Xo}F3??rKGFJy9l$opbtPNwM{%Wk1*ow3kic zA&@nBLd+FX!>{?*g8w9c$30j7cEp1Jq<3MxR^s;*XSe7_zW=q#;F3rJjLrstcI@`a z*I2m!Jn|1AS23aMlGs&n7G1{uU9#jwyAc?I?`m-g#>G}aRtw;8Vn*d1Fiuz<p?#Fe zvKpHZ;AxP8uL*u21ZdJk2V;+P0Vivv!KMU;{%>D3_4rTz`N!Y=%BLQ``~Z2Thd%ZA zzv%f_4;<o2pnUe4>)-caP%fq~2d}zvN}D6DzV7gmg@aeN>*q-%unNC+OCDcB#~eh9 zlVhr`mS1)e1T>!~{hww?c@LF+ZsWmg?k?pv3X+B*De_<>75GNdSR_SeOvf@BRCH0n zct9?l_>IYAu@@yIt7s=MP;=8AnoF`hB4ES3bjSF>41|sUVwS)@z<9>ttT#4SpS5s8 zEe8yU<!nE((v<2>SMfC%AOwJAHQ1u_#*f3E10+f*G81=YnPMnQRTh;=pC~jYMti?- z<tz|_AVb6gDY^K#K?*P2aq4=ng?SZ!P;*t0A18hDPRbhDkxZi|?eN3W8Iu+-Foj?z zPrA8x>0_nT<$~Isa_6*d0*hj9GC(Yy{8I&oT&!_=0UidfPo-4SJEJ(3_E_w;<~Tu| zTH|Ay_hu32<k$T<`Sn}ch^Z`nX6NM7Kl)uE(2#VHI|Bke8TdoD-QhEw8cv)dY_XVx z;&A(rxrF#4AfTkZ6bONHT2dg?u1SBvFfk?xR<LuPNe>pGjN%3nh_6w@?LZOl`g8cP zSc-}G?#-atFIy+s;CvzlVSsObe~ndxqJ+m;$(7VE7Ie*i)U^;AjWGZx1}Mms48tI? z#hKqW?ogT%wZfE1sM5p{5<@}5MaWQ>QO9jX@Q@`CP@Dv{jWv4Fq!^uv9G}jkmd2#k zBKM7-a|2_NH2ZB9Ntu&FM9Y@fiX#{uotlTqTNpYAf|ZTeMm^kL8lIzxpv)xGHd^G= zQWp{lWS1xLw_V&Ui^k-poOE_$Ec7B3=sTc;3*9frl(mHLWxqGTP>Nq1FjM5xA?b$= zkAP1{j<t))uwspPe&4mGq27N8c_e_<qS!<-0Ujh1XcRB+!ho7h?^L700y4<8OMu3P zFqAhV#9D^I{VE-}b~NC09ez&$Xl&s8;<xy(aiC`<W#~8rM4>c-^q<w2niFP-aRV^n zs>2u@j;e!FyOWRtT7CF)rpj&j7EEDw6C{ma1L7`wCjIOwVX$Adk{;9PGx=Jt*l##; z1jU{YrGKpTs#+h`&GjngEhZcZHZ`&8*j84p6J*&zk=>uy_)vv$p6EZr>*#4g$mWMi zl@lF?8_ki?W$E%2Cykwa%Bka!CsY}D7b>`+$ZnZNPCp}>^H{9fe!Cmfc&A+P;iK-u z%0(X@a34-z^x-@1!)c2?yy|YlTXWHe*SilVE&A}Tr9OPreHdL-;}4hm@MrGB;?BRi zRE_U0_2DP(!(wo+`?L*kF-qR;KK$Y$a36Lb#uj~e%zfCf=)>)wu|CdN^x;AGVa=is z3-?%!#kl%S_hIok-tIoETGZ4h+y~DkjXR^RhA%DnHO_1y>(UaNvuJ98|2Qm@qb|J! zJz|PS%SocC!+d15M1}xI%u{oqXoTM8etaffc*Vkkj&60;nAZ3+2f!viKMNg^AF^|r zxOs!!q<1=1rnWe4Tw5l&TN|ynx%1W6(G||B-$a5qy3=KOv4(Q`5h~k7vid5O%k3-* z-mG%HYd~~c7lhI%rq^W{KJ_Ny*8%)G^>0H*Unpy8zn{amBc*K~eZK<u>Hn9#cY(9) zs_Q-X<D7c!s_GMH@8sHX*ynVsjnZ`ECkY|((Yw@03@U2m`nlt0py_myu4=l|)s+Tj znocFDgp6_}JPjd)pj;EZG)4zBNYn~9U_c)6g;CJk^3aHIjW1*b$$WqR|5|(RQ+2A- zc`(j(h9Z5=*?T?y>%ac*^<QhLPSLtl?^W+tgH^m#?=~G(ZA>ib3UXMekbzja^WzT` zE3CL>Pl_3%OJ#J)9%_^Kxy=ZdRCz>Kl2Wk!yaX%~K{5i9x>Gl<K+C%8_?mvOCF~wP zZI4)U51;apwdee5&M~wK;T}G#f$gTH75P2m)yet-KYORp7VlJKJhb2?n>+aA=rJ%v zDlkjkGZT2%#s7*N7wGh7manV$K^{hHHg+7XqtSduG>TN6q`P5Y=qA2a^KD1BwQdLn zC`}WE1QenB9HeswEiFb$kzr}4y4pq)GLSI>ux~Rd+mtsX+lmLWXKNWFFvqoT*YpS4 zHF~RC@oZ6TS^@>@4>E=#G^DCtMoJ66g3mHV>dU9X<x<;SW9P!#j)Z(KTnZ{n_hL}I zOT;0oE04)BQX?TL28!t?IcyrVZMZ!uI;Pweum!9RF?-2yd&VBcIPc1Ip|FeoAVzJH z))Sm!SrxCzyPoZq9?!3?Hp`vygF!PooO@`T<btY)EsTK#2<(XRz=2%I(BnIefpltK z*`2%Tqsj6h*T?Z!+(B6B%i9lBPd!Ss%a5G7Akm;XQbN}~+<Nr^;)Sg@9rJZ*<&k%M zs`COs$(4O0pHP`L7%{+<&rSVDVa_aRerMHjVo?H1W5PzcdVUD5GM6I|z!<YWWymZ6 zKbsC^F~A|n6Xrxe#!h&Iofbng54x<o&1GG6Y=alsZvB-BE-Wx6KP;m0)JegXdN27M z)h^wn$t(7$gYJ=i-29X=c#KQmvg6!<6&EocX2mGm2FWi2vR&m?Ccgoy!zF6t*D*;< zD&%&D<?uN`22?(G_KkCoY`ShRHpg|c*ig?m*Or4KJ9FI|2A#QN?tKrQ{M$df?x((d zjAWMwG*l3)3@2W_`<K4_`QKQ*<2@h1Mz7!DW8&g%A0W$~p6@!Q9Mns2z}Vk;j75y| zZ{W`Iu^U&c>8V()y5|-BR4X0)Nm1yOOfsy1>IMx<|GWBFId;j3WAy94xu7-q;Vq0R zy>9L`Z@l}H|8(+Gw|&?31LW5WcuKLWuYbW{VoNzrZ{@^^W5o538BX5x^Pl^rPp2<{ zc-1MCD{kovKU2pku)KVW75GszQz0}!x+Sh8M0f=qD|2&4kC(YxO@4&+kNgh17N>!5 zVg-kB?2hUqwh#XLpM5Hu>J01PPRP#CAAuwXMRog!ByOsA%00(7Wslia115ITPn<dH zAUC3Ppv`IZJTsRN6<@tyH3)`JRc9UGszaTBLvWQ0Hce+eA*2D8)I#A>t9g-DZ}`OD zjUTXo=d0dPJ8`JuJO|fQV<1DryfHn-ROUW~H}iv5z>N@p14I3;8(DbblOHqX*m3)f z)0$N$flM88d(w#;hTwUaH}RN~n?7QLemjF+00;Te>MKxfdRRwUl*Lgt2b_4t5Y(A& zj}lxja{_x&i@>hfn7{yNRo#|(MG$G>#ElFiy>T!`<8w6?)?_37Hg?G(@m@OmasBgr z$_I9O<dNi<@NtZrm-Xto`Ulee5j~mGY>+6&a);=yIC0Da(SW_dRG(s&XBqiv&+^GQ z%Me#<mUH+37U|DGRi~J5#vA7Q!S;MVbgKED9h&dE=zsKlukz*z%=hh72gCQLKH+x( zQY}*M=Ep`drJm|?2BHlDLE(wc3z*r;#LS(kn!Y_{cH4r+X1C#ElQct9s`Z!jh;F<> z9KG+4b}j>j$OA!|&%O6=fA#F+bK7s6d-#`m5+uZzw;!K-DxwzQ_3)<a=5Bo?4=?^Q zIX*Xa<J_y4?^%BMn$_{^=Dx_@V-qUp-2g(DznWh+xBZ3{eO>+vug|on%gbM)m5Ccx z^kU*h$wVIT{Y-tQoSp-Ms`Kr<8&_6V2<xHHfHO$I0jBXLNj^+H8^!Tg06mf^tv(<U zN^h7;S6+U{0$kajp1bSh>NVHh@T!}4xfbkJyJRJHtGhm#NOkKHdo!r$?f<O^Kw#IS zIM&ZKN(rudl-PLvX;z^m3AykOCrDecAQ{rF`50@}el~X68Rc|2xy1@ggD5+b`_SgA zue0@0JFE?~JX7X#w=85rsAW;k#jL$D7pYd+x<@$DkCcMR?^mbYXpc-<?<xEb`FE>N zCwd)hf-io+<$iVdaos-CGLt5%&wf0qKKm!K1Fru5Et;4Do5~`&rg;i%qB7O`s#X=X zXzy!q{E`Ibk)*%nYgpDry!Ixnt?Vk(uN2rQVOX$%PkO9_ohNwf+j2;#(WqEnos@mA z@mIUbNpGX%xN-gug7k)U-MBA}r*7YNk*2912G5e*P*JIvhtv6OG6op!>0LAWmTM^t ztQMEW1k=(IUQa7r3H)_%=!TJDbsS9w-Juoftm@2zgRXmZW|ofyXGb|T?4s|zT0Oe6 zqvQ*-*AYQPwFzWf-U=Uh=gM}IJ4k5nHCCk+<^Hu695_CC7&DHwBGG(cfCCO#u%$AE z`t0f>tYq1JoSz1e%&e3j_*eb}R*$~pH@tF10FE&#vgP~-a6CObQ~M_D8vz5YHSo-K zPbeNBS3n;|X3UkDN-Tsb7Mxen>nWZlTr=jF1=;%@$42ji`YehI%waU_tn+7viW+o7 zy%pa@#KT)}`$8E5%X)9HZUUf<FGMSvlJ{HZ-^{EG!(*Kc);xSnFrGDx5zZ|e1!Dq? zYkKjnv)&s6;GOC$2a^|T&|Exa9pFWd+i)^%E=pbR9V<*SAf64zEQ)JOGo-v5!33g! zYRr*iWd>RZr&;O8W*K85SUX3G^e-+y@UyNs;a~1Co*2fu$i%)G>4ZZZbaf-@+P6@$ z)Q^Wrth4uK8z{`@9w&hxMn&z8XjNUMy;}n=YHE6YIx<L|D7a<8?P-yvm;#Pc#xvA8 zC-Jb#@f@_MVb=5SCGh7xH5&3(&wUXr-$4tLlm|kx?Wby!C5dEh@cP)$;zPR4<q=?T z!Lva_{6j5wPSLVEgWj_7i^~HVVfAbs<gLj0`-V|6=Dicr7&l?xWgQb=QxO_~dBP`+ zy<IcHFdzdKh83jX+Y>%%?0>*3tve8F@!pnHCYdI4+zaoZNc`4H{kCa@9Gz`6C*nfr z)R%}Xo>LJ=BszARlRb}Iwu5Oc|7!8b*;jhhwJ{SLlp{}!2W)#mX>5g(ZWe`DUob^c zugd+)cskWubLrNE!!j<rx08MyNuxXc<iE*WBa>;s2OB&{>d^<#Dc><gwLc>v2z&MO zw7X<7SQj!7bi5M@f>X3nz*abfm|2FVsj^rgA&~4Ybuvr4Gxlwoz?}6qUCwGT+)fh& zvS}@#&Xlt<5N5UTPOa&3rf<g#+gbQi*km)kUtfVzO|SQ7P1$4CQFMk5?7U&^*Wvpt zkPK!^GSTFwHa(Gt!<qSLOeLC;`;t+1BH#h^SY?ABy(n9c4`rjVlGb2>#3ub@<aIpp z{-3@VcfEH)z!~V{8qSmthYK9--~n%=&+Hk_vfW?|)3d=CR}?>a7+Zs`j-v~t3O%3- z92=fUj|q4x+IX7P^iH-$p$lnQvd!>}gtO9ax5i6mg86OKqA%kht%%eD+9)ASMA~mn zLxj&$oK~6;`Rk3*<uubcC8QYs)dtvr<YebEVY4KL5<T!F%mNdf5Vk#h8qer>rh1{s zmUEVu%2`6=)WSM8@|o2$s<5DOc+qLGx>A&Zn_EpUd0~8<p~ATe^ZqGIglry8sHi3h zNV1`Uvnv$c6ODs2$e;1R;-t(`mL<vO?cX^=#0OTt_Y<Qo^NOy)$Ey!f0qRh69Ia zsD$=hgW5K-#&*?$;t=gUG*s`$hE`lt=YG?Ww20b~D3Yp-_A=6Hf<>Q4*~@ZbIKez< zBZf;5H6IdhW=Ob>a%G~~cV)sPSt7grV|J(8|JDkrNG&00t#D_zsUuN|wE%ULb_bvR zGbQ`Jc4D{_hadu@DW$zqEY;p!*TIcoYdcF%W;LTJE!Q$Dr#$qJi8Bo@u~7t$;hUXb zW?oQ3IVs!0S{klM*Il%ZPv(H=U)#4ZSZf*l|0l0N-mo(ikN73V<3^w*{4|tY%ni|M zP_wn-<Q_!w8heju7HP(EBW@3tW1NWM>F#HwM%wy^J^1w!97-3pYknGM_9jKo+6gNm zfK+^o9t72`Z&Y_>%QG&^wCzK>+di8vo@zyVtk^7TSK47tQ!{&A-^GwUAcNZerISsJ zyciJpbt(;(YIMaEA?5+wvR-LAEvxP$j45g*q-9wv+b!ARgxhA>ZoPYkb82QW*tB}a zeD9rlEos7zT&^&dc40O4JooIdEq2!{w7M#dB`_Hr+5@jc{3!W7VO^L@p?#^AXD@no z_6#q0#ktwpe2X}87_;6lg?V^}T+a+<(~=EC>4OP|7}t`3na;k49R{{9+xmOnvvby~ z_52du;LPW9Jq<hCnf`>rvbvobtzpk82+`aqfd^ZzgwJLB#1J7D#jk=hbY#M4?-mD5 zLwo+Svjf!R5d4>Uk3W8Hwxn-Q&#tJUxH3R-sf?NJ*^3<56eU-Y!oF1P;J!<!PwznG z6;@s6S%PV3+ldJu36$Zfs1zt;&v>(q{^=ZhFx8YbCZM1W#f(*Kj*@bU`N5_SzH9Ej zH2#u1%xU=D?Y-WH*y+7C%mS+bE|-corcJg$E1qD7Lxh+Ha#r%+jZty|&ouvqFKqRa z=p>ZDc?2=XOVVBA38xewE-kd7R6WZl%X&8%5CjLvK6d7b;e@;i7rb?(MJ<AUbW_=h z#EfObuTmMKXV|uiDJSDOe3Uv6ZpelfheWxt&T77GES-*4b34rf2+zWp=L0<HLq`Mf zL3%aK*^})9u0mMqeZVIJ4J#rRA&?SQI<<BDMrv*)hh(5y-%+#8?dEc#KaC|gEpuWX zlcy6M^l71Z2AG3OKFwFm3f%~S=Up&aI*DB4HMziV+06WB261pj&c+Dg{VrZ$q$t-O zR~*#nr0E$twYTh)Rv~f^74^(|$fIQtDa`Lk5|O{07Fc{|stnSsIvbhJ09k@<QxlKQ zlliZAXWQX?6oq_9O3|Zlk7*$w24=EkOrX`V0fKr)NFY2R3}U=8czqk^TgSeo<+LG} znlox{yQnp*xUcnRBkG@Fi|H7Y1BNA?Qy`P1?DdVsOH=IyeZ*&?VSkGugY2h4Fpdj? z(Y1@79pMVpumPxKuPycjtI|sYWr2c8V2j({sQUD5yAC1Y8wht^7`q^~yK0r8&#<LY zcnPEESP34;9by)E_U)17g!kr8Gte0nv)z0wOOp;3XurTIt8zZbV9fZ)G~b0ZZnrNB zUDRn!PH4id8}-JJru~-$71!Ofyl+wpB2uL@P0lga-iL%wvCC@AsIfePsOlsFdT3?J zSb71J)2K}XGI~st=E){y3O)JGz+!?+S*&TVW>H7M60}QkbJ`dLZRurg{HkgSFOoFL z0Bwh5X+k=SLfV8BNYz#52qCzxr|}2tgX2F4WHD$pz%{nEA=!Rt9`smM8mu7V>HyVe z)7<kVwleHxawD2pE#pIEU$4hy&Rlr{kpLb3(@SF|)?ltCot$~knAHgq3;7C2M3qj= zl`H|Bld-ytqm3*M8lssi_9HOM+OW->YtJDRpthg~?P7yE{FWlo0PEhywrU9~*ARyj zu!R`9hRV0uuymVtov+CRbp0`OZi8gj@2)3-$hs2Q>q+RuNno9T&NT^qYbL>hb^uUM z0;TFna55yWUL*Xj79_3xOaTtk@U^DL^k;Ykj@Me_>S6Pz*X%TD^H1*lqYDYap0-wk zOUvr&N`44@WIZ1fZomS%a0j+TtgX1F7E5Z}t+XIsN4&pY<LyZ$-hj6jp}wPK0IVa@ zSJpTSw73z@cuk^o8)pgxL1WsN2hL)4mCGD#PmwPnTBA3kT#zN4fh@C%S~ho^-aeN# z^tLw87N^iGd!0b899&5yq?R3jLGa}@8W26X2i<3m5L$lk4nhg4N2gKz%|Elctjq#b zil;~QwFjn*>9;36Q6Xe?@{SQaj$S^#vc4C{>2b95nGr1^BO<&jA5}->MqBDM@G=3Z z`9At5WZ(8pfJf>qE%{OI|A2qM3B)bKas~VqA0MXNEO341(f4dlvH{5nc0;7%OBQi3 zzhdroUXogBeg!j;?FJOf#(S?w9wkY&T0fv6r5`l4nj6S(ezub)NeZyaCLgS^b-`XO z5;lb;QnniWCr5xXRG%gn>ZVuUtNBf<+wDutP_+zjZdeV=Lg8mzB?>>oYD_yf^geI? zYn~hGj;ZDXAL`$HZfI+{^fBdzwz3?Z?nGcA%MpFmiB@|f?U3eL4<!cIHA;*5r=(8F z#KD@EaKel|KsjMwk_*|uo)Eh%)fPLUPzg1ifk+i7*pB;wn=zlG$=A}HKk9LaOa!kp z<H`GvSI$r;jaSa7F`9GX7!(T70EQmgW&s9wjo30##Nr%lHn82zx7-Vvo3@JrulE+% zk0vXBr^5|#3XHTB`tbG8bMXm{<NGP2(tvHNhv(&{SaoG)tM+{AZ4!n@_mfHQb7JG9 z_*uyaVN-u)j;oPc_EXr^l+&4?pbx&VJ2Xy+I-FGDg(?}#UPyVtdMoX~O12PSzl0oK zL^1O^jkmWVY{)X2%U2As6&*xl8DDlf;_C;7lt~?<l;YNOgJ<a??tYKCqPO0p4u}eB zRa6iFbGP@?j{R>^YYcuFky-upAFNWc`WpbOZg#L<=fzI6glA)@fFoudc~)u%AWjln zZLpX@q>=~b4b5Q@Q3EE;gI|2%>Yq&a0x9~)bwE5RQ#})Cz2DVT&@z?@ujT#7MlpM( zX`Lsc>S**DZd+E$ucjQ4ksfFEnmu&g6J;Lv&@pkaj~JAW*iDxc(AI7`JE^38lkL`@ zTlnIL^niTIu<uP9*LVqyu<AUHbrorZA=vcmcA{}(1VJb5<$)BMAG4ir_JF{${4pj> zR_fPl;0d%}X~QzJKsLn(H3K+2MwyOM_c<g`MgGC<cp`NN?Z&HAmn)jQZZ-d?{TI~f z{ju}W`HIiHR#XL}_0z4KpR|l2Z_tO9HRoH-ut1}5pY%TERjrv%`1d=9SBKMWR#GUE zibvsM@{(XFHspsofM=@os5lLx5@Mq@@sKfr_f2gU1K4XNR8RK))H?&0I@Kr095p4I zOYKxEuMr2Gs`9I|Q6gGG7(w?_n!#+h{v>0~tJCzF9ht_*T$S4Ub8*k0Yf~Q%sI=GJ z-e21r1g~fsVhYrj)e;Gzk9ZL(6#K}0&A}MN#DPm+p+v5|rGrC7%ABj`*hsX}QJHnZ z{oRH&9VjW;rN)7oZ#C6{?r40G^qn%;AOK!H*5o=okPJCliWhZ-+_MBIJ7zS*_8+bP zv*tve_$+r@B;vHKuNRdFdxTm2P%t9R-RfgWlyeV1uqx^G_et*|)#lxK9K)iox`s!m zAn1_$=gxGzDJ6AitXh51=`|7`B?LnZqeYg0$kFrP%SV2G@;wePvMG3Y=Y^(3iq@Z5 zfx|7}OHke#K6AKXlW->95)Kg8ULs@+NYwZ3+@WsXHiqDvHF8c+#B1&n2s*LyY+^_% zdTaZ|HhXa%MaX?`<K@4+c~#VF=!lrv6)}T@&~#*KHg<H@WA5lpx1ORSybY~yKPC*5 zuBGuXjentOEF!jz+Z7ujE)I!T9Jdnr{Ox8v7=dSS8x*Vh{L|3Mhkj3BR-YI_CrlL+ z)%#2J7O#OKv+);x*J?no)jLcS^xm~HT;d;HbACkKz5Vs3(1YfTa_wW|sHAS(@NlxT zCgB2LCDrvI8i&n#o>8WtHcuEU`l{Y<yK2K!N<8}=;@Cg<F#}UJ2~nVlAKuc(+n`n+ z(=J=JEK61q0i`SkOC;9c`UcSzjhCI+h8(=SB76#I5_GG@>PM_gdpzMRTvr}+ZI3`` z`@smX&U+wf_39s<i3X6r!U`aj_1FNq*04DBgx+Bb4tXla(DlMj;f>qDwt`3p)-wHP zvK_cu?qJ=O8CGe2yCl>yTKCqD^H4u6lXEbDd2Yqe&2GkcI0ub^d2AxEVX;AXMW-;| zud-NEEgoCZNzJP~-}*-3$M{jP_7f{Qs`+kJ(DKlP9st>0JV1h-pWLZ8EQH*NgnxGa zKu9D9CING^{P+h|^Z^W}wEBE>^8U`bHmmN!2Jp>*)Yy?cz>GSk-7v`|CHas1jbO)R z)d$_RJ3)o&lkO}AYGe{s9P2{w=(6f8x`iR>Z(u%PVBV?x68)8(J5&WRWapU3khaR( zv)c)tRM(IqAn1Wo`Vm8y=_tWBUImK^XP~y06aHIfZgwPhb=0-S{jO-)ScI^;vjqt} zNabo<2#2dxi+R+u;<KQGRvqa9*Fs?2Db~hbnXZB20fRz<;}0Sfa=}k_=C>K7I5i)0 z2~Z&oprk;<tELU(WwpNdG7+#)X9zO9mH17wFiL<dh8Yy2@f8-iZEgt->h;F0KO+o@ zqD23g>5}p6AcCN@ma1P6x)XC-9b|e#+GBlE$W6<ulJyyt8ltIUypb&9SLUMu3`0C= zY&ytyB<@|x)AG%(0xo!(I{N?-;F3Tj@9>HhnF2*CLTf9LhPjc+HIGWTPVRk1D3-70 zf4wj2w@A${s!~Wv3fWpSMOeL6ID*xCpH)eF9n`ym%%cmZ>kEs{>%z1|ryqC0`IfYX z5`fKeP{2mUl5@vt4Ebc-!aHKf>Nrk?oX&sJIFjO?ESIy=4oT-uhyh2)Dk4fOOp$Kq zVSfP8!TDX5?1jCpH)|Mf8!1x>E}s^n{!ASRbn>;iH&+&y-tf;^^Zqh0IczRAW7Z?` ztz0TayRF8!o;4V6mxyR?m8iAKS4?Bd-j=vc-1hVkg<y%wtDXbvv(2$hqqXUpH+cN% zVILuJ%mo7cb0_CieM7MpI4B{V^OH>sIWzati}o{kJz+P=);9^4y!(Au`uFPlJ+AdX z>Jt`gP(B*Jl<~fb7Y(`R9S27odVazoSzB*W>!vz7>{N61MaTqr|Jg&LIwPXY+Bj&P z_;7RHSk}Ah19xE@Ey>8EQ3fJELX)=}3S>c~P=<4v0{+bH`HDx{#Ji&-3ewL`?@Jme z@1%-JqYq8pV@(N{_O2?Rs8XdsgJ?_NwDFy7_xdY`_S<{qFay=CJ~8IC3Bh;+bZr@j zra!8kQHCtP)~u;d2U2!WY%<X^+R4!Sd%YUuE_?256)$rqHs;T|^Ukx_xm+<C%H@h^ zK=9Uf(TMHgm#>4K5>LtYGVnYak)oL-#r{FvN<QogYNI&VOnpo8BkNHQA699L05!6n zad)C@w;69F=jtu1$T|wN#0Wz*nG`L`_oPjK#AG6=h0~;E)+iL@!b4V6RAVdd3N!Lr zMRYW&k78DIT4n`jI&ElHuyEWmDd^T{$lT7zEydv>$b*gyRD;5k2usW42Sg7>8<?CX zD=N9Gor|{+Y51ZXVhmiS32Olhv~Pz!ah$!sa48?yFO|3r0fs3Et@pPoRJ<i^@<<vC z1~@W@12hSaO7a)SS)4*JPE#YZN3i4$%M1prLaDgPDa0?=pZ_k}Vh00B<JSk;dhcas zkyt(N{e!`bFV^sR*)7h?v>Lk=iLq>Jv1u5MX@hovLf*L+7J7doDocu=93I!H@XNTA z#BJjmjbp|o49gqQxWv?S1`<7*YEd*8G41`m4I(J+0BT<NRQ4Wr$aapJ_{Nw33|Wv+ z`h#BnZ-(qC<|vHC*1Q^f%9M-b2srph)l`-OsT{xy?6OlWGmFjl?6uB)=Q6tNgDau} zpKppr26^4@ugcIkL%|NtbvlDm%ZbebgJGnHHPt~TzTL|{$Sk(XfhuMHYE}~RsD16# ze&MO&2;aer7`OxK$QNa&csOfF^7g!5f3IQ0rHHx6ng~mIVuC!};|Wk`ek>MVy*`(! zCp9D^_R;%U4VF<dsMqHv%BtfMe~S2!=oH4&iufpL!^_{ChsaclYGGBMN<^oW>MOXA zZtdulLY?cPQ$$!GGeHa%YH%fTm2`!xri?3o*_8@)($yzzDgjta)O5so2+Ff%Y?iKw zXj2^>b;nC=)@cu$Dkc@?mgx#{w2E$Ha0>Wu&*!(<lopE(!lAF!pIQ)>fil~|C3Q!b z4A?TvV{I}dEt7$*`9mfH<?2aJivIu?0I^wY+O{SwBmb7}div3#4i$@O%TmoEWweoK z$ztPdud0)Cir2Qz;%~+h)+Lr(IUD#i(4VhT*eV8e>NbEaR><d}4^6HR%dg&h#oWxk zTFG|@E!^2^ezWvwcdG@X^i^r<l9jg`2AHrwTK>CtkLXbGxcfL7S5k~AM1I4%qEr3# z9<X`GW#ZsA<8*G)F#y1kBH>6(r#OP<ibjn606yp#87E>niL=043cWc2lmroEb%i+Q za4AAlR*3_6?M%A?$BjZRW072d2@HH$7%+>8UPZ;A$S(W<P7*;BWyPN(m&J^NINQbB zhjYpkr>`pSr`YX`b2+Kr5$!6bmZ+9F=-QQ`@P8=sem6#Edw(a?*t8fmz=%YWD`m$h zNa}wgEFJnh+etFSiTTqLgU*g5(Zw)0^8Hjbh>qED-jwF;cHaKQ=YILqx;TZm!lg=> z>`M7*rjjDYDsCs2Ll{wp4Glzjz4WE2EAuTTibUIh$^j;P=rADx)o?^6_5ACO-@lW@ zuMY-+ii0o26s4|+I;|+GGJ(o8_@%n}Xzr7rzvGeje`5K|@45PVi-OJl?REe4(?9>4 z51!n1{feDmLNo0atPqZgD)+<HrDjQw&%OKa|M7wQ|M;G-K9XF|*6EM^${+vA!>|9) zi7zDAujmXEWKZ>*7D@y=q=g`bjEXsK)G}&-)*=`<@!|@^zDX+yEetc^qo$ER8K#kr z(}*TBtT7@Zw6oEtSN~Snrf|Wp3TGkj*yn&D)I2#c%#-9oJ_=9T!hkth7bMCAH1@KG zY@O`ASB%T#pJ8&-{z0hASMl8{y2R&v7FE1n1V?;H$0yA$*U{Z@qUT4DoT34iW1@AK zSetVB#R;uKB{NbhrFVHmwZ6bb$K=@pSvQJ#0=)=^VEVSrsNy9O?-tk|q#j$%(1uI^ z6b%b9i3!VHP<!;jJc0}XN5O^2^=G9on^f*nh2}zIa17I5oCVjK0N01i!80s&VaO`J z8?nFDzACdW^;yREyNze=6H1%NCyXNVr`#V*u~<U@@8Y30qBWGseTW12oX4Vz-p^^m z7$D}<+w`>$N!COMu)o~8e)@KHxHqjSdQdG-VBtB*m8!|Z<%!<sL~@2R9=%F_s$0XD zQznypZ}eWbXRn%&!6+1Caj?hPu<S_@+q0XHA;C&O)HJFHE;nHzJ0Y%NnI8kgNbCmY z$*S8Yf&#BIX0qxlZj5~+Fx~jNd$Hjn)*vqo!kdT~-aO%isY&(qqHm@2%|c)GkkKe# zzT{41I0CLpv2cR)A7_56Ef*HDW2<j50z$#CtG4=3g4Yt{;2-?qs=PPLVsN_nM#|U0 z$&=PGH@0a%y7Wd!7%GNJJ+M)!y|fjk1p`IG+eps7tv#yDHK|^?oi67D&qyfdA!<;u z+Iq1NEeL1mB(!_ej-N}*BD#5oGWgXMG~FRaslzEb630#qK{ndwC0p*`u=SFYXj#kG zmP!7@{jsAMs0QDc-4mo>bI~S6)v=$lAc3LtjfiAIEL0+wARyAE29jBQVY)w(B6ley zd*3BMYl$30lA<M6M(yXt{vl4OwTL*yR7vmua#bp2r7JD%j-^wyttAyx?v3Dva)^c( zzt(MbuEW?Z@$J(@qh@5uJy8^oBaV>SoR7p|L2UU8yH#_nCa{FGw4-@BX+kfxef?!@ zKvzokxGoqYr~M+#CO%{e^TEO3;2MFrWa-<CPu)!Aa9$=o!RfwLPyb!CrT1>r7hN+N zf`FcA^u<`CFS@|2W6Vx%!>!d9<lV|UMof~gF;ZWkdjSx~mlfGYU#MOLhmN}pY$9q5 zD>|tmNq>R4yuqBja;!~ZuABgO12(flRq8=_1AVp@zF<fPi_#KhAtl5@@I_n!05Lk= z=#@t@eJ}#F`z{Dm7XSX<O|Oq7%*%(}sE>;&lVS#jdJ%e%>Y54N^U}3r=q5{EQ%Sl; zimp>@t*NNB#@kw}UfiS4TYQP~x7Ft-hx^p28_`+&yuXTikp=Dg^7S>ZXvmfBkZ$}Q z<0YVU4<mJ%AU<!->(m&uc%gUNnNSNGHs(mux6kLh(}Zp#ri*E$hOA;1<&xrmK@(e3 zGTCUUpSuEpO34KZDnMES5{b!9CDO^QZBLU4a$#LZ*QZ0=9yFOaKyU-J1co_Gh)h%2 zxE$UXvimPwV=ACSWGAWK<-x5xG`^5{8an<|C>fJdhoVeLvB{()L+*XZaP$2p_BB>v z+8mlpsYX3ccI1J5FLlbJs{Dqkuw_Tzf2;NV1J)GFO{tB=CdvCRUZ-J$KeGnmazs<j z<$0US^M=c_hRZvGfl8<5a(Lo`gudnQKn5ONK>T`~K>(ZKr$I<X7ab5>D9YxKi=S!( z8BErHHQ<P^aPHu4297X**)1^(iG0{D#NcH3aCoWQ6y_ykr39|d?NGPmupJ|SfV1;w z#+g?{(#y<aAFr+qpI6vtXP*DzjgD9B8ss(>1PzJ5b}OZc7dgN=yYx(0k0ZgXHKd%X zAis2;J9Y)45W7|yqdyMJ7)9#T2&rouK+zTmQkhVu>Y+z=q9XlN#12Ej_%b1BW0nTu zH`>|SX_^>sng~_3Y{X73|JS-C!$oIXj=Nc=j>qj*7mwJHy*GjUY^?A=FisAC?r3*C zXy_IeyTYVT#>VKjLJ>h5p?l2G4R8_N%0C?F98v_Hje_I7)}b4hLsbmjts_ioblV{_ z0o{Iy8Tw0#C8FDLWr_4)U5ALC3f)Z;>(FgvxE9?+L|W*EFgOfUfrbo-LAQ;r=Ds$^ zEtphr#QIDd#Welr>xN*fgoY@O2T9^9B2x#ggZUj;U3kc4D;Af24f<Nw$<d)0U6Aeo zC+p9mM?sY5)%#sQbLvI)(clM3E>(A%YU0Zu#{wqn^Xh(g6z-IewNQBU$}u6oX6Gdb zUPU#bbN<ce#6Mmq<hzZujK{FgH&UDL(98w3`P;4Uck6rcOQ~f72e-RfmP43I1p2xF z-==Nu34-bnW%=lA1!1=b(et4BAMjN(EgC923z}l`+ywf1C=tFtOB+8)f{;7MUCm0& zNX0e!@US4FM3JB9-8@*EOBrHon|e)(*JY0pVA4b)z{<eroj$;43Sh*E0Om?1tr4yP zSYiXjSag_SV924L{7fp}Dx>i!Pld|8+L~)J;N6<5Q`c!8Jh2~6DDe}wMqwkMsfCos zyVQA~XHy}?{?(Q`-;)hx2G_F70`A#v(P6cm%GRjQNf*M$j@pKG-r3!ajNtr){lMsY zJ12d*%2@BTh~xB8+?!4~={!^lXQf-MS=I8&-OA2NXPLw++xt!aUd6hh6)#<%m(G0p z;wpCg`7E-e#54rAEYZ=y2NYzjB2@^FTou}32NS<_h=(Cdn;88%BM^jW?THB!3DpFz z<85q{v!t2!0_ndM<hof@!5c!b1Pvh<#UBRYkk&GMTF3=OZ6OyplW$sawvdYnDa=TP zT<}XR<YGt<Q5U(ab<~A=qoOW!a~e^XagVy-R!jW&^p#`n>5Ey7_4J)M1y^()8&{Bk z7P+Ex`dnd&pXVp49Jqp{z!q01rUKWfSi==6U2_F_4lS<023Mp{rWu7~XXC}i2osr) z#6PiW2-&~~8%3{>h#p~Tq0#a!+o%lL8x1E+LM9R>y1rn<ElCbb()+5ST~G)DSgUG$ z)LSS4iJvSjia#ZM;<8SAK8M&bD8mK_v1+y5{KKFx-)u2Zfz<%Wk8eb$*9!1HD>EVx zdLd}iUx@;d&ya;sF>r@CfdZM5e>Nn<yD!{j_$x83uFQ%brn3<Ncg>OU7HhA)Us6a+ zJ((<vORcg9V$2tWM5-=sONVgW3B;}={LP+#PpPUvLTV<uwmcb)*_Nk}F`|u5y<hfz z=~*FFVP4AxeOOrX(zdtTH|E0@x%oiX;-%QynG6Yhaevb!jhx1a&wPv{{y*J-s#3f) zPQ|cU>G)Qvaa~6S<cO=Vm1<mDsdWDNIQN6pk5xw))*#_3_EA-M4`CmbHnZ?j-Z;%O zrE#ibNJ!y2!k@DB8MFKp;=IBH{-gF$`I1t-kBV5L?J3;K+*1T$BFy;`6SUO(l<@w& zwt!iTOyS+@R%d2w9Mo2@JU956WV8M>j>M*$_G}3b^HX`c%$_zS46@({gM#?pgD%xs zKv!K|+?_T?0XAs}PBXUxH|hO`U>qpZMn6sbi#nAw=?@r)h<L2Ex>O}U2nuOITv2CG z{H9v6AO{r*FF)5ehtehk09YVI&5WIKgKSSTK&^0q_I%-z#-edbe#*CwW+{oF?;}M5 zG;Esh{f>I2CeQ|Hu=<93mlI*n4DQ~0RG=vSC2NkOA#k2JH+hwQN}H@)eMm*K-TD(M z$gcwXtku|>d05mCvq4=c&pV!mG(c$3fLMvy?;Ek;ookW+0<Rw~RiqbUB;?2U0VZjf zU(QvT;!r(LBpYyo2;G<DF$sg3denrPqEblYQboFH2W<Qe`3F?8gQFF4p7h8Grv=Ur zCadpYWt_LBt0x(O;_b1()I1M7&?ezS<B~Ag-qJ=4)&m=cpWiZ64xw3sA_03*YFUU5 zR9eyM14-}Ke13A~XkPP)YFJLH`{EpF`5VZztv&1gh05x>nLrbq_`+Oo(!f$)+nOIB z$$GF1km^3^B9$mr{j;jx7cDT`cGqECyb8U3J|;iZi$4Cu5^s8cqUyfiOEe22_+ZT+ zG2r#_PLvME!RW{$0?VB{v?vE~dG)Zh?B)}*1}zW{3cZGRwlbe<tYVI7-lP8#gm%TU z+!g}y(+4~^PxKTsYC~PBDD*%L!?s9G%tCc;ltaVP3??mt&gOBr-^57M3eb^86n7id zPzzv+d1I9DrYswYMfY%k3-=cb6OMSzjFMx}<;fW~rN+?61VcZYDIN8vgLouo8j#QO zc#UjFEwgJoMBy061!LEhwE+$tFys_6H0WkHvVUrFiKQ8;xJZlW*LHItZ7#*!RZB>x zOZG;YP~xBM^SzIXL`*X3tx0N3<x(4?G@J0u2~;p9@+Dj3<Cc2%oyvrl4z(M?ygky1 zt-X(_JyqS2Y}b-OUacZ9bXo>*W9He7?Oui6AF$HQawwg7X=8kqug8hXl6W5D3V6|* zPD)b9S1Wsn27dQ?zpJ(aF>U6ZX};e3^wbrvD#{D6p>=u>+8d(N(ua0<87wb<d^}cS zL@b|ZtdwBKfO4y)8+sp6Rm$CKpO6|yX-i^?OTv;G`ywG>JC#y1qd+ODjZ*nEqC3pB zM&&L3SG8mVWRY^L_hJ2%vbOH*B?ufba~vAmQHYzvy?U$G#y2o-Z(dM)o6>1Esdun) z%Kl`N1H`cfGmBoV5Zk>F7#`QLj;xMt9zd8+jIEWPvI{}FYd+X>V}))U^?3?*F|f0~ z*iVeWM63LkHqi5sP8`leP&HR7_TE)aU4)-@asp)#>{4Z56*Cv#A(XL5Ztq6PWdHhq z*jGEV`OG8$wWez?_>)aR%X0pyHNRigwE3Uxs}5e+$~R`Nosl96u{-NZ;0vebw~SyN zyZ{ULUag7&M!OJ?Gws{$MR!~%5}suK((-Fmtwc_RmK$%Ucxg7A@tlYGfW~^SRW-4= z{xq%0ESgyq2204EQ<N=-WPQaB8~dDWnv}meDnASVtMmLVWk*LVAloFUYxRg~`iUX9 zn_i{l-Js8z@!snk1a&S!plL%R^J_v`ZhV7}kkdnruf5ozMheKFN!!)TcyJM|NlBhm zzpP2V#U>jp0k6%j?b6g~V{a5#`eM7t^u_iAk%8QvjWpm=Hc-zux!Bl%m`^%1VvIHe z<e6T0supY`OZh}vuspR#Z^6cD8crkol0-!DE3M2W?B~RX=A=RBX>%|FnV*(!lr#cf z3CL&$+;1k2BqRi?D<ine`RaaZyxF_Qq><ELw!776HryzZDbt=28jv@3s<u(}!u<yA zt_PMZO>g&6U=~FKb7+{&@kl>L!Dt)HJ++NV*>3USw0rugOaGvb!gk}~he7tGz55L9 zUOik_k@h}d59!!<Q(OVLc?k)|fOxqO)zxM$N&=Ymp7efCH3wSRm)Lv1ekmycfVF>U zGeXhyAmlH_mTFV4>~pLiEcxQvwC;(-9U12SXR;V^I-6w)aezPYX7PvCz|#ADe<O0B z1N2qa`|TDCQlgIr12PaGtDAJ23?Mx<(fY(P;o9Zp(%{%qn8CGGkK58U0vj+D_uI)0 z>HVRA&{+h?o$22F`c<WfGxC{?Yd`Mq@pDkqy${y!p+Wf|y+88z#+04b``R?SK(k@u zuMy{nlQ+v9?cHtdK}rIx`i|bG^_y4)x3?$4RGDCh%_Dc&TV{JP<&W@+Fa`Fv6q0?D zzg)Vz63j8x&3m6$bG+g^9x}~T-@z^PEYDJ1hC2iB2AMG9sbCFW#CA7e&Vfozt4&is z1AI4HkuL;7u%-K=A3o?JIN{b3mLDml$mHVlk{S{JRMEC(gO+3l4iU*TZna1(qLlxl zP#9&@zPmGtyF2qv&h$0NsY&VFbq<=qFo7oWs#s5I6mRl$OM;t6Z<7{5ma>Yf<!e{W zg8=G<GGDBgueo;RWrzJk?o3wkVB$bO%R^a0Xh%ktg?6ninjT1Fk0=BoFg5TqXAv+9 zxnV$KhG@Z2M3)NX#Xo0eLJ8j`FOL|GDMR^?txkF29c24Vvd1E!uxQHgt;y81YjOu! zjp;J;xGN)3->N0bfIf8;zE=R@r&WRD@V$a)@^pF--T{aYgsdS<HUg({m)Sbxi%>m6 z=kpcH3&9&y3JBJ26T^haFJQwcoH4dA@8h?m<V?8YtxZsFc}T>cKO>g=dKi3Hux4v_ zeUOImhS7hB%rP(zKx8CsP|LHodVi@_ihb5Z@aUG`?BSd<WdaR78^DWb!FY;YR=xG( zt3-aF!@EAEFO_CHsXm-syd~WFWr5Spy@#xqmzZQ{&Xe9>>A@iG^Z=2>@T&U~&=1Th zMV!k;Klm*%i8WP9jZi|-hE08xbtxBtN%651fdEQrO<g)Q>Kk-rWSJ~j>_p+EK&m2N zBX;rh`iGFw`iGit(H0sx)h)1EMEKt!pj@OaN^-)GJ*Y#CHsU-hZemV!Pv9d=I^qh3 zOA*h-D^s6nSjau_Fc^t6$xt(aL@FRrT#U%0RXY>HS+FBvr-T{aBlq>8V!&w~3o(kg zcoXVat-|dhRQqDhEEGSOsOOpFyHI^gVIFk5r?6_bUFbDecJ4-Yc%E7l!;rb4m(+`! zLd*vI8>5h0ccjI7pG6Zm`Y%xDtu@7hn=FPU|KYX}mK1`#B&WUlQ=&f@A)CwY+|_Ke z=VN(!1zoPYm{@VEyg?PYhmH6kh`ie?4_6QV;vfEE7Xgc+Ew<h+-Otvjx9L-9qG@~t z+Oqg~q59(l+77Qe{<uFj1UL~4&O=!k;1J)Z?g$61hELsI#p8GBC!crelkT@E9?Aaf zYWcX1Tp4PO%pOK}Po{Hm?viejnMu9sPJYPe=Igt78@}FW1@DgU*qPx)K5Wldn+8Jp z`+06fI@q6VrUDa&0W{n5=Bx9lT%+2tsQ664IuQby<6XxFlc7V|s!fLnT;PJOrsH;r z-z<=9TRqp(p{6k?@SGN9ONs)ZoX=~gdp*Z-=7|=NA(>Q9ov$9w5A&ta-du992c4r} z!!(f^7{(Og*286Ik-IV>RAnFQLgwwDPj<^LrNa}KZ{mvH8KIKnW%AO)j8q(F^Fjuo z1q_vd%SwSFQgkk`^^I25U>L|Wm#@Se-w6K1V1j+fj88G>5pb3qO$;VjZmO8Rg~h{H z3?>DzO*H`1y8z}Nmh49YJo(bY1D%K^kjJzs9>YQ;U>8b8Sb^Ivp*2#ZBY{pSp8%j! zIC}_#3~Zu$+2MJRhU8$3o3+S6Npj$LlOJ6gOd=MB>L^wYW@J}Q3L1+_aaAdMk`!{K z836~{^sA#Q+_2$2YksI5`ZgA}9Z3^lb?B|!ktsyo(8F@-iYi+oxN}!F(8*wcUgirJ zrel4uo(pgu_Xb@g8DBQ*yiAN(&>D(@Xzgd{Tr?QlLbYy5xh=@sE}=iOo=AX^+;vBc z*JfQC8F@h2sbzX5P_z25(=rlrksKXZXIw0;^WKn>JS&jllYJ(QxmOuMLnR>8nP&c* z3h-fxyw(hJnbg%L$A9Z;SjCtEvc3`#K7@>bp#*8wt)AA`_6)d-Ae$=X)i;Thk6u3L zEa;kJapBTJ^_^s&(u*NY^Squ^xw$qX*tC+SSUu2UQvG{qB0ajmzfZex%TixmPe!aq z46|&*azi<8vqEx%wW1j!ou91*=p%3_6lgOFlw5to(W;{hnR|oc!Y#ZU-$HLFG1@QL z4xq)ILT)e{0u~)4SZShWrE==9rg<1QWw$02)*(Homw9Lr0$^SwqR}%$zs-w+x>|Qi zoH{1;#o*Q=Cwkmgi{kF`n)yMehRlwFGy8&+FVn~%u9mWg$e<zO(P+F>Af8DPhH*AE z1My5+08+%Gm9RiO(996=*zZ6*Y*Z2POwsrnGU3=*TVq}GII=n~kr(8q1FZ==c)}$H zKb2H0Ua#e$IG)F~aO_Ij8eUDxLlI;AS1*`2jwnJa?7avaZ!#7-UkB>+0NP}A+Wk<m zC$I&%I|RO?^i1)x#0+k0fGBI)u3N7SiAMazN5m!>kvC08kI%SZ?_F(NfGolcoMvWR z!!!$XznyR+FX8zLL~;-LVUm7wxU?Pq8i9<vb93D5VMKbnD`)gt2i4YUZfq$#Is;?4 zu_0vbNbVs*dU&Sm3~!$LP=RU{R|`7?aM4mSa^tp7(|hx5p|MZ^aayqf3+G=j`=3RJ z3uk~uR393Qh~=Cz`S~nNm=lR0z$_q1+E8$qdw}B-VW&ie`~a;K)mN39a4}z_0f(T^ zrI3bz-JXFM6S`EDeq;Eo(UfBs&#-T)2%wRWjkP?D>fzl&3C`>GxePeMZ-ezQUWmj- zXfYB2RNbFw)f7nlb^-PX6boyH2w!VBV++_n4!fp9=gm&2l`-$clU5iWV-7E=R4n-0 zuh${)R8dFYH=~6tv)a2fA*suHR0)#Via)F!-5}AdqKWTeawa44(sI&U?n7<in|3WQ z415RwFcGaV$T-BvSkId`0=ia)a4?;{1I$%04C~${Kb(2ljwQ<=hvq}9{Vpx3`QOkx zB%)2#(8EY=lC+{Va3)<gIZwn%UyaI86xQTWO-V<KW%&q(aiRl5d*Kli5+n~H$#b(0 zW0dH&5v+3tZPq!c(D_IxrrNfp;5HfLWBD+_vH2NdmO<VzgIsbJdh1j&$T4s9RR($Z zDucXJ$so4}=nyX=gPa6Wo`(iPc^TyPWK0IR83JsiU~XhsV<3t%o@;c@HP<ef9Vt+B z93({|<C%qSW|yol=7tr=64xHf-m*_%LJ>Ba*91PQs+y4J$4*As?exh`hOnAGn20@K z)l7>PrbIIshru-F9w1vrPcR9F06>CUxLw%R!Pn$W7yzj_BW&akZ8NC=G&IjXmB0<t z48L*O5vAq3oqE8pk#?jT#V0#DZDaz$Qy;GBh>EpM(iu5JeL@4+Fd_ua?B8@*hh~t< zADWBI=K@oA0o*tiFo%6`YNdKM(gq~XSCx4UptU!TD>GSb1^;2*rXp55+yXy@Hq3Fi znhkq&^YrZgTvuh=*HymCvW^rC)fi<@qVvwBAg`RrCpiU6@*H}jtmqRu!m44YveU=R zFp-(ksgC0ZK`na9#Hz5?l5J$dGT$GO!!|TM1I)YXOR2jJh-ZpfOcJAw7o(j9EFp6m zNTx6og6YCa0PRp{Ak+yCYG5dK6B`CC4XgKH(2iH%#IH35?PB$vN3^(TR=`<ob#9%9 z7FYws$)XkG_7j&-HgK_$9J0cq9)e<cj3A^iB4<*eWWkI;#RA3}!2<5M1q@D+U^0Ts zNyTN&2<F;rdA;9o6w^^=H<mD?mytUtB*7*%3w9TjdKJ~sfs*AWK>)U1pJ!&4sBsJw zM^Zex!3R5mcHjy%uOb9+!=Vcis8Kf28VgJoDVE4u*)5L~3GIF&?`vZVQ_T#kt^r5; z(Z15r_2?zex@}@YGU|;gp)_NoAQj*YmKGkCsX&!XtY2V)8NtLGM}3+!C1>HoGgETx z?#dJ$AJe4Ca>mSE=LotFKbUf<W=#sY<)mt2@uLegXZDQ??eGxzh2{s<fD!F%TGrI} z7$7yyggI)D&R%SeFRX)3wMo7evq~^uQ3)duJi1{USp70^6WKvVXvdS621PzUK%qU> z>v#(;u6e6#-Qr*qbriW8Z(+!-AyujvZ*`q&m{VJFhj7GOHie27gp4S;=YR-@(Y#;? z9M-A6iC^NduDgsxSC)*!Fj1^F3$g1t3|}DfmyCod;Ow%4UTj4yavfV?v>MiU3&Lra zA_7jJ3UTObn2fGrGTd6ZnK&q7vN4nSW0o=|$G;=7-xZrNd15olK{H}Aa(8O4Nau!& zgxVcWhkn(e!WZ}_>fZqd8rckj%|aM9bnHk;S;*Klm_pVzH*01BhCK_(@|eh&$WDuh z2{97~6A_b57>L-uwTM{CA=@qKV9w$?BIZT#EH@@%v1*HmX=6PRg8-Qy!T%CI0ul49 zCgSxmB^{1X$CMz(&3*z<RY#QY3D%M@{(+J3x|os?EzwVRA8kVLtob!;{JDooJlLX7 zico8^6w<GACc4EJCem?M6fksLM=R4lj40({YKmK8l4S~*!1YCan4C*0<Rz0ctPX8{ zw9!iWAlO<m@Qm-A`<-OgMh!NvYX)kzrEOb4Rx`}AeC8|&5x%ih11*OJWi)H)!ne*d z0xqGJ_?=b+3;NLdDB^{0!K}t3S_iq7G?1mVbJRnkSU+~rsV--vGiORgHV80zQ?ee+ zOi!%<#aUF6NKpZb@NTmwF?1>d8@h`_&n$;QLX4=_xB?sF=*N_WJ(Usas3fwt_xeo* zvsXN@VPPnM6TNviLzAE$&cZSk>#=_48YDFhB9yUiIUGu4>6|XGf6DP8{^O*IUdciW z7Oh5SO2odwe{elRA^7C{7so{7!T_lds(}(|5MVFJRtPUp^;jWro;(VAR~m>GqumHY z2nAWiw`hX#ZG@6Yc^nO}FxgTPEClgF23WF=!+2vHmbn4O;DZ5X9A+eJ#1=@`XA~u2 z(a>5+Bq#}Z3nh`cl7Ki}Nia1Ks4WkyR}wmLrdAUvG=hZg$d4#9g5c5>M*PFnDgV%P z1q(Qm$HbYOAq26A3OS9g00MLcM^Q^xfN<%Ganlv!q69O^%tgqgD+CC-f^yh%(iJv~ z<03Rsrn-<kZ8{Vqt6z0UbL6z>r>-8+Q#6n13ag=_U;tgAyvoAX#s1FL#jcy>5E_Qf z>SO}goOZ%=u-d3e%V69AbP*z$X7>W4kOIzdVp96js1Qk1Yhoir>;S?@N(`aDa+t{B zDKRURO(p=W(URy@BZ16g+H|m%jVUA#Fm8f8kPsF7qkdH{v`1wPb(%+JPlV+iO0v0D zgVd$crY%+5y!M=Duzpvp-&MrJSs#>HB^$2ZJL@aZB#T?(4)YQ%t?~#vjuzvL6;R_$ zlbd!Q76nw&9@fA^tXDdau$n-|5XE?gTz$vJI;08Uv#{<YNgxiFB<A_6`l%#IcSw?G zC8D%bP1vUvB#C`%NfK;NY4V67NfOYB08$MkNrO*&vAKCs=JMhVE$C;(*dh(k9xe~$ z!<XTx_-Nf_q`pRX)yOMTn;t?_LnublIFj|T*_M#kc7+8pLfz1rUDF5oNy81#6rBYL z$ecOfN?bBXC*NQ%GcL>K#B3YU1Jon}maXgx4FVpLZb}xXNmFQQNEFTQwG^G3I@6m# z(RoeLJTr<`)cNsIG}I?M-$}S0wZ<Pz2Cld?8m+b6FnR^nlR=}V?d-6=N7Z(5(CcWl z!O%Jyjryz}7b9Fp6=L9tn1!(3AHs`Sqv~Kv61mVF8>u>~j8=6}pClF^w#VJYxOe~9 zG#y+aO{d()`T+JILSfxn)8SzGpG*Qp#Hq(BwM4rqj5YdB7CEi}kpK>k^<9P|RU9@o z4z<LnSrKxEP;juY6ddx?6`WRSq~L7gx)P_NRt+8n?md(pvyllgq@I2Pm~w^;Pdo%q zkRclrC|)0%=_(RM6wv4C1Z%{DBv+{g05a(90-CZY4rIz77ebVLBKkHZexgjd`7PJ} zAf}W&tz{kVLp9h-`-a%NrXxwk^*zfnvAuUp{;~NT2_#_a3v$SK1aaEI*Gci#NitEq zCvvT<p3zuZ?WXls@It9Lq#mS7J-G5PpJW~YikcVdL}?h-VJ0P3Trn@(X=Wgs(7Dw9 zLO?(a=Mf!T`T^SjL{mB=4hKtSiOK?iRRKBZaE8fHCBzr2%;**^f$<M09x`B|RA!7v z8+qLON7)pXg{K!X%-4lf*EuQjOw7eIfY<c6cJd4W=J!lyL}QaDq97fVWd@liZ><gE zuJdRl<h@TU6`OZ!%tnpO-DuR_hY;qZC}ctML0pPvBs*Et?luUcY$}=yXcBSpUtHOX z@m9LGq#>68N@HZXTk|Q0RsBu+<nXc3C)6#)<A6`pz7%$&G08gBWBp6@C~u<nrJ|W= zJSEtvZL7pg8cj+0cze!tUYv44Zt%{s7$_z6V^%27NO6!|NX`t;WKd1?nO^8gW!!3j z<K5u0-<A`>E$?nnnJ)z_bu?~xDh1J5#J6Pufq1uoEE!2a((GDaDxjXB4_QDKkdxC% z7><e|AjdZ>Angd{-x!dKW1?}%7o|;64hyJ3fW451Z!bXKl!xO2F-$hZz}je=7(%-6 z2p&sm!+FdWRD(<C^vAn~(KJ~>_+%1Y5+&6!Vg@E5l%GWA0yJb4D-a3!ug%*`dgcq1 zIAFLg#jP_IjNoXE;I0$3@f#DtR~`(1o*{r##>miRrxqWEH3vi@LZbAvWy}Uc2|nSG znMrHxd@N{MUTY>%*zJ7kh4y3>S<nS6qYbc~9ERE|vcwlf#XKB^?N8-3R*_py!IZMm znVoNnX^ovPpL{@=DcJe4u-r&8tvx(y5rRU8c)gNJaPv@{$nAW5huY3xQ=Q>%U_&ut z=3p(A8`2ap7-8pI#EvL=ZRhh1D%VD5ffek0dJGGghECb}l!MB75v@$&S$4j?a4BFY zH_X(A{qTH_GVGZK32*}%Zxxo146m`pH}H?hUSrrp+=kW0uwUEND8t@5)1^nWiM+AS zT2rHL-SUWry(n6q#Spz_yJ>-kqYV4e6gxcO+OHA~dm0i6TWiB491MHj1e99@%1D@J z!LYX|RLfO8T@uEymz;5$tw-IU9bXo`1X0$8{V;k3@RLE5X4u;*3P+L#qO@-<q6}fI zIA{nWI-gFI4TkJRO_a@SHsj*kdc$7)8H}zGF)RhXVhoFwgUkYlZrCdtxinA+WXLjs zE5zvl8ul1KDhi>c!_j*U*eKn)$FSGB1i{d-&XE$rA|aR%R_-CJJch8C7TUHAXCn-I zmKSQnzC(*LNeC09C3&1?MBsJ++%Uvg9BwfQwQH6;;04@hB3J-G1S|Il7PvO^-Xd6y zfp2?#b2sn>u+lYyfo~IN2EM4)fM6U=f5L@3Ick~>Vva5Yx3$<og^0DHc$uZD+pJ)& zNreaF+c009MfVCl&!A_kj3ixIBc1&f?pq3KvI>Oj1k7re4N~THkIQ(n-apx5pv)Vr z2!9QifHvofyf~4^BS6t2(j<2Ev=ZIT?zZ4%Rr)@Q0p79@0dSbq$`_%Z-30g7Tll<E zdqY<KfKwdU*X2Hi<={(&V5~=lOsA!r`yMmR<r8Rjn095J60M*JPDiqEST=sHuM%M_ zBwV~j>wlQe`GSScWFtsLOBH~5@i|3VwzuVQ9!m!_PJoIKB<7+`dulX<7&F>vc?#&q zHHCQMW{a?)CQX~lJ7Q$)sM1W3?juN`+`1|ChuD@)o)TpYRuN#z5dqfdelYE!dGJY$ zlXd2@-tU*V&7)rFCZ1DNyfvl7R+5XBnS$170qRSZ+Pdq>No#_Pg=hRQYYd7?hps2* z!GtKI_Z1NoLq+ibqX^Yh5dSns1nEg1s+aX|1J@KZTa39EfCfA?%kZvQp~a-=UFV-S zrMy)KeuhiRh7gXp5V_V?I&XuqrvQ9BJ4ZKOsUBobC9IM9?3{3-zKJe>3o>DXaDI-y z(LP4H`P`ZWNu>aJQhd839B&5%ncvHGbe6njI5su_8}&Q+0~5WQuVGk4@$YD+cfW>4 zpPM<6#h>5OZO*)DPIM40%Nk(T9c3kuFgd+dH6XNTfOz^I^X1#NJ_ukdKk^{Fmc7jJ zL?9!KKZ7!UM!=X4BXy|k0tA#@nkd`fdbPU~g;}}S%qpx3dvW=+IluHlOi*3*e0_K` z8Qf9@?p6pZtbMXb8Fx)#g9W=}GmVPMD_zxO?Lw@1({9}$&0)#c$xzjYKE1k3%(Am1 zVFwsoQFeAF9KK8&YQUE3%mjM6?wP^zstj9a*4T1P;kYHaVeJ{7YY#X=9E_krAu{4{ z1!V))W)GLN2B^V{HUQFhh8g!d95kvhytl1JTcah6KGZBi&s!SA)q^<FFg`?k7&AS@ zkxEn!4KNH(g17`Hm{(><asog$&BlINKFno2w@U;LU0|{?sa_BTgc!0bq11Rh?}5`E z@|O5Ww;7d#@-;#tGZDerFl$AFhXo`W#0dJQ5e<6c8pIh4K9lT<i^hd2!7zUVdm$4( zn>Jj6B+DF>K<G3@nuhhEl@-`l5Yn0?7_tfJ>e4F6Q>8C}5CEW0C|4}Y@-<X$`wKO7 zUQIDkR!k%vcqp|A-k9|~idYf8{e)h^9Z9zD0oV<tvIPWA92II#-Rhqj6U<G0kH$Aj zUYkHc!+yl5zS_#n-1)|PVSspT#|tUtES>E(N#aZ`dB?hM6r&7+@dTch1_LChW9#)& z2ZO9SsZZ44G_SJi&%-Z7PVwI1`?B&jvK{4MgGu-JA10J%zi1Dj*&|RGoMGwc<1idK z?VOXMie+cj4lU9*!aD57HNAnnfZoZtq?!^Ov|m+6p6Q1i(utQ566r2gfVkW_Oq6Gw zh0kv|$`sQ#{<^+o_C*OP-%CP+FMX>zy{KWH)39Q$V+JL>0+ggqVafF_!am@azN72V z0MDI)EV%6ozOhk=036{wVEwF?qd2%wfXPtFYO^q)9Y?gyV|BMpo#@Sv=%hxN;ZF5u zKEoepS~wq!b|3J<o$91dc(`gs>#>o2OXF}TS6&_*P8?j-BZY;-+hAwoxZe9#nC0S% zmQl%boescA>}E(&f4p))lNv7^0M62M9MUoFB88N_-$32c>cnt0wr~;5zlZXJ9saJ* ztU_N^H=N0TOO}q20z&V>$YXn_4%Y;Za7rEzl#g0v_Mk%DArBOj-h$f($w1FC-CRub ziM}_LWKKLGQW7tX$kea`z4gJd5Tg!`)jDeELl*q`3DOnfw1Z=HSspJrG?p~LcxbFw z2#3a64Kz<V<`ucSDP2TfsG_}-TfjRN8LtnFRTuTqn4W*B!(tVO5?WzD;{3&bG`qrp zH@hn_JYm4Pxk$mFHVl2XU6rYU^4tiKnA_;an!9Ax2NSS$q#m@d?!X9BPVCH%uBu6# z@zjLiF&yna7mJ8#&?l;gl9Of+v|%lA+?8mNEuUZ?pnqM&XUY|{qL;Mi!zxQu*woA1 z8aD+6GXe^tnU;9%!q#48RL&}b3aMVuFPhLEv4CigbO;%yp-Ne58l_OdS*5jZ{tcy2 z|CkOw1Rj%$%g>4t>`2B2oh_8@aQ<PK&PdO(0wV^lVCO=6&IZAwAZ*HvS;Dmx0@Ec= z)?-h6@Y<{sLd3mm4I|)$qnW~CWmI;JzD@EiS<Aq`V+`Cn^LMR4DgG$4<()9es~kr$ z;Z1oW4Vld@`pILJw6|cyL>Vu9%BnxOLCOU!BZ<r^S*qj+!7PP-^Ud}$JnMl3nNhrA z>}T=8;LfD2pB3nKfAa)~Y3Z)6?L2i=)2$xT+j+ev&aZoDRNy>n^GjcMsiEqx>Qa>H zS(!4(k_WC^6^IiRcF%ZJgOApMS`8fl%&9wg)2I%<PP2-wv_zN_Y7W=i{AJeV3@q*? zXGN{WS-~W!Hp{5d)l97LiDc#;8ovZm*JGZb4*h_Ad{lheFuXTBqTy+!N_+F>Zu)4l zJm~7<_$%%}^=XHA^_k@0fXwNo`Cu9nLuQAVhRCY}3tp&sEsvQlIjQ&^pX$6I_~_la z$scF(!V&++v5STwD7$wI7<1EMGP3RD1d7xdLWjw9pKqcFHv!&f;c<PV0vnb#xd%S^ zi$Kg*u*O2~?ILo(&6waoySW%4b&-$Q>-2uX7~vl}8(%6#_ny3ibYUkdy;$-ylZdXs zR4@aybscS;B{N;yDw%8*%eT|`MD1>|Ks~Y~CQ&UCCzEImWy2ucW#^%7B?3Bgw?o2Q zwk2CYmonKR4$m!O)jE_^!bzUl7+3^4(WY9*`e{LDT*lI6M(E#43+I$JKY-}g&V&@c zwKaII8nXClA1j7TFD3b*bP-LJ!PlRn9Rz$Z-{?XHX_EG*WD`yLux)jYpTtYu{b5mx zmdgpqk1b@OkR_01zBq=6ww6?2G8V86E+bkP*H|#U42HZcoaDgR&a`9A^Rf&tCx>3L z-<e$#7o@X%6c?mg93>T9Ir^=spz%kW0r!mwJUWKZrA{?w7-A<jrw5na;`?NmGq=l0 z)hJQApi3Fp%sy6hm0pla>?8dx`VT{x<d@w4!nM>*ioY5X)il*{Y8N_znaP#Mk?JFD zE=0#H<{@j^43e(GST%BtT6&QPJv01r*&45=zj=98a?|qb@+iQXR)CaNnvUcJowB_* zUOT!y@F1ujb9)~<o%SAelp|aDXm`kY&D)4}bdUbGOlP-`rQv^_JWadtNBeU^KgJb; zt3Ig|M}2wGeD&Yh=rhgN@}ij;KCU4~+4rx@tH-z}^4r3X<?oqi+aSj;t5kWReJn5U zZausn6P8AMx2!-oGcynermOD_m5?LTF|FQ#e>yQOr=OsCise{+@@{A_nTAK2N1wDu zRA?SOWRKXh**yA;J<_4kbz3JBwWTDkYFXKk1WN07AGLSL7q0pvx{oKf%;xh<rPJP< z4QBg#t9^wxdvDP<1fPUb)6RWrr@-Wwu(oS=30`_ha0=BUSF3AMUN5IEhj6xZj~*Wv zq=c`627T|BM3@6Qsx}`**HDI?ACM%$ilf>UW{+mVqlxB`ykC`?ZXU^k)1zMVXo~)b zX`#6mFIUu^;B2@0vcd((DJJ<J?W>g`J(57sJY=D_vh>oiXY(zu{)H!-Lfa9dS(VtC z&R&pAR8tB|GGtvdfh6Ec2`bfoolcjOWa#~j&{Mpxn|JM^hitc*;-*nwt~fFR=wfZ6 zS7fX@MqUl~{DIs&HyI<Mr<q&`8H}wCu;_JYEkTS0UsYi2Ol0nb*v)AtdMX&M1R*;` zOIe`UaCD_~A#_bKC((FQ*;bs9xx)PX>GHi2L}Tg}1ljR!&|fa+gPa#~apn@#2=r%< zEE~ihK}KsIK|y4*%|C;tev4!Ma*-4|Sr1%&F~qUzcDk8OxHhK#Uy7?XS-t3Rbvt`< zt=44qR%;-WIvTH@Hg7iYwA>iZDmh#ol#-vUc0U)oMOD~0zh7jyJL~W!I_#!NlE|PR z$Dy~z5asAAZvr?ctA}xlWohpsHOCu$H82<iEOP~Pz#*W+9yUXL-b~_{UiFtM19EI3 zf>3^1V@2?K$&)IX7NMlKuJqM*qkeJCO=?v1N0TTNteQlTai!{50LN}0)=YT58dOF* zflM?LkP*}gILxT>8buePMQ~|hNpyoE7Bs1rWiJ&6(5hAH2^Kv2l8Ltj!MI8J)$slD zd6o=OoqXi`=Bty)R(POV{whBnHV>a4wIsDyQG|+<QXo%yZxXj~Oil5px@xUT`Y}9l z@>)@()>v2x+(Nrw`RtfZzF`D2qHPU@G#u!@DwcR%kUq$|hXRUh(?!?urFkD;F&7)X z0{I)g0%ap#H5~wFexYEx2yISjy-|ixQhnXgepbD^&{AtA5B2Pe#m*SadIO7EoS=(0 zpu)&%ZtF`$?ToOx`pCSTIc9||H;k7vt>lm+Dr&*{p1+V_#j1pHg%+;!RZJt(G;5Wc zx#j!eVmT$mB=kncrweUYAka8JKf5K#<NrGGOZXI1Ce`we3pHKU^2tPBh&J#;HgQs2 zpI*rK+tUjd!>n$-25A%Cn>@+bh;ww^1mC^VnqVzx{+G89O0o%_SUmLvcNRy_ho*ev zR~yYDwKjTn+7tXpx1QjqJ-HM7-~aaXCiv&q@XtNhx6q4eP%VEY59oynPRTzTVdKd_ z)YckLQ%^3QzVJ_v4^N+O<Eaate&cPC4F5Ip^sP0XCZAk9-DH;7qYI47+Ci(ePL-@O z^K|l-bE1`FcW>lg>6g1c(b@S+cXnhmHPM;}I%Fd&ia?Otf(ckg{NfT&gq04=QAks{ z*7&`MJt8cmR+uQo*G1u&hGR#gxy4boA&9vbeyXwqpZKoR0p$f_(M-D4HDI%{0tI{^ zT4`RXqB^qyF<MPs$Q{cGPA`m5NQUxbN@)z!7h{%W^1{~?$H#3KS6|pgs?#U2w4YGi z4@=}s2HWeN3}&jIeEppdBu5s?84NcaVOqE?Hz?{i#Uh6L(8;J$yO?%KIZf!<@;au% zo>;of=_b=K5+4Sjmw>8}DWx<TCF3EzDa$i@RFumDDhyEuLxTfK3hU(r>An+Wd0<|F zNs36LC_OjBG+=WouQLW{Q_=-{Qdn_E=Pfr#2C+^+Y<apJIx$0fe4#%hR01;QoGb}O zr@RrL1HueE;C@nBzlDtTDaC*(i<>aqo^+@pDwwBZ#TSPo>fLQJei^2%FC>JXg_Ru% z!Dn@h)*VdjNT#Y~g{D->En<zFchJj6ona=c-|;WRBRC0UOGTWA6S-81#FydGc~xj` z<lE4Jy#(vk1J-Uex-guC=dKPcSZiak1^qA}K@*d~6h{)w<JIqZlhhpnoncA=Cb>J^ z-~c>q3tEbYut_<rTG=<Q_OcCz*vqmIZOfk}A;ZIOT>-P8Gd2=O5?_E|!t3JqH6wsX z1V5H<Kn9^zt+bC-e;AR$Z!VK)<5km3HQj#3$_8g@D+V>c%}`P%#oJ`zXlB{_t0gR( zWba77@C`wf9&5@8CWR>v05DlXU0VHV1c2XbCe|F>wdMcB%Uk_*<s~_A6!9zImczv_ z=B$#7Q<^_sxRm-{WDZLm+*kuo<npwFgNUhPF-<-=&`&Q-=9z~mr(c2=)|O4L&>Nd3 zu{?vIV(m^n<4vh;G9)mN>=COq_K4LQmT>J6t2OqB)f!Gku}7@d*dvV84y!e4i`5!? z#41Cl8nZ{N)Yzjj_t^x1N$*ZA^{_|*P<S6(;FJRwcdQ@UF3)^{%{}YooQ|7h=v|X@ za1yX2Cf?MT@PwEFPbQY!fD;0YLfnOM=ZFaxdOYV98```axi<)s=sz(>>z$u-AO=wc zHoavatf57el>!|iMXtD%R8oJ%o5r#U-2UoY*`*^jhJy}$n!vyp9LMIbQg;2Yqrx>7 z6wiBwX_;7sfJGTVY-i5Vb?-lm#ca7;&#jnk8^^6Y+@GS7w2>qk=#>A3(miZC$DTte zEU`(>&jfFLzMKIatshzYM_&IxmQ0&Q3K~!+PD=1|?$C_2S~o~ZHQAp*-?6$dWwW58 ze~2=(BD90y_NQ%FD5jn1^gyf1&Hz4oi$N0~STH>=eV^8^IRY_1Jln~VG?B*gCGiQx z=HyfK0ck8aO*Aee0s>&x`cs0@;?l5@)@O*BR4FhbFBNZ}lA=T6w4^Gc0;MgEO}OD> zFnfn)=8=~luI}PfxvFejq5%^hQ`XslZ6l(O-p^~2xN(P>C^Zo4v7a?CpjwB!uXc^< zo*QMX4~&VSmNVd0Fd2y30+BY=Bg==O$E5C2`GFS!EgwGwcb<CGL;^y$qdG~!$Si9n zwjHd-z_?>zY6wnLkHI-L3}>H6=}ht0W46;f+YO46V1)r5X^)~?LS8yzhuEF1Un;Mp zHvb?84Fmzecx4Of0i3B*zm8qpJZ1DWT_QKKn!rJOP1A_47PV`gCKl_bP%1KN!bJ#8 z*zBcc-4G?z6e}#4plpW<1#{zL+eiULb^H=RHoL@nOb^i+**5UuGm=yRG11?c*2}}g zEIcGAMN(aVf?-S|SZzJL%R&}vZdKc6#tkQ84dzp)15^)>AJ$0_V8k{IBB-#I^sCJK zsjz_sE~z_7B%vBV3g}};7wu$yZRfK)<u-WdqIqP!jT+4CE_*Wb(Dwu8!NoDBmJ;8T zKLDmcA)(zzuKCLNVZPeuJW-_10b;qrIjh!ip~6z*)mLn>8%(M{_Db3SlqV7fU_=mb zD^)#uAoI*yz%_+PtO*TeO+!K&as+$`w6lbn5s;Z^E)ChlKTyqw`x%&?<i80fgfO;I zF#%oEeWKDyW<Vh|ZMuqtM!^<6k|AD`pxUf|fC<iW5k>0MCgiS3Uo~a(3dhF=DXAun zJdwTxW)NW$#f1q#F<229oFWajM%Got-zZ}$MWG!{k&3kiOz~@{w52sO5_s^YCIDM= zdNA2G^8tb`Fv(ixb-f|;kD}o;R7L6*%`P|^gO}0EVTpV39*id1P)*R#$BYbSt&Sp* zG_!--qJ7#nawX(Pnzo}Y)|n8O1kq-xrl+!0QI(9_f@JmBgWV`KKr{x_F+HD*C=mXJ zQBM=KX>6s(s!wcOTBdQ!7#=HLl{G7(<YN}w&|%Avg8GdUHrydX1Cv2(8<3mUHlPVv zMdVtOH(+9Ytatm0T9L|`WD}tM!v8CFBr_-+K2Yw9eP*4pS>|AEbfXcHK|=@;84(e+ z(Y+oKxSlmCjL?L*+UOot-j9%rBbMELTbCV$PKy!(xitIg+M^-tDW)0{NOsCBC%VcM z&!9XTkeOuQE|_Fmz1MRSzqXW*LTPkX*Xo#1Af4l|8SV;cj3X2<^zmy46Oo6}JuRW2 zh5Xi=mQY~z7r}^7(9TgTA58GzB1>oBt;K2xTs0|qDwAvs%SM;g0X(`#V=7bLac`|8 z6pPC8<_$evS{O{&O$Xv6T5!2HQN2jP0gnFgM|?v=)5#%rb7)xtd9|R{Nq7i7>M&f9 zB}sQE0HxuY@uvual{V2SjC9m9*wPjrFlfeJ2DeXo)~e2Lbz_gga$$o-$ldw~j3k27 z_}$zirPRzli325WFbKMaW_9XFOQztgAS$hpvjp$kvd_~~q|{sHocDoSl3gSODrCY5 z8XNbK#k12l<T~N5_@7V7nZ@1H`J`ntfhCJx$bb2E28B0s9v^rwXY+vrvdg^^RxPV* zG9_QwOOy}`!?~q`w&H5wC!;|#Pr$<@|G7g!YKrDA_j%C)y#vw^%xXsxOj>-#vTgV^ ztzQGJ$kEnhIgP@h9n~l3!~}8b-F$xvH?0Z*H65tgy>KwSg#u{La(W9#pi+VvN0Q7_ zT*o*2nrH#_VPM8^(v3fJFX5!#Z7a*m3G}bx<g1CtG>>igrGDYGKv_C1HH=^*_<eU) z4ZrFOejJ5S@K20}|CEsWz%Yia?P2H<B=JxDB4RDaFCG+I1~Z1(_!1Pu*(J}XN*2Lr zL`DZZh7rujaxhuWgsY-i)ifg-&jF6CI24C7Ir_5<Y%<`w#_QdV*VhNUc4Wj%$HMvU zIc#$oF$D(7vy$Wnev=W<d`;`GdRxw}@W1{n^VTw8+<jCt3Z1HjP?myBo=$*nd5@iP zM&~a}70u|v9q5CD|M`LRy4o)j(4hE*GSyV^sW>b2j3B@W(S&aj$r3E!L}?&lDa}bh zhwH<0^9xF*`JtJyKqG;YZB0wj?XJ-^cb`C^&?^l=Umnd>Z#vrBf~cD`uOw}<mrZ~` zu#wQGsW^7wpesTLxU#!Nqvuu%n8Ix1HNbCb1J4HNQNSx9z9=_E;88OA16*UnwZMxi zHUfT=h@j`dZ+aZSgV#-vBL7{yWkCT5z`*qnR5K;U<phDlA<8jTNqmHtc0$>dh>T-t zjUuN5kxf5lA{zs>C`4U>j5skXS;*Ls$RLE^ylJ*jfl9?u-t9)>R3|EKrwGTRZsP+{ zTS~^-R#+vrY3C|7=4L)-lx!wnnM?nY5L;s2XKFsXf0)l2adGpoV1qE-K&tLM%@-Rr z^=H{oHfFZX%)n|2_Y%vg&Vj8S8i&yBam;}wn}+4Dv2{7HY{D3zttygV50YcU#{nJq zJTe7#OewHtELNvTfn^=}9iNf}({e^8J?<-BeM`sBl-I*i2gDE8=E4SzPumAs#u(9q zx2N4++QSG%hQ=U)wT&@fqf%fE3l-T#?G)IU^21J?r_yqn;s9YF_ulF9^O32ngSHOo zh-?|`I@U|PVE0KNBE*68H3Dn+Pp;a<GT3O5FXPA^8oyYn@K}3#`$K#{htWiw!F<wh z(e*_AP!EJMK3*};>)MFiB4dp<#rX6<(@LzTS>5(RU|G=6wFM`dUY>{lg(*jcK$i~= z)1@^>BM=c@8YVMQ*=}oP5RH!)p)|;nar4VEB12oZU9*Z-rv#Tgcj_F-Q&xxN+bw+J zv|8{wBLG<Sb;-2=_<`dj?84R{g&bVTQ_YXrI{4k#I(lLBg+_7f`*}Jgol?96@o}EO zpp0bjni-0*fjvkxAghDCAz8M%icE5owFzyF=`%ak(+>C5M_#>9ohQ}U5*z)!7_pEg zmCYzi>V=iMQr2}!x#^`frE-<x#Z$CR>*0hNnj=vpD?VF)PTrwy571RSVWOG|3=>8{ z^U15fR&m=ez_j@y@Afd8uey2EYzinw&-|WPp(<3OJs~0}8PF~I6ngY~EjgP6pn{Wg z#On74a?VB0B4TCMk#xv6?#iL5e79knias5Y4@A(ma<BnGTZ1%X1kEakP(?riWh#vI z7z;ef{D>HY`HhKzs+3yWJ0wCAMn5**|FMW6vft^@!=H_$hfeR8106VJNPI@hc*2~2 ze>!R}YQ<U@xYn8uk<)M17j~866Ase0S11mbYQ%r)y+JTK0V%?fSJzlp8)BFOWxE>M zMeWO`l8}Zgr#ZNknNJoJE^7OgNDC_pqZ=5l&P8Wgol#om#SPt|9O<CG1(89;5JE7c z#{v?}0b7<jc>>^i?-205kwKffY*6*`VP-*{c{1e!+j@R35XHn-=RxcQ)&e=j!d)x^ zgjM92+is9ERw+ME0it>S#n_38GDPfz$&EX`-I{pvI&ETRai+n^%@Iyb_^7Gyvjw2( zka1&J?Fg5KRr_XGg^wMpz6(w3nH!JoG!S~)$n-$Ssrx$(Tt0^WhUl<Ue0>tg<iCAY z(!Kgt5;tG%4E)_gamI8TXj_K-w!U4TrcqKd2c%JA8~?nbHcF^8D*M)^)c5#oof&o$ z{rFJpd1#%9kRb(k$&d;=rmNQWG$qL9VFO(i`pZS8^(L`v(xzpSo4TQ8Gs+Y;ww6uU zOXgz&5K408$67?QU9Prklp>L~K`;|1W<Gt<hwPaUanxs_&rOc#V@<%DgEvf4nUwNk zD&#wqi&v*)It2T&*=%pc`gxV~lYF;xB~!JQ!`a9jlI`hUJ~RjvhqDf#WsT9?O*_Wj zNi>6zl8u~7ig!!mO1LT*+Fl+JG#!^!ae?G3wjaSFMD`X~Wzgbm)N2}oBbu^G>aW$k zenb_{GMXj|;IU+f7D;keL#)-hK}xF0ehS#w$(ekOvQt>fq{~u(*LomMse*enN5e6V zK{lBJ;H)=_=(OLNG0B2qw3r*L7h$?77NIaK5zzS^2~MG|%_5j1(?kbAsYh#6$r-e0 zwZbjegxbPgx=NJbU_bI(jtnLi%88IGp{Q?oI=;aE`A12Y5OkI>5nuxd!KOia@`N2p zr2w!rgJe8F>SP8B+TtP5zhy|s=;2|q_03{uASlQ+vhT(r#XadL1!V~(1pA5j8>a9q z<zAv7%Xp@j@2I37Ic^70$nL(e<`=g}&bs&J`DDR+n~^Ix!g~Y$QQn)16Zxu@lKOAR zHZLx>gdzBfu(bwf1_EEWX%ygP*2nEDbs`&@f1NV7)w7hWLFK3+n`9oeBhAHYH{e$~ zY4PhUj+(-sLDU9;z5&0&#iw9dZ;DMW;CBO-{ZPl2MBEI*w@ggl7&s8O*hdW0HQt3l zMACyXZAUS0C7!tvnWz03;2*W4gggL6)<TJ2zjalL0P%X!7Wq!Qe~`UdqyWI!T-JDs zlD4j4+egxNvU+B;yixO3$+mzf!@Wyc7B#fh<ojkO<=jr#RukoIX1mMLTTR%MEg&to z)4q*sXG?o03HyCj!VbrnXm*nP(2O0TH&JOPiIy>Jn1hW#^<6Vo3bFVorF4T;Fc2Q4 zRRGhb2*e{G5a*FdlY=96Cjw?tSPB&aW;I6-<l%Hl`-6;_rVh`h`6hBeA&Tr32-B{~ zjiOATQjCS%<XWh!2FVfVk*HA}Zj!vT0MGwtLroxt)P|>#76!8hT%01{bklgcsipdZ zMTBUTMv_|sb_e>>(QQ$^!FbEPFm+`SCs)*#G(xZG0&6D;>$qv3DfL_}CT5x-x+Uml zCJ&~GWejE|1~%EgNpxQIHvFNq+*Az|AK&CVElb&sa>q?4YmLU^L9>!4(<V=5waU23 zCCMgJo|~j&r)gYq4+7kZd-Td#$pWhI+rl3`MLdedzLhTYBUD+W&a4RA3tJRK(QQRR z))(eQ69w5sMHq;%d`>syqO`oeTwdR7$;b2nJL?RAYO7^7iU}5=KuMv%4C<T@Al*0+ zfNMyEI_&3RohBMJ78&Z63JB34IzM9@9$IElv@$8=PRWOZBU%0l^7Bg`gbRMNV>Y;` z*hSDnIW+qgTjE7YI_?=|yLH>#W@wHlzRWTo{ve$Aq6ZbW7C2hA6JNOMdsfR$%+Q2u zYThWb4_Xw;hidD=P;F$#CTg=rLw(k=%4ybnwE5KYxXod;cbOU*3cWdsQ$3c}65b#u zM5ZCq0&gi#BYMAIwwB{9+8#IBkr_?A!P-FPf%~;<z=%EAsf|X-&SuvqM V~pcT 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 B29f{( literal 0 HcmV?d00001 diff --git a/plugins/official/ai-completion/plugin.toml b/plugins/official/ai-completion/plugin.toml new file mode 100644 index 000000000..8cf1c5630 --- /dev/null +++ b/plugins/official/ai-completion/plugin.toml @@ -0,0 +1,44 @@ +id = "openidea.ai-completion" +name = "AI Completion" +version = "0.1.0" +description = "Inline code suggestions (ghost text) from a local model (Ollama) or OpenAI, as you type." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: local (Ollama) and OpenAI backends; the key stays in the system keychain." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["storage"] +# Grant the backends you use; the key is only ever sent to api.openai.com. +optional = ["net:localhost", "net:api.openai.com", "secrets"] + +[[contributes.providers]] +kinds = ["inline-completion"] +languages = ["*"] + +[[contributes.secrets]] +name = "api-key" +label = "OpenAI API key" +description = "Used only with the OpenAI backend." + +[[contributes.commands]] +id = "use-local" +title = "AI Completion: Use Local Model (Ollama)" +category = "AI Completion" + +[[contributes.commands]] +id = "use-openai" +title = "AI Completion: Use OpenAI" +category = "AI Completion" + +[[contributes.commands]] +id = "toggle" +title = "AI Completion: Enable / Disable" +category = "AI Completion" + +[[contributes.commands]] +id = "set-endpoint" +title = "AI Completion: Set Endpoint URL" +category = "AI Completion" diff --git a/plugins/official/ai-completion/src/lib.rs b/plugins/official/ai-completion/src/lib.rs new file mode 100644 index 000000000..a908831f9 --- /dev/null +++ b/plugins/official/ai-completion/src/lib.rs @@ -0,0 +1,214 @@ +//! AI Completion for OpenIDEA: inline suggestions from an OpenAI-compatible +//! `/v1/completions` endpoint (fill-in-the-middle with `prompt` + `suffix`). +//! The IDE cancels a request as soon as the user types on (the fetch returns +//! `Cancelled`); the API key is filled in by the IDE (`{{secret:api-key}}`) +//! and never seen here. + +use openidea_plugin::host::{self, storage}; +use openidea_plugin::providers::{Document, InlineSuggestion, Providers}; +use openidea_plugin::text::offset; +use openidea_plugin::{Error, HttpRequest, Invocation, LogLevel, NotificationLevel, Plugin, Position, register_plugin}; +use serde_json::{Value, json}; + +const LOCAL: &str = "http://localhost:11434"; +const OPENAI: &str = "https://api.openai.com"; +const LOCAL_MODEL: &str = "qwen2.5-coder:1.5b"; +const OPENAI_MODEL: &str = "gpt-3.5-turbo-instruct"; +/// Context sent around the caret. +const PREFIX_CHARS: usize = 3000; +const SUFFIX_CHARS: usize = 1000; + +#[derive(Default)] +pub struct AiCompletion { + /// Why the last request failed, so it is logged once rather than per keystroke. + last_error: Option<String>, +} + +struct Config { + endpoint: String, + model: String, + openai: bool, + enabled: bool, +} + +fn config() -> Config { + let get = |k: &str| storage::get(k).ok().flatten(); + let endpoint = get("endpoint").unwrap_or_else(|| LOCAL.into()); + let openai = endpoint.starts_with(OPENAI); + let model = get("model").unwrap_or_else(|| (if openai { OPENAI_MODEL } else { LOCAL_MODEL }).into()); + Config { endpoint, model, openai, enabled: get("enabled").as_deref() != Some("false") } +} + +/// The last `max` characters of `s` (on a char boundary). +fn tail(s: &str, max: usize) -> &str { + match s.char_indices().rev().nth(max.saturating_sub(1)) { + Some((i, _)) if s.chars().count() > max => &s[i..], + _ => s, + } +} + +fn head(s: &str, max: usize) -> &str { + s.char_indices().nth(max).map_or(s, |(i, _)| &s[..i]) +} + +/// The request body for the text around `at`, or `None` when there is +/// nothing to complete (in the middle of a word). +pub fn request_body(doc: &Document, at: Position, model: &str) -> Option<String> { + let at = offset(&doc.text, at)?; + let (before, after) = doc.text.split_at(at); + // Only at the end of a line or before closing punctuation. + let rest_of_line = after.split('\n').next().unwrap_or(""); + if !rest_of_line.trim().chars().all(|c| ")]}>;,'\"`".contains(c)) { + return None; + } + let body = json!({ + "model": model, + "prompt": tail(before, PREFIX_CHARS), + "suffix": head(after, SUFFIX_CHARS), + "max_tokens": 64, + "temperature": 0, + "stop": ["\n\n"], + }); + Some(body.to_string()) +} + +/// The suggestion in a `/v1/completions` response. +pub fn suggestion(response: &str) -> Option<String> { + let v: Value = serde_json::from_str(response).ok()?; + let text = v["choices"][0]["text"].as_str()?.trim_end().to_owned(); + (!text.trim().is_empty()).then_some(text) +} + +impl Providers for AiCompletion { + fn inline_completion(&mut self, doc: &Document, at: Position) -> Option<InlineSuggestion> { + let c = config(); + if !c.enabled { + return None; + } + let body = request_body(doc, at, &c.model)?; + let mut req = HttpRequest::post(&format!("{}/v1/completions", c.endpoint.trim_end_matches('/')), body) + .header("Content-Type", "application/json"); + if c.openai { + if !host::secret_is_set("api-key") { + return None; + } + req = req.header("Authorization", "Bearer {{secret:api-key}}"); + } + req.timeout_ms = Some(10_000); + match host::fetch(&req) { + Ok(r) if r.status == 200 => { + self.last_error = None; + suggestion(&r.text()).map(|insert_text| InlineSuggestion { insert_text, replace: None }) + } + // Superseded by the next keystroke: nothing to report. + Err(Error::Cancelled) => None, + other => { + let msg = match other { + Ok(r) => format!("HTTP {}: {}", r.status, head(&r.text(), 200)), + Err(e) => e.to_string(), + }; + if self.last_error.as_ref() != Some(&msg) { + host::log(LogLevel::Warn, &format!("{}: {msg}", c.endpoint)); + self.last_error = Some(msg); + } + None + } + } + } +} + +impl Plugin for AiCompletion { + fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, String> { + let msg = match id { + "use-local" => { + storage::set("endpoint", LOCAL)?; + storage::delete("model")?; + format!("Using the local model {LOCAL_MODEL} (Ollama at {LOCAL}).") + } + "use-openai" => { + storage::set("endpoint", OPENAI)?; + storage::delete("model")?; + if host::secret_is_set("api-key") { + format!("Using OpenAI ({OPENAI_MODEL}).") + } else { + "Using OpenAI: store your API key in Settings › Plugins › AI Completion.".into() + } + } + "set-endpoint" => { + let url = arg.map(str::trim).filter(|u| u.starts_with("http://") || u.starts_with("https://")); + let url = url.ok_or("give the endpoint URL, e.g. http://localhost:11434")?; + storage::set("endpoint", url)?; + format!("Using {url}.") + } + "toggle" => { + let enabled = !config().enabled; + storage::set("enabled", if enabled { "true" } else { "false" })?; + (if enabled { "AI Completion is on." } else { "AI Completion is off." }).into() + } + _ => return Err(format!("unknown command {id}")), + }; + host::notify(NotificationLevel::Info, "AI Completion", &msg); + Ok(Some(msg)) + } +} + +register_plugin!(AiCompletion, providers); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::HttpResponse; + use openidea_plugin::mock::MockHost; + + fn doc(text: &str) -> Document { + Document { path: "/p/a.rs".into(), language: "rust".into(), version: 1, text: text.into() } + } + + #[test] + fn asks_with_prefix_and_suffix_and_returns_the_text() { + let host = MockHost::install().with_capabilities(&["storage", "net:localhost"]); + host.on_fetch(|r| { + let body: Value = serde_json::from_slice(r.body.as_ref().unwrap()).unwrap(); + assert_eq!(body["prompt"], "fn add(a: i32, b: i32) -> i32 {\n "); + assert_eq!(body["suffix"], "\n}\n"); + Ok(HttpResponse { status: 200, headers: vec![], body: br#"{"choices":[{"text":"a + b\n"}]}"#.to_vec() }) + }); + let s = AiCompletion::default() + .inline_completion(&doc("fn add(a: i32, b: i32) -> i32 {\n \n}\n"), Position::new(1, 4)) + .unwrap(); + assert_eq!(s.insert_text, "a + b"); + assert_eq!(host.requests()[0].url, "http://localhost:11434/v1/completions"); + } + + #[test] + fn openai_uses_the_secret_and_only_when_set() { + let host = MockHost::install().with_capabilities(&["storage", "net:api.openai.com", "secrets"]); + host.on_fetch(|_| { + Ok(HttpResponse { status: 200, headers: vec![], body: br#"{"choices":[{"text":"x"}]}"#.to_vec() }) + }); + let mut p = AiCompletion::default(); + p.run_command("use-openai", &host.invocation(), None).unwrap(); + assert!(p.inline_completion(&doc("let x = "), Position::new(0, 8)).is_none(), "no key yet"); + host.set_secret("api-key", "sk-test"); + assert!(p.inline_completion(&doc("let x = "), Position::new(0, 8)).is_some()); + let sent = &host.requests()[0]; + assert!(sent.headers.contains(&("Authorization".into(), "Bearer sk-test".into()))); + } + + #[test] + fn quiet_when_cancelled_denied_or_mid_word() { + let host = MockHost::install().with_capabilities(&["storage"]); + let mut p = AiCompletion::default(); + assert!(p.inline_completion(&doc("let x = "), Position::new(0, 8)).is_none(), "net denied"); + assert!(request_body(&doc("let abc = d;"), Position::new(0, 5), "m").is_none(), "mid-word"); + assert!(request_body(&doc("call(x)"), Position::new(0, 6), "m").is_some(), "before `)`"); + let host2 = MockHost::install().with_capabilities(&["storage", "net:localhost"]); + host2.on_fetch(|_| Err(Error::Cancelled)); + assert!(p.inline_completion(&doc("x"), Position::new(0, 1)).is_none()); + assert!(host2.logs().is_empty(), "a cancelled request is not an error"); + drop(host); + p.run_command("toggle", &host2.invocation(), None).unwrap(); + assert!(p.inline_completion(&doc("x"), Position::new(0, 1)).is_none()); + assert_eq!(host2.requests().len(), 1, "disabled: no request"); + } +} diff --git a/plugins/official/checkstyle/.gitignore b/plugins/official/checkstyle/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/checkstyle/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/checkstyle/Cargo.lock b/plugins/official/checkstyle/Cargo.lock new file mode 100644 index 000000000..5c3d4f38a --- /dev/null +++ b/plugins/official/checkstyle/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-checkstyle" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/checkstyle/Cargo.toml b/plugins/official/checkstyle/Cargo.toml new file mode 100644 index 000000000..2a04ad515 --- /dev/null +++ b/plugins/official/checkstyle/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-checkstyle" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..13e6c1d643afcb21ae484ae2295dc5103d7d74c4 GIT binary patch literal 157899 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 literal 0 HcmV?d00001 diff --git a/plugins/official/checkstyle/plugin.toml b/plugins/official/checkstyle/plugin.toml new file mode 100644 index 000000000..444157b6b --- /dev/null +++ b/plugins/official/checkstyle/plugin.toml @@ -0,0 +1,33 @@ +id = "openidea.checkstyle" +name = "Checkstyle" +version = "0.1.0" +description = "Checks Java files with Checkstyle when you save them: problems in the editor and the Problems view, quick fixes for tabs, trailing spaces and missing final newlines." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: check on save (a newer save cancels the running check), Google style by default, quick fixes." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-event:file-saved", "on-command:*"] + +[capabilities] +# `process:checkstyle`: the `checkstyle` command line (e.g. `brew install checkstyle`). +required = ["project.read", "events", "storage", "process:checkstyle"] + +[[contributes.providers]] +kinds = ["code-actions"] +languages = ["java"] + +[[contributes.commands]] +id = "check" +title = "Checkstyle: Check Current File" +category = "Checkstyle" + +[[contributes.commands]] +id = "use-sun" +title = "Checkstyle: Use Sun Checks" +category = "Checkstyle" + +[[contributes.commands]] +id = "use-google" +title = "Checkstyle: Use Google Checks" +category = "Checkstyle" diff --git a/plugins/official/checkstyle/src/lib.rs b/plugins/official/checkstyle/src/lib.rs new file mode 100644 index 000000000..b4ef62b72 --- /dev/null +++ b/plugins/official/checkstyle/src/lib.rs @@ -0,0 +1,317 @@ +//! Checkstyle for OpenIDEA: runs the `checkstyle` command line on a Java file +//! when it is saved (as a cancellable task; a newer save cancels the running +//! check), publishes the problems as diagnostics, and offers quick fixes for +//! the ones it can fix textually. +//! +//! The configuration is the project's own when it has one (`checkstyle.xml`, +//! `config/checkstyle/checkstyle.xml` as Gradle and Maven projects keep it, +//! or `.checkstyle.xml`), else the Google or Sun checks chosen with the +//! commands. + +use openidea_plugin::host::{self, storage}; +use openidea_plugin::providers::{CodeAction, Document, Providers}; +use openidea_plugin::{ + Diagnostic, Error, Event, Invocation, NotificationLevel, Plugin, Position, Project, Range, Severity, TextEdit, + register_plugin, +}; +use std::collections::BTreeMap; + +/// Built into the Checkstyle jar. +const GOOGLE: &str = "/google_checks.xml"; +const SUN: &str = "/sun_checks.xml"; +/// Project configurations, by preference (project-relative; the check runs in the project root). +const PROJECT_CONFIGS: [&str; 3] = ["checkstyle.xml", "config/checkstyle/checkstyle.xml", ".checkstyle.xml"]; + +/// One reported problem. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Problem { + /// 0-based. + pub line: u32, + /// 0-based; `None` for the whole line. + pub column: Option<u32>, + pub severity: Severity, + pub message: String, + /// The check's simple name (`FileTabCharacter`). + pub check: String, +} + +#[derive(Default)] +pub struct Checkstyle { + /// Last results per file (for quick fixes; rebuilt by the next save). + problems: BTreeMap<String, Vec<Problem>>, +} + +fn unescape(s: &str) -> String { + s.replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace(" ", "\n") + .replace("&", "&") +} + +fn attr(tag: &str, name: &str) -> Option<String> { + let start = tag.find(&format!(" {name}=\""))? + name.len() + 3; + let end = tag[start..].find('"')? + start; + Some(unescape(&tag[start..end])) +} + +/// Problems per file from Checkstyle's `-f xml` report. +pub fn parse_report(xml: &str) -> BTreeMap<String, Vec<Problem>> { + let mut out: BTreeMap<String, Vec<Problem>> = BTreeMap::new(); + let mut file = None; + for tag in xml.split('<').skip(1) { + let tag = format!(" {}", tag.split('>').next().unwrap_or("")); + if tag.starts_with(" file ") { + file = attr(&tag, "name"); + if let Some(f) = &file { + out.entry(f.clone()).or_default(); + } + } else if tag.starts_with(" error ") { + let (Some(f), Some(line)) = (&file, attr(&tag, "line").and_then(|l| l.parse::<u32>().ok())) else { + continue; + }; + let severity = match attr(&tag, "severity").as_deref() { + Some("error") => Severity::Error, + Some("info") => Severity::Info, + Some("ignore") => continue, + _ => Severity::Warning, + }; + let check = attr(&tag, "source").unwrap_or_default(); + // Checks configured with an `id` report it after a `#` (Google checks: `RegexpSinglelineCheck#noTrailingWhitespace`). + let check = check.split('#').next().unwrap_or_default(); + let check = check.rsplit('.').next().unwrap_or(check).trim_end_matches("Check").to_owned(); + out.entry(f.clone()).or_default().push(Problem { + line: line.saturating_sub(1), + column: attr(&tag, "column").and_then(|c| c.parse::<u32>().ok()).map(|c| c.saturating_sub(1)), + severity, + message: attr(&tag, "message").unwrap_or_default(), + check, + }); + } + } + out +} + +fn diagnostic(p: &Problem, text: &str) -> Diagnostic { + let line_len = text.split('\n').nth(p.line as usize).map_or(0, |l| l.encode_utf16().count() as u32); + let start = p.column.unwrap_or(0).min(line_len); + let end = if p.column.is_some() && start < line_len { start + 1 } else { line_len }; + Diagnostic { + range: Range::new(Position::new(p.line, start), Position::new(p.line, end.max(start))), + severity: p.severity, + message: p.message.clone(), + code: Some(p.check.clone()), + } +} + +/// The project containing `path`. +fn project_of(path: &str) -> Option<Project> { + host::projects() + .ok()? + .into_iter() + .filter(|p| p.root().is_ok_and(|r| path.starts_with(&r))) + .max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0)) +} + +/// The configuration for files of `project`. +fn config_for(project: &Project) -> String { + PROJECT_CONFIGS + .iter() + .find(|c| project.read_file(c).is_ok()) + .map(|c| (*c).to_owned()) + .or_else(|| storage::get("config").ok().flatten()) + .unwrap_or_else(|| GOOGLE.into()) +} + +impl Checkstyle { + /// Checks `path` and publishes the result. `Err(Cancelled)` when a newer + /// save (or the user) cancelled it. + fn check(&mut self, path: &str) -> Result<usize, Error> { + let project = project_of(path).ok_or_else(|| Error::NotFound(format!("{path} is not in an open project")))?; + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + let task = host::begin_task(&format!("Checkstyle: {name}"), true); + let config = config_for(&project); + let args = vec!["-f".to_owned(), "xml".into(), "-c".into(), config, path.to_owned()]; + let out = project.run_process("checkstyle", &args, None)?; + if task.is_cancelled() { + return Err(Error::Cancelled); + } + // Exit status is the number of errors; only missing output is a failure. + if !out.stdout.contains("<checkstyle") { + return Err(Error::Failed(format!("checkstyle: {}", out.stderr.lines().next().unwrap_or("no report")))); + } + let report = parse_report(&out.stdout); + let problems = report + .into_iter() + .find(|(f, _)| f == path || path.ends_with(f.as_str())) + .map(|(_, p)| p) + .unwrap_or_default(); + let text = project.read_file(path).unwrap_or_default(); + let diagnostics: Vec<Diagnostic> = problems.iter().map(|p| diagnostic(p, &text)).collect(); + host::publish_diagnostics(path, &diagnostics)?; + let n = problems.len(); + self.problems.insert(path.to_owned(), problems); + Ok(n) + } +} + +impl Providers for Checkstyle { + fn code_actions(&mut self, doc: &Document, selection: Range) -> Vec<CodeAction> { + let Some(problems) = self.problems.get(&doc.path) else { return Vec::new() }; + let lines: Vec<&str> = doc.text.split('\n').collect(); + let mut out = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for p in problems.iter().filter(|p| p.line >= selection.start.line && p.line <= selection.end.line) { + let Some(line) = lines.get(p.line as usize) else { continue }; + let whole = Range::new(Position::new(p.line, 0), Position::new(p.line, line.encode_utf16().count() as u32)); + let (title, edit) = match p.check.as_str() { + "FileTabCharacter" => { + ("Replace tabs with spaces", TextEdit { range: whole, text: line.replace('\t', " ") }) + } + "RegexpSingleline" if p.message.to_lowercase().contains("trailing") => { + ("Remove trailing whitespace", TextEdit { range: whole, text: line.trim_end().to_owned() }) + } + "NewlineAtEndOfFile" => { + let last = lines.len() as u32 - 1; + let end = Position::new(last, lines[last as usize].encode_utf16().count() as u32); + ( + "Add a newline at the end of the file", + TextEdit { range: Range::new(end, end), text: "\n".into() }, + ) + } + _ => continue, + }; + if seen.insert((title, p.line)) { + out.push(CodeAction { + title: format!("Checkstyle: {title}"), + kind: Some("quickfix".into()), + preferred: true, + edits: vec![(doc.path.clone(), vec![edit])], + }); + } + } + out + } +} + +impl Plugin for Checkstyle { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + match id { + "check" => { + let editor = ctx.editor().ok_or("no file is open")?; + let n = self.check(&editor.path())?; + Ok(Some(format!("{n} problems"))) + } + "use-google" | "use-sun" => { + storage::set("config", if id == "use-sun" { SUN } else { GOOGLE })?; + Ok(None) + } + _ => Err(format!("unknown command {id}")), + } + } + + fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) { + let mut saved: Vec<&str> = events + .iter() + .filter_map(|e| match e { + Event::FileSaved(p) if p.ends_with(".java") => Some(p.as_str()), + _ => None, + }) + .collect(); + saved.sort_unstable(); + saved.dedup(); + for path in saved { + match self.check(path) { + Ok(_) => {} + Err(Error::Cancelled) => return, // a newer save takes over + Err(e) => { + host::notify(NotificationLevel::Warning, "Checkstyle", &e.to_string()); + return; + } + } + } + } +} + +register_plugin!(Checkstyle, providers); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::ProcessOutput; + use openidea_plugin::mock::MockHost; + + const REPORT: &str = r#"<?xml version="1.0" encoding="UTF-8"?> +<checkstyle version="10.17.0"> +<file name="/project/src/A.java"> +<error line="2" column="1" severity="warning" message="Line contains a tab character." source="com.puppycrawl.tools.checkstyle.checks.whitespace.FileTabCharacterCheck"/> +<error line="3" severity="error" message="Trailing whitespace is not allowed" source="com.puppycrawl.tools.checkstyle.checks.regexp.RegexpSinglelineCheck#noTrailingWhitespace"/> +</file> +</checkstyle>"#; + + #[test] + fn parses_reports() { + let r = parse_report(REPORT); + let p = &r["/project/src/A.java"]; + assert_eq!(p.len(), 2); + assert_eq!((p[0].line, p[0].column, p[0].check.as_str()), (1, Some(0), "FileTabCharacter")); + assert_eq!( + (p[1].severity, p[1].message.as_str(), p[1].check.as_str()), + (Severity::Error, "Trailing whitespace is not allowed", "RegexpSingleline") + ); + } + + #[test] + fn checks_saved_java_files_and_fixes() { + let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]); + let text = "class A {\n\tint x;\n void f() {} \n}"; + host.add_file("src/A.java", text); + host.on_process("checkstyle", |args, _| { + assert_eq!(&args[..4], ["-f", "xml", "-c", GOOGLE]); + Ok(ProcessOutput { status: Some(1), stdout: REPORT.into(), stderr: String::new() }) + }); + let mut p = Checkstyle::default(); + p.handle_events( + &[Event::FileSaved("/project/src/A.java".into()), Event::FileSaved("/project/notes.txt".into())], + &host.invocation(), + ); + assert_eq!(host.process_runs().len(), 1, "only Java files"); + assert_eq!(host.process_runs()[0].cwd.as_deref(), Some("/project")); + let d = &host.diagnostics()["/project/src/A.java"]; + assert_eq!(d.len(), 2); + assert_eq!(d[1].range, Range::new(Position::new(2, 0), Position::new(2, 16)), "whole line without a column"); + assert!(host.tasks()[0].ended); + + let doc = + Document { path: "/project/src/A.java".into(), language: "java".into(), version: 1, text: text.into() }; + let actions = p.code_actions(&doc, Range::new(Position::new(1, 0), Position::new(2, 0))); + let titles: Vec<_> = actions.iter().map(|a| a.title.as_str()).collect(); + assert_eq!(titles, ["Checkstyle: Replace tabs with spaces", "Checkstyle: Remove trailing whitespace"]); + assert_eq!(actions[1].edits[0].1[0].text, " void f() {}"); + } + + #[test] + fn the_projects_configuration_wins() { + let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]); + host.add_file("src/A.java", "class A {}"); + host.on_process("checkstyle", |_, _| { + Ok(ProcessOutput { status: Some(0), stdout: "<checkstyle/>".into(), stderr: String::new() }) + }); + let mut p = Checkstyle::default(); + p.run_command("use-sun", &host.invocation(), None).unwrap(); + p.handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation()); + host.add_file("config/checkstyle/checkstyle.xml", "<module name=\"Checker\"/>"); + p.handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation()); + let configs: Vec<String> = host.process_runs().iter().map(|r| r.args[3].clone()).collect(); + assert_eq!(configs, [SUN, "config/checkstyle/checkstyle.xml"]); + } + + #[test] + fn a_missing_checkstyle_is_reported_once() { + let host = MockHost::install().with_capabilities(&["project.read", "events", "storage"]); + host.add_file("src/A.java", "class A {}"); + Checkstyle::default().handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation()); + assert!(host.notifications()[0].body.contains("process:checkstyle")); + } +} diff --git a/plugins/official/csv-editor/Cargo.lock b/plugins/official/csv-editor/Cargo.lock new file mode 100644 index 000000000..e0f64ffba --- /dev/null +++ b/plugins/official/csv-editor/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-csv-editor" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/csv-editor/Cargo.toml b/plugins/official/csv-editor/Cargo.toml new file mode 100644 index 000000000..9e381e432 --- /dev/null +++ b/plugins/official/csv-editor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-csv-editor" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..4667b33e6def739b122e9319e7d011ed43d7e1a7 GIT binary patch literal 130639 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 a0Jjz;_q_0}Maf={*A?#nvM8^8i{?Lo8wRfc literal 0 HcmV?d00001 diff --git a/plugins/official/csv-editor/plugin.toml b/plugins/official/csv-editor/plugin.toml new file mode 100644 index 000000000..06d920a50 --- /dev/null +++ b/plugins/official/csv-editor/plugin.toml @@ -0,0 +1,23 @@ +id = "openidea.csv-editor" +name = "CSV Editor" +version = "0.1.0" +description = "Edit CSV and TSV files as a table. Edits go to the file's text, so the text view follows and undo is shared." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: table editor for .csv and .tsv with cell editing, inserting and deleting rows; large files load the visible rows only." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-view:table"] + +[capabilities] +required = ["editor.read", "editor.write"] + +[[contributes.customEditors]] +id = "table" +title = "Table" +patterns = ["*.csv", "*.tsv"] +priority = "default" + +[contributes.settings] +title = "CSV Editor" +schema = "settings.schema.json" diff --git a/plugins/official/csv-editor/settings.schema.json b/plugins/official/csv-editor/settings.schema.json new file mode 100644 index 000000000..9df4a56ad --- /dev/null +++ b/plugins/official/csv-editor/settings.schema.json @@ -0,0 +1,17 @@ +{ + "properties": { + "firstRowIsHeader": { + "type": "boolean", + "title": "First row is the header", + "description": "Show the first record as column names instead of a row.", + "default": true + }, + "delimiter": { + "type": "string", + "title": "Delimiter", + "description": "auto: tab for .tsv files, else the most frequent of , ; | in the first line.", + "enum": ["auto", ",", ";", "|", "tab"], + "default": "auto" + } + } +} diff --git a/plugins/official/csv-editor/src/lib.rs b/plugins/official/csv-editor/src/lib.rs new file mode 100644 index 000000000..046635fd2 --- /dev/null +++ b/plugins/official/csv-editor/src/lib.rs @@ -0,0 +1,319 @@ +//! CSV Editor for OpenIDEA: a custom editor showing `.csv` / `.tsv` files as +//! a table. It never keeps its own copy of the data: every render parses the +//! document, and every edit is a versioned text edit of the document, so the +//! text view of the same file follows at once and Undo is one history. +//! +//! Large files: the IDE asks only for the rows it shows; the parse of the +//! last document version is kept, so scrolling doesn't parse again. + +use openidea_plugin::host; +use openidea_plugin::text::position; +use openidea_plugin::views::{Body, Button, Table, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Error, Invocation, Plugin, Range, TextEdit, register_plugin}; +use std::ops::Range as Span; + +/// One record: the byte spans of its fields and of the whole record (without +/// the line break). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Record { + pub fields: Vec<Span<usize>>, + pub span: Span<usize>, + /// Where the next record starts (after the line break), or the text's end. + pub next: usize, +} + +/// A parsed document. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Csv { + pub delimiter: char, + pub records: Vec<Record>, +} + +/// The field's text: quotes removed, `""` unescaped. +pub fn field_text(text: &str, span: &Span<usize>) -> String { + let raw = &text[span.clone()]; + match raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')) { + Some(inner) if raw.len() >= 2 => inner.replace("\"\"", "\""), + _ => raw.to_owned(), + } +} + +/// `value` as a field: quoted when it contains the delimiter, a quote or a line break. +pub fn quote(value: &str, delimiter: char) -> String { + if value.contains([delimiter, '"', '\n', '\r']) || value.starts_with(' ') || value.ends_with(' ') { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_owned() + } +} + +/// The delimiter: `tab` for .tsv, else the most frequent of `,;|\t` in the first line. +pub fn detect_delimiter(path: &str, text: &str, setting: &str) -> char { + match setting { + "," | ";" | "|" => return setting.chars().next().expect("non-empty"), + "tab" => return '\t', + _ => {} + } + if path.to_ascii_lowercase().ends_with(".tsv") { + return '\t'; + } + let first = text.lines().next().unwrap_or(""); + [',', ';', '\t', '|'] + .into_iter() + .max_by_key(|d| first.matches(*d).count()) + .filter(|d| first.contains(*d)) + .unwrap_or(',') +} + +/// RFC 4180 records (quoted fields may span lines); a trailing line break +/// doesn't start an empty record. +pub fn parse(text: &str, delimiter: char) -> Csv { + let bytes = text.as_bytes(); + let d = delimiter as u8; + let mut records = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let start = i; + let mut fields = Vec::new(); + loop { + let field_start = i; + if bytes.get(i) == Some(&b'"') { + i += 1; + while i < bytes.len() { + if bytes[i] == b'"' { + if bytes.get(i + 1) == Some(&b'"') { + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + } + while i < bytes.len() && bytes[i] != d && bytes[i] != b'\n' && bytes[i] != b'\r' { + i += 1; + } + fields.push(field_start..i); + if i < bytes.len() && bytes[i] == d { + i += 1; + continue; + } + break; + } + let end = i; + if bytes.get(i) == Some(&b'\r') { + i += 1; + } + if bytes.get(i) == Some(&b'\n') { + i += 1; + } + records.push(Record { fields, span: start..end, next: i }); + } + Csv { delimiter, records } +} + +#[derive(Default)] +pub struct CsvEditor { + /// (path, version) of the last parse, and the parse. + cache: Option<((String, u64), Csv)>, +} + +/// Rows the IDE gets when it asks for no viewport. +const DEFAULT_ROWS: u32 = 200; + +fn edit(text: &str, span: Span<usize>, new_text: String) -> TextEdit { + TextEdit { range: Range::new(position(text, span.start), position(text, span.end)), text: new_text } +} + +impl CsvEditor { + fn parsed(&mut self, path: &str, version: u64, text: &str) -> &Csv { + let key = (path.to_owned(), version); + if self.cache.as_ref().is_none_or(|(k, _)| *k != key) { + let setting = host::setting_string("delimiter").unwrap_or_else(|| "auto".into()); + let csv = parse(text, detect_delimiter(path, text, &setting)); + self.cache = Some((key, csv)); + } + &self.cache.as_ref().expect("just set").1 + } + + /// The edits for `event` on `text` (`header`: the first record is not a row). + pub fn edits_for(csv: &Csv, text: &str, header: bool, event: &ViewEvent) -> Result<Vec<TextEdit>, Error> { + let skip = usize::from(header && !csv.records.is_empty()); + let d = csv.delimiter; + let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(1).max(1); + let empty_record = d.to_string().repeat(width - 1); + let record = |row: u32| csv.records.get(row as usize + skip); + let insert = |before: u32| match record(before) { + Some(r) => vec![edit(text, r.span.start..r.span.start, format!("{empty_record}\n"))], + None => { + // Append; the last record may lack its line break. + let end = text.len(); + let sep = if text.is_empty() || text.ends_with('\n') { "" } else { "\n" }; + vec![edit(text, end..end, format!("{sep}{empty_record}\n"))] + } + }; + Ok(match event { + ViewEvent::Cell { row, column, value } => { + let r = record(*row).ok_or_else(|| Error::Invalid(format!("no row {row}")))?; + let column = *column as usize; + match r.fields.get(column) { + Some(span) => vec![edit(text, span.clone(), quote(value, d))], + // A short record: pad it with empty fields. + None => { + let pad = d.to_string().repeat(column + 1 - r.fields.len()); + vec![edit(text, r.span.end..r.span.end, format!("{pad}{}", quote(value, d)))] + } + } + } + ViewEvent::InsertRow(before) => insert(*before), + ViewEvent::Button(b) if b == "add-row" => insert(u32::MAX), + ViewEvent::DeleteRows(rows) => { + let mut rows: Vec<u32> = rows.clone(); + rows.sort_unstable(); + rows.dedup(); + rows.iter() + .filter_map(|row| record(*row)) + .map(|r| edit(text, r.span.start..r.next, String::new())) + .collect() + } + _ => Vec::new(), + }) + } +} + +impl Plugin for CsvEditor { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + Err(format!("unknown command {id}")) + } +} + +impl Views for CsvEditor { + fn render(&mut self, view: &str, viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error> { + if view != "table" { + return Err(Error::NotFound(view.into())); + } + let editor = ctx.editor().ok_or_else(|| Error::Invalid("the table editor needs a document".into()))?; + let doc = editor.document()?; + let path = editor.path(); + let header = host::setting_bool("firstRowIsHeader").unwrap_or(true); + let csv = self.parsed(&path, doc.version, &doc.text).clone(); + let skip = usize::from(header && !csv.records.is_empty()); + let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(0); + let columns: Vec<String> = (0..width) + .map(|c| match (header, csv.records.first().and_then(|r| r.fields.get(c))) { + (true, Some(span)) => field_text(&doc.text, span), + _ => format!("Column {}", c + 1), + }) + .collect(); + let total = (csv.records.len() - skip) as u32; + let range = Viewport::range(viewport, total, DEFAULT_ROWS); + let rows = range + .clone() + .map(|row| { + let r = &csv.records[row as usize + skip]; + (0..width).map(|c| r.fields.get(c).map(|s| field_text(&doc.text, s)).unwrap_or_default()).collect() + }) + .collect(); + let delimiter = match csv.delimiter { + '\t' => "tab".to_owned(), + d => format!("'{d}'"), + }; + Ok(View::new(Body::Table(Table { columns, total_rows: total, first_row: range.start, rows, editable: true })) + .button(Button::new("add-row", "Add Row").icon("plus")) + .status(&format!("{total} rows × {width} columns, delimiter {delimiter}"))) + } + + fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> { + if view != "table" { + return Err(Error::NotFound(view.into())); + } + let editor = ctx.editor().ok_or_else(|| Error::Invalid("the table editor needs a document".into()))?; + let doc = editor.document()?; + let path = editor.path(); + let header = host::setting_bool("firstRowIsHeader").unwrap_or(true); + let csv = self.parsed(&path, doc.version, &doc.text).clone(); + let edits = Self::edits_for(&csv, &doc.text, header, &event)?; + if !edits.is_empty() { + // Stale: the user typed in the text view meanwhile; the table renders again. + editor.apply_edits(doc.version, &edits)?; + } + Ok(()) + } +} + +register_plugin!(CsvEditor, views); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + const DATA: &str = "name,age,city\nAda,36,London\n\"Lovelace, A.\",\"say \"\"hi\"\"\",\"multi\nline\"\nBob,40\n"; + + #[test] + fn parses_quotes_line_breaks_and_short_records() { + let csv = parse(DATA, ','); + assert_eq!(csv.records.len(), 4); + let texts: Vec<String> = csv.records[2].fields.iter().map(|s| field_text(DATA, s)).collect(); + assert_eq!(texts, ["Lovelace, A.", "say \"hi\"", "multi\nline"]); + assert_eq!(csv.records[3].fields.len(), 2); + assert_eq!(parse("a,b\r\nc,d", ',').records.len(), 2); + assert_eq!(detect_delimiter("x.csv", "a;b;c\n", "auto"), ';'); + assert_eq!(detect_delimiter("x.tsv", "a,b\n", "auto"), '\t'); + assert_eq!(quote("a,b", ','), "\"a,b\""); + assert_eq!(quote("plain", ','), "plain"); + } + + #[test] + fn renders_the_visible_rows_with_the_header() { + let host = MockHost::install(); + host.open_editor("/project/people.csv", "csv", DATA, &[]); + let mut p = CsvEditor::default(); + let view = p.render("table", Some(Viewport { first_row: 1, rows: 1 }), &host.invocation()).unwrap(); + let Body::Table(t) = view.body else { panic!("a table") }; + assert_eq!(t.columns, ["name", "age", "city"]); + assert_eq!((t.total_rows, t.first_row), (3, 1)); + assert_eq!(t.rows, [vec!["Lovelace, A.", "say \"hi\"", "multi\nline"]]); + + host.set_setting("firstRowIsHeader", "false"); + let view = p.render("table", None, &host.invocation()).unwrap(); + let Body::Table(t) = view.body else { panic!("a table") }; + assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4)); + } + + #[test] + fn edits_cells_and_rows_through_the_document() { + let host = MockHost::install(); + host.open_editor("/project/people.csv", "csv", DATA, &[]); + let mut p = CsvEditor::default(); + let cell = |row, column, value: &str| ViewEvent::Cell { row, column, value: value.into() }; + p.handle("table", cell(0, 2, "Paris, FR"), &host.invocation()).unwrap(); + assert_eq!(host.editor_text().lines().nth(1), Some("Ada,36,\"Paris, FR\"")); + // A short record is padded. + p.handle("table", cell(2, 2, "Oslo"), &host.invocation()).unwrap(); + assert!(host.editor_text().ends_with("Bob,40,Oslo\n"), "{}", host.editor_text()); + p.handle("table", ViewEvent::InsertRow(0), &host.invocation()).unwrap(); + assert_eq!(host.editor_text().lines().nth(1), Some(",,")); + p.handle("table", ViewEvent::DeleteRows(vec![0, 1]), &host.invocation()).unwrap(); + assert!(host.editor_text().starts_with("name,age,city\n\"Lovelace"), "{}", host.editor_text()); + p.handle("table", ViewEvent::Button("add-row".into()), &host.invocation()).unwrap(); + assert!(host.editor_text().ends_with("Bob,40,Oslo\n,,\n"), "{}", host.editor_text()); + } + + #[test] + fn a_changed_document_is_not_overwritten() { + let host = MockHost::install(); + host.open_editor("/project/a.csv", "csv", "a,b\n1,2\n", &[]); + let mut p = CsvEditor::default(); + let csv = parse("a,b\n1,2\n", ','); + let edits = + CsvEditor::edits_for(&csv, "a,b\n1,2\n", true, &ViewEvent::Cell { row: 0, column: 0, value: "9".into() }); + let editor = host.invocation().editor().unwrap(); + host.type_in_editor("x"); + assert_eq!(editor.apply_edits(0, &edits.unwrap()), Err(Error::Stale)); + // The next event works on the current version. + p.handle("table", ViewEvent::Cell { row: 0, column: 1, value: "8".into() }, &host.invocation()).unwrap(); + assert!(host.editor_text().contains(",8"), "{}", host.editor_text()); + } +} diff --git a/plugins/official/git-toolbox/.gitignore b/plugins/official/git-toolbox/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/git-toolbox/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/git-toolbox/Cargo.lock b/plugins/official/git-toolbox/Cargo.lock new file mode 100644 index 000000000..9ad60b812 --- /dev/null +++ b/plugins/official/git-toolbox/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-git-toolbox" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/git-toolbox/Cargo.toml b/plugins/official/git-toolbox/Cargo.toml new file mode 100644 index 000000000..98e03dddc --- /dev/null +++ b/plugins/official/git-toolbox/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-git-toolbox" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..e14440bf732dda0f574954b2cf5c4cd0671b5f7f GIT binary patch literal 107752 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ÒmF!RBe6n6<f`ygdAibhq#aEC zHYpy4ENf!?>w+-4*(eecu8C2{o7sD4tGJrge?;E@Z$Yjc%ygzQWHAK-#z?s+|IqKm zluP`zluM2+R?0<D692DLE~cb#Bb9O?F13`4Aw6bY6tXs17wWB*b)lOFkagjF4_tnQ z)kD^^J-k<}US`{n@79w@1uXnvq=EpymI_Wcd!SNbE0NDhR5?lot5k=iLOB(rLd6DB zq0)_1u<&L`D)7OT>60f+A^F)vbajS_rMToj@oG+<VL~H%hG__mwg7k-m0xw0Hjsod zMOqlHr1!tp_L2lkQhZ#gA2<XBtXVaFntiEYn_H$9C7)70^|a0~JxBKbMPoPs;%SM9 zCbFps_IJeGE*-_dUYSQJEQ5imi!aF9aRM28Yko_hVII@ov$6P_%?|2F$>5p*fIzAV z1Cyo2KdQ8PGUZdxjmm`ANm?q@PF(MeVFW38Y*L35m;-=dQB@Y0Ep?(b+vSWYY`dJE zv*t*f<cfdte(6~`P@zis{b^)QM$$H{+Z713wE_Gn9M-CZX!!}A{mo$6QSaUEpHzWh zD3{c$ecv?K(b;J9gEokp<~rImr%1GO;vI8HGGAe>5o|LvG^t>h<{>~$m^$Mku_e1G zR~l#)29QP-+=OR3oXTfxnjLe*dt|ec-kQs?ce}%u+VlXuD$ks0M4A^f$4O{|1I&N& zRDg0!ioigcBCs^ZG>p1_@A-;qljdxW^u8QYyuy0P_dw|z=HzcAma(DJWb=*UF;D6x z#hc|1*3OJJnq{-L&|taoeAp3TPp*35Q|!Z}*2d4rHelqps0FnT#6DeK-Tx|9Cz(vi zpTRSnP5v9(iOlmeRjthU{fNZS&2zNL0z{9DYg+u@ux*+2ZVv;$VXzPjZpsB#@e;w( z*Xl6cnuU-&XrZq^pihZSduRfxN`46Z=<F#tvRUf#Qp1ZVPen4&=lRB5+GL!-#E7v( z?bBW&MDTx*sKOEYbHoocR{O6kB>LX9g;-kZ>8zpvdF$qj8`P^@;${~7_Rk$(PQ)eV zlNT>mfnM)l(l9Zk5)zkRcNwPxZ8Ev_Disy|edMeOgv&y8+ZWVeocEA;qu&D0b6+~w zSGeom3A|ug9R=`WMt~qOATA{jgFulL<hl=4BOi~=bJ%-2?~5PxtpU0(S$ii8ip8-c zYKls6@u5u&lQJ2~>G&Ha;HfmGO3F%)d2)sG=s51Z`hF(QdFv-bGXl~9(Rg#P|2z*o z7bkTGE~L|N&rOMun6cc4;pbXIWpuM0%~DMX*sJxVW${3z6|Fv!6w6MZ3>wXgo+!WN zq`IS~(RAwA2JEzWr^<3_Y=}IuLy}5J2Izs;wp0UP<zU!iqUuhWE0ri!{Wet>|0ht} zjsmRbw_>mFF+!5TZRUmiiLK2keo@tZkD_>1Oo2F<LXK>b*@FJQAI*+gzghEk0GCy_ zTg&cYK{X@@tksC}cQ$#SQJQoa`kwcg$OJP{F(=7~@c2}tuO}5h)vLf`yHrtjf*OWx z*$D{?)%ATxYX+|uWo>jE?HB4$9<)iD*of*Kat+iLjMj}YW^c+CEHN)14}tkX7I`$% zD$1a5rLdCIz*`h(bb_Iu4Zx222ZDGMVTj0Q2AwX9MIvgUu5DUIVjLHYg}IqpYjFBR z<dBg;163T3^7Re7)TMa`hhw2a@5L>SvKjj5?2cxgi1Q^2$xJEnXDc815Q~^*)LXMi zc+;ge^NE_s%&i!Gyy)lPMeoc?J$s6FZzx{Z2}$5z?Xi5#+PhKhsp|G*t7a9l>NpBR zr<4FUp5W72U=*v+`_Efxs2oeDUfKj-<(qM0cBP)%eB;sk<eL+qM3aB01~pi`PHjbI zS}$&w^)6nnr|vwcq5=gQT3-Buz0p~fG_D1Y86vNcy;E1>2{SAjD<$;dCL1KG;5DjB zxu3RQWMiVQ1f@wyoONR_FOs0oN+`Q+xl~LzrLqZBcNBJwie_o?I<;g2WFE6!yjr*L z=Fm$JxCZ4o0x?$>Tt`B^%-VPq<M!qSwYMpoW<<T2$yK(@n;sz5Etr`X!#ZQ%WsN&m zGu5n)jSe8p31ic#C+$Lz?wF4rWZOPU?sST-GqB^)PAA4-;#KaY*!A4CJ>z3BvRR9` z?$6%4$!UskYo*|K6uV3rSjDx$Lx^Q;dZn<fGk$6NTu8N}LpLN5s1;p%L0kFh1(h%^ zZjcffx;F*wMQzbcPiJYY?UEO^Zlwf1`!aywtj&p?rLjFp3iH6?q4nlT^?qQay*gqS zii9UQ+;Gb41eNquY<b`r^)63GSLtVw9`IQ4dR3E%>z~M)#Dh;2J8R9F5^)(U>yvtT z^-oMEw5F8G|Ctaie-`K79#u4J_DHf)x8D1S5x57vnlH;AX>rzo-{c^ub14Fm4U5cO zR}0~|LXObWVx^|>u;M8F1}PweCf^X7B#N{qrFlZBm@+O+t(zQR2{th5zEM?mG})W# zi(~c0o9&_+8C8g*wN=5tpaLabS@ZnTp)q3vw`8Bi<|pKjxBMTs@YV;`0t5U-8;%Y7 zm+bPttPNLjvp4Auyp1j%g=CwrP!dC2OuN4+ebal_&`K$s@x<}ud`7Bkee@_H_K*uC z)kbsDwzW@j-25l0TQI9r@3I1Y7);QMYfQDtmZmd9ji#fGqUKU<nxX*(lP7B%zd^!u zf_7H}%c@Xs_Y^QoJb*bi%;^xQQbxgO2Vp?4oq6eugnGg=3&BlEv~6*}{piD}NJH^6 zhIX$WKlQ+zx;><0-%fNf6!sF*vJvr$N2(*{drF;HD3BDdSIxdw5=e*stnRc30f4oE zZap+Nql#3?N(QvAlK1j2iGzDD4oy!i6q1+k|4gx?o2Sw=A&v0{Z<gR{1uVr){zmLT z2N=J!xPAx*S>#i}z`3OWSzRAklLts=Bo%zTOnP{EximO7_RM=*rK)RSBc|#Nr;2Hc zHwp;*It)ZAUZJ}xpc`2~Wh&rJ{+^%-_pNwk^Bx{lKvNv^_kaefhu$}S-jhTP)1?Ec zLN~o$v2F1pYY$cuXw~-@zoL6mEr~3#bY+55H;)o$bEO#s1vk7R%fdMfvTk;zBtz-3 zPxS3f&lc}jbG$<5l2)LZ>ieI@$G)r*#IK_zoQt$)G8J9R^H~%L%o$LrX|-u<!ukY) zw4%TYgy5J@i+}i`i<r5vmT)6k!is|LeO+3k#y?fGuiv0$@dT&nq#CzcBso(m7*gtu zGHTyrqr_vUd1q((>SxrXbmj&OvtpRQta(-YKm$;v54a?_Idz+~h<m41Ox5y>k6Jha z)ceY8p;~^?i;rHxcL(&N*eI>I>h7OTvsjkw$l{u1;azKsru#DgYZQVJAPw?N6b}_L z!+^#N(}JTKT`H9I{wXEX7X_1-i-u#$P(H4Xr@ZhEvYnFb=}4e~c*^iCVv6h{Za-}> zU8bJrWhClbwZs|lr#@=o6+rj{s=#+Eyn=Z0f%G7}1CYf%jxb~mAoi8%D&&h%J;CVo zisgmi6I6-_*6tR=gxD`)!#JEV4q@KMZ;PN);fl9Lpe%kqsQ6Wh+#?b29YzW?bshWn zL(G*J|68J&+Vv9O)E&@x2JpMoN^j1Zh~eQ~7K<}=D1U0?Ssz)%3f70mq}BCrK2FvL zK0J1-E-FzwsXm@uu#uR3{_#P}sW~yIc(?U(iD~u}#1-$+gMPiU1Vpm_r@Au%{lFY3 z;!-aDAqc8>k_9c{5lSdJ5K|vzQ_59f()(mUA%Id^Q<u(-x`MBaEt7qgh*7R8vZ~nE zm|Zfx`XOwz`l0A8-ohdWzPlz%SN#;~DidpqlbkW+T-uiK6G)yFH#H}|pJvETbkr3B z7f{c=mj)*^BIF65I2frk=}>clR0||ZicxvAYP*+R1o?t1%1Z-GEA+m8R17$+V<Co) zqncI6Y87c0quPZvv#<9<iF%$)et?i^Dbj&SP-Eum$TK`CS{0YgpgVkBoM)meRfW4i z7phlpLNPz`e;bF~v_14z2S6g=+W#VVeyQPDaFfo&r+0i&JZZ|V=Hq@|`BPF!I3erH z*6a~|6rnY{ynGbvew=ub8NSLJRFOOQq&Ns-o$sN;)je<jgSWR(uo&9jW{ZEOTbnHi z(X!M~KLTxO{diyX*#zE>uX2Ap-5vp)bO;9m?bC_Qcd9#bOUL7H!7Y#9qg#G&(=Xj` zQF4<53aaI+bmq`VYn&6UMXjk$#kx(lNp2?fs#~~G0MF%ncpES8w1T(S@7M|AMLurN zj}ID%<?rIT70K~GZ4H-?3Cs43`RZ&cJ2ar$v8ZHE#5(B)x#KOz1|@VJVYTjXzm06j zy=HJcVR3JDo-Mzcz@Z=vTaYg)4uEsMuA?3r8haO_vDRHD)fw~Eec53ydfJ@Mn(uyo z8Q3sOqy|PY#RSyhGGC~|B8;ll&U8u&OAv2OcWC)NN&Z2S(mP`f+f`+9`C&#Xi8H#e z;?S~>TJX#CiqczjF0ge)t7<R~WMT@D8i7OIblHf{_>|CI1J07OlEDPCWfkPxw{ZC4 z{+IwZQUfr(3t;}RL|{#T$1XqI*O71nxvf(na4f_E6B<DcD{$*`>g-2$VheOiIRSu9 zx$zOm8CbWv;_y63Lvt`jj{Y_}vF469*=0-pG1S6H9VOnujQlD)xn7B2eN=+w^vd7T zjDQ0Q-LbOUe0X1_Khh3;n+V$us4iF?d8=@QZ_$L2hvoRi6=%(FO>ay4IvNkq87!V| zdm5}~@Q1!HR`grg2QJp@;8UDf&>D+^XzicK&+oT4QmrK%ZbaX5^0|_5iVZeM?|LA{ zYxAy+jXXQ;+%hp4xmkVKaWM&NpBx=D!AEelYtj-@V?A{E*fd0g_9`Q2s04&M)X(p! zfVM8t*P`%*e+VV1e&YzDVnP91A3}wXAR}TZL0h$|M^9@n0MbRqPL;Civ0~-Rp3hQD z_Dvuq81#MBQ&{avFNQSHc{Qtc2M9K+WZz}g`4Slc)g$1EaM?coKKf}Jm!{SASdH}> z!^{COcTskXDlDe3RzxAT53rR1djt-J0&RYQldDHvR^^AGY0>XJjZ+KC&PIB}h|zw@ z_5;ln^%MrP0b#*GLX^6VD3#-fMdnf5l&ywQ?CgCQNpu)u1i(CBOrvLpexr*r#G1rP zoIA#JVQ_2K7yhuRr}|8RT6eL@V4sG}4u%cWf|QF1bkLZXhii1u2=iz(UMe!rm>9z( zo6?ba#>5=<DLGB6MdpELMwrL$BlEB^M$9u#<15&N<0nde+Hb|<TGV-ozF;jp(3-MC zCR}51t3}L`bzZnWl>Xsyqa0h(wuV>J@^FNfg*US=jw6cD3VS`G#G8(V&sTvu(T6wn zNj-~0C7!?*_3jAxj?xX1WvLm8M~Kov+fD0@BQa2a^&=9UjL4g&qle~PumP}fE<hG( z2ELai=c@&og}IL=^ZhQl{t86y>-P?`$VeYr9Zr9(fsETT3*4Jw)ckf^#^^Pvs;%67 zH>k{Y2+U|>BgopG+(Eka@Fd_E=gHw7DNr3J)WQz|T&$F=alOsd?A|<Eb}SV@(pO@@ zYJ=O&|F`cV1B<vmHdrH;gZdQbvn1PsNE88X0gJK?1&8^lb3Fydm(N9V<h=S<%{N@Y zW$k*P=rb9l5n#8bAjXs~Rb}58J`+A=`+`aKN%bOVEC9z_o*;e1yL}}%Z`yA)g%EWH z>tlQx8XKj>NCZ%ISE4CcAkp{Oc-Ad@`&JAQxz=#n`|y7pc9BCzEqB#Q+ad8aHw=$4 z$Cp&<?en*fWM8kIs-ve*YF5m=_7+Wuf0*1VK{8waN3EkfB%&&o_zs9ORwFO)kr*S+ zG~5=iv`d0v5Igt}z5TH6ph;h2J$v35=tVftNfjm5JRaOtFpTKlrZ^nBY{iphkR$Yv zXun7EbN+ASoq|x2YUE+fZPK*jH77uSY@Upho*0*5D6Gkmn$nJz%kmM7;zTFr7V!}T zsmCF;_;H*PeM1}XoI#s+4l3;XE9knKWhuBt4msOZMtWtlwNwLsb8Zf~^lT~CkVB4p zqf0sD@lp<XUdbW12iOoVB8Qv>sXUJj#PV{;?MYh>xj6!C+<-Q6tZ@(}8Rr?F@55;{ zE$iUH(e)rHQjfwsbaT7peL)+hFH1suJbPQpf)I+avAm}6F;!JpksmuJ<+sx(J164Q ze7{@wfLAjiUKkh8;2cKRc#LOqk`r8l5dhHOmTniebznUUFbseo$*8y8IrI<}fJQNH zqy%n+X7r8ojyNrwaqa=X#@aRCC_kC&jF~Qqr#W5LyQ*uOrZaYi`-BFtq1M1Qx1RQt zWsu1qp#@)J7cFpg_kkP70`9O6POb1&heSx6uPTcgKx;8iC^J@V22YW0<26=uzC${K zHr#Rdnhkq&vn{<V(<k8U@;I0BtZNR2YmBqUuz6=uuwn8sj)9Xthu<hG{$ylOiJ{6* zw@)<Nj?28dia5x&UqXBfFc?Loui1(Y$pe|H5#@k;SA9Ep*noP5r0|E51v<Q7?Vtfq z$byF49UX}|UIJ)`LnEVhIT(VW__t;sZA-W4Jvy|V>an!F!l7NLp0cQ!O7j9Xv9)^W zKtQLsNF|R}f;){KH(>Fya*2`xvYur<1jXnWK~v9|9HPR>a(hsyc))lgc))zRR~|4p zW#Dpx%Q59;%?a+99>cH2b(Gt!QYdJg+<s3QY)n+>!57x6xP}h2dT<N`;Ok9;6mv_| zIBe)hs*j5B!FI6@d9}f-2myQw)0GI^D4%GB2PTgcPh_R;R>X;h_BfIEjkATTW{y?M zfTR7|ztUG5uuB~FJje;jsW+~K(@cz_RzNbCXZ|(03RFoY`aO(+(_n<7<R))|f1s2C zyW3J_$J-*BJZIe8Re_+#@cnVuYSyHXTaKwFF$T6kG_$Mq{8a?NpfCbjqEh?Wv@9YN z43HWd5RN9Ivlr{@7uG>uty63TRY~R(&Uu1>h;GyeR=*6~RCd%6+F|9TL6Or3D72@0 zm24r!jcm27TLNrStzuV`EgZQOtV$J=t(J2QG`0DA7)P>YBviH_F0WE}4v2(|G%pyU zgyq#^xs!yoJY>}JUXw8UoD{X0h+QpV!~(T^$w?Ro&K5iNC067jSBVu)tBDnFK{!xR zM8FADA&x0Tgp95cGQwI383BrftZh2KZR^hz_~%mlEr}V#lbBHso{^Zb5U7cY5HmqR z7lRHbV!!H8=?mf$_3r=!jeG{dW+@Cq9Dby<ENpBROdhn+W(6e>*iC4b2gSx@c7~Xk z#b)He#KbI3j7)4-Lrg5=kj_XuSg^Q?iFpwt%M&xPM0JRXX=61Lg8;c7G5!)hA`|nh zVdB*}B^}1l<dk5>^?u?|)nt^230AT&@qw}Ms+^KBE!j^GAB`YH*4&Lce;#3y54P-+ zGSniLQu@sTj8^Zjx_mW|S64(E&7#6IjwtXjnBkV1WSRmYaC^}lA^4>_NeyvEl$Tb* zi(hPC8?0pxp2?j{zq8EBtik$K&A`nzwXG=^n9x$HikZ`>M7UzA240R0%8F^~!nf)( z0{fUenqC<!_(Sufs28rHTTMi?3UV!OM3>UeWu6kn`(anK(9N!nS}2v-Ai?BKDXn2{ zdawc%XHrQjMFl9ryY;?^A+Jbm=rc5WW@{Lv#7Jy(dRWX3_AxNAr!rb(NhPtpz1KYz z%wO@u1{#aBJYQ$8MQ;?L>R}U}sYDO_xdmZab3_^QmSH8)=mP(z?2O1CCu4A&pyZGJ zRZ;%z3i(6o84AfKm%n;U5>+t3QV7++hzt;5FIcS*UtsF-LXbQ~6!flTAexMJCk!PN zbrn}mbFis{0ah7_z~kBhi^P_ZU?PYYa=`S}&XMr~Lt+w^x&y}GqXT9VW-M&XHZU2` z4I^RM(8fq47ztzxBayn1fH~brKpGg-rUzCV36gzjV<rNlLPO_@BT9YmQntdFe+9@y zd1fn^z>$2BXL2ndh{aUsX>0`$U@JI_UA6**%T{#ER&>M(=8{>6kjhpF5NrkI@aJSJ zjEWsGnmAKkNS_Wmlpw2Lbtu7fY1ftrhsJG%)lg9|fUVF9&YsPSebDB`uAbu18xa%c z1#C`NfgH>>ifAc<+t<>2r<eJ?z$m1EGlG~vUm6uEiE4w~2pKzoFqTSZ{9ifB<j9nS zmCB|QFq6hh;#Z9XHm}!UyK-Y}@P+gN&P~(@Qlb)n%&+Rj_Nc6(PVmV5i8#GONjCFp zkgW4^YpL26wP!p-^ji}BmNFhL`k>4z*>LsVMPG>~dE8QWxR+RIl}Fiejx!5$L5&Md zX4ZX_)v2UCB~gZ$uXG?0HGzyH>Y7A}388HT(gg4(ygO+Un8P)RMgFP|QBJ5yS|gf7 zGZCeoqQbtos7dT<NRuc$7XT4O(j=hM&NDSlGQg+3SnoqX#e_0b6mMw3J}bvo^8oD; z^1wb^jAq3l;~EygSbc@<3N`ZRNaP_kHG*O^jU!n<>um~oWmkA0Yj<YfT8tKMlu~pS zH6S!|xs|$Pkj}o*UuImkG!(aOjUFNc%U5=T1_O_1H?2IUNi%3_NF2@mN{;r7mfl2; z&Ki#9nQ^qT&JT^FQI_)aorRlGYy8n=;481A)@~HN0_)#`N6p$HQ4he{)q`HeqXP`B z;?dfkHREE0tGGe}+^tt3tTuH-F)K_RLdildY{!XA9aYvgb+EcL7C+JtroU*#x_@w% z&MNa!=rt^z7G<M4Ea5<a<^SXoC?ifiW~rsx&0wssck;*y1;_+&P_1v0T+_tiQxi~2 zjhYvsU<d=JjkFjz^rss*&C<xg8R1$|r{Y!(9s}+@l)3rHq!@xPKLJbyLxv|Ff*IJ5 zT@Dm)j_Gt0i6Ig)L7y)tSfL)I(n?JLupHmMz*P2n`%^7O7eci7MEq?={MIt?(ZlR! zv&jkTwD|fcua8JJwW*Vha5@52QeW6A6WhoK@n0~%J%I&men9~lk6=za`90~qX^fR9 z-cuR8p7B_yW@vpVd7-5^EIkNXdT@x3sB7f`ps0DFPMn5g9Vv;-8uij@*-lRb*`&^8 z_V+~u)ag93gG<k}-9$8{GwBGh>{KGk0)SNkIoNQ9$xx-l7pm0w7At}C4=A28V4_rR zjAt8pTpX8AVXN@;LXP>Wl<F!)j}G+^&j4Q0<4WQg0MK^>^wrq3xI;-gILjO|U%a(4 zjk_wMk&?HtQn)g@TVpnAbnb~p?R^Mg&Wb`7EFaaSct*ODIqlW}VU!Kxxrin)7yqwk z_Ue2q-P@ue*8p0^$aJ@)Q30#^8*}3Dvu~PIw+xR1-fdzj{6-U!Ri?-Km+z!?6HP2t zyNM=Kf}PqnOAOIyO2)_AbD{I%l#6<UceaXwQZhg0h4PFg4)P0GGs81ERNZOF3qPrh z@IWP6HiygZZB0b4oN$K<$6m-8WaCCjDTp?a-$sooLFgWkEk+WMG`rFRil}GkBOZ_? z<P>z0hNEIk$Z>@Sr0u10V`4(C9utjAzi8PM<#2Qc2(TB5@a+ZY8~1cvWQMT;GcY$g z%nWh4@ES4}Xv1a9CREJ{ss)XA13?q9qVh?lx}-{~7gSfs4(lU)*<n^Lz(dBd0+E#e z%5|GbVX;6-07mH2yJ^yr5gg?a!*#MY{>f1Al|{#&XDA?*F*bDFsmX^?%K^Q`NR*zi z6|>P%LQZ&OZqf=r9}k+A;nfj0iNfyZQ!lnBugH=vU>R$G@8mEvUXd+)QC7^;QTYB; zUf~tF=M-Hj8=d+2W|&s^`FL+aLha|v!*VCdtoG=r#Ry6r67@<eA<ZLsBKPx&9U4D> zMRkUI0w0PILxYuEZb(zcU=2UtGInIi8$X{bxI7*brW|vVv9s{V&?!Hka&S4I)Ly3W zEI;2~xE3&!8)h2Eew3bTIrfl23fzFkP9qZ1;SIL90{^QWdzjm>I&kb)cD0scZ=JCp zQf;Ddtg|7~s9X0uYR6t2t;k}8U(*>{;Ne=1{n{KmN^tF0sU3S75(`^vqbwX9d)`Ep zTL#Klm}k+kw=C39sQN%z*s^D}W8aK=fOcY8R4+`~q_IZPD}etkn9>}3n?+%DQe;ZI z8e+<r#;OMmL)5{iGvxq7_M&0R0W}BX;%c>HFZqm4*BUu2CBEt$7BdH_B@W%OS2l8~ zuN26LXM$8n(g8H?F@RNM#)4RV0`Q%6HDKd(>mJ8m^AaRO<2*+og~ftkQdpU%u(CRZ z1zBkO1oIsDB5394*ypq;mju;MB+fU(<wOv60Ng0VcpUCA3AHPxJCFs!Xfjv;Kn5%G z3>LUXHOt`~aqw;9aOMua0M>HN=-?Xx&A}Jf8W4<Q(4TN2%t-bmToGLc?$Bfh6>6;Y zdRN#|b)yPSgiLsJz76x$Cc0PZc@jHYr7Y5wH<ItI2;TyzNh=U;A1dErgOqz+@HMKm z_^?d|%Dush;0p`9MoK`NOGVbZHLH&t#fr$1*nu)y=q?mvJ25p?>H94Di6esNxYSxN zLO(Mk_g8!Pyi$83UVfh=A=uaDF@>$c2O1rHTpMIME#2MsoN1<*K-7WkT6s$BLlJ_G zWZz-=_?a%H!k9?7V58>$aGkS#mO7J<zyNH%8_Y}2Day3Ht^4D#Y=FF4cVkq9Br#u_ zwWr2Is4?S>rVy?849!p>p19j0Y^X_~@(_uUv7<_qV7ZSVfpeRt)E{bFK6xO^7`-Ao zP#^-VwO0h$WAosX94GV4WwYNe`M=xy67V>#<IMNEdv0Jb0738u8z4oIAaUQ+VGc>r zVkAqlb=Zy_lS5!Y4hYPEGXoy6EK_zQ@8?xwIdSf_L*>iL@y2J?&N{o!2ENbFmiKTD z+li#uyUylVIoWu<@uB_ydfh!eg8@iO@|SPF4W-6(zkX*`y{dXu^<EpDhpnO+|6HWh zZ)oJGZMCy^BhKiuLif-XH8mb#j(MPh6NwPk1TrR^u?~~UKv5dA3|+S5s3D$x7Ux~& z(<CU=1;K+pl2Ani;ZN;^Bc}9dj1bzt3>?}5#_oHD@ErnFU`(?Z-e*{&N@YH0ejn9% z%LdGWFd7>|a9#$<HPF)W)>HOqfcH*ol8M4{7#s>g8wm~*@=ov<)@9xTn3x3KfkM1P z{h)JMa}x>VN?y-Z3S-pRg7fbYl7W7HQ`e5ZsXyR{-69ZyGfsPPYRFkzzz(4bcmd-E zf*y|PQU#2W9en_1kqvwg-@oOi9RmhajCLAWalM0ZIF&W52xO7|*JQw#<q;4yr&obT zKsuuDgud`6j4VI`rXHK+p8#<HX7DY6>qtWqgo0WB@87#}8FOzJz;@-Uy!Ai$@E_{G z=@XC|yYByoM#WyrUz{6A)s_2(gogp_P~IhkHD(`zHyL^g4BvpHvm&Ra@D58s?BNqy zgMW39MQOICQ<Qt$cy1IQsKmxEVi^^w!lnVM#yPAEV{n%2I1lG3+J=tL8%u8J#J&Yi zP~$FRakRpUk&cRKr-@20!RXLSgj<07XO4rX;EDH`AD|k1K^|NlODo`mfw&x=p*tkN z<_`3HowJWoc^Ei|n%bTmXCdr`WagV1-!GtbHQ4r7I2TaD!+?|hyurX%kQ)aSq2AA! z%ue=?4MM!nL7<zpj{zaGP!=^qd!NO#9Z)=&hG#-DhXiYRGlvKQA^Inv6L0?c=M+0) z`oXmGH<`&a7v<9s8YewWJRHWS%#_{&BJ{-cWA776ikKtHQ@>{6n^5FAn0bPB?Ed0e ze*m6$su&U&u9meVAq!RoR!5W7HfVLA4qF%(7{B4MWXGRmtD@t>f`YiLpkmsAw0{;Q z=#J-D(#_(vK>>eIL9T)h<s3_HzQs$NVTmXqznm%Hona{@u+Vj*reMsRVJQXW9km9$ zW0qKOekGd-PU;hRgl9~6QHRiFkVsage$w0hhYku`20tQ}Y;UB1zZt0kpX_Gy9HwHM zBga|m0~+tA(Dx)n==d&>FJZ+7$*Qn-2FK(C)Qs^2gkY9<+?>kUAapO-5Zb`uR32?e zO*mSIWQQGLfId3?1EPnN$>1vy6%Mp?WF=P+)RP;>sJWBJp)25$=C$E_^2$PCD~mOU zW;wJAXO$$uzaU9G@CcHFxg>H0xyTW-bcNxsmx~)#T-3!OAQ_Q*H0xpn{VLk)Y(rk> z7EEf;0fz6>vtIz6M!!5U7)%4KJ;`syu?aI0v^=srpo@yG%zUmA@mkhzupnhVD-Z!p za14xhFcRhvxrKjlS@?{_MmCZ9BTEdu9zIvFF+FZXzHzA|#!)mr{D{ga%aXY3e`*Gs zQlD4>O9yW!pM||#(@o0mQR-j*y1ew#G8ivFM)w7Vxg9?k$7a;^b}~8nN>ChU^zbV| zdyy*N>INPCG2hPfH7tJo0tPcop){U&JH(2ze-h+Eo{Z#iJ+|?NpOId~0T3Me<A34e zDh;q!uK{#s{RTe&#wHDH#FWEC#c5i)48rxA{qI%;Te%+)+j?jcSqA3WPN?cQ<Oo8_ z#Csk7R_q>7HItD7A!m;EhPDdr)*s#{*g9?2+@*8)(J!jYy%GLA@Va+GpmDEg_8Y3$ zix^7m5?1))0eY5w00E60B3><=HJNI!sXL=doRRwOr=9yeZ9Q^0@d_Z%J6!w$Xe0GK za3k;RWz9i@2v2WPw|Wo6qoIACQ5CsM!Ze`UgJR)=SY0W*-LDLb13dAc---DwX2IFa zw>SgPT?+{IXxHW`j@oc{Dy5mfVV>0ga@PKw4VpJ@d@vH&W6_F)K>~@iYw7b8Yb~MA zkxF!#BnBFYMGZtEZ#X_uyU{~HJ=6~p5KrVB2?z-K#lg2Z+cDoM$eqJiK*IRU4<$T5 z2P5WOq|4g1{UOpfE=qQ^gVv*fB6>&KWbM#tLcNn&1}BCEBcS9a%#j<6j%H75P$8J7 zu>{gtLU<>mM$run2xsYv^Cx=+tw(#3s0X~<IC8RRf@+}BJxOXh2{UZ5)8i4A6XQ36 zc^WmlagZN2lX#gVl*BS-a&Tj*i5U`p1_^a<QbUYmfkGvHu-%j)IY4YsJ5oJfy57q~ z-An9zMEvv(I*p&y4VrEe1f?=sH-rjQel-kG$B^xD20~DW4b_Oc2kozR45}MRy|}ib zs>w?<fhKuw8L$s?NLJsV$>G81#SZ8l-$GMX=EG2>A>jYAj#Z;7$51=Li9Q3l)blle z^sa|VDrA~ac997076vK$ql}d!hpLKJ$U--JPlmVpUul2W<rOd%84DC6pKk`Wpr@#% z))2^Q{rcYdhUWDF4#dEZT;G$|Qr}fa*|e4~Mrz-W!#d3l1KNPQ`CnJ3_eQn%&#|?R z_z&UVI3HqMjAy^bU?<+3oX`F_LjGHD>mF}y#m|GVR`i(L%PaTKXD^R<50{V5V*?R3 z2{M;^Onxq3J`_rP2&b;2(#)Tc>$SFmIH0APU1DT%h<Vc4cVkr;Z^51kh6!sZiP^7x z7&NF_Fh^=hUy~#hs3m<%lCXQLmh=rt;(5A3T~8|3#c+=7WyVFpWD>mlqP(L47?bna z7}jT(vB$|`Zmd70&vN^u+=5;+Pw@_nk4&jn&nH+<3feuHwS4eXXTD}~0;ZsP@YU>t z8m}*F!-ueh;&&YJbAyHhz>}cA{c#dzFB-~ro`<YK9>joyBw;-`&;22i)aoV0Ye_U? zGFMA2iOL$2GPR^6+Ka-X4n^QJ?QbyCM6%!GA{~4RNd5=8l{^TPf(>ULWWl#o>U4Ax zcXsv%NNMxx_Is3OjzOKiLB+F48YnPi9)}N3fWz!jC|IrGj3vB-8uC?wC-t$29g!pE ztwXXR6N!3>79}vC+qj(;My2`e{qV5k3u6GTZ3~Yf(W4rh0O^gc^g{FHS(q>#oHh1E zj2(!yO(yK?l7j+i7GtP@oTtNhFz9e-ih%==HL1gw@$XSLEI9qi<>BXZpCo=@Vnee4 zkR0>``m?#tfcG0-`NIlg1oS6K;ukhaAfv`4P!Qr;<`1B%Kg0oFHc1LHnF&UGVTjS} z6*QAp_+UlwPeYAQWKW*UUcv4>DV50nhE!lt9L2Ib=S2r@;fe*P7M#l#DdZE`L$`u% zVXc-s^JRnj=A^+#(O^U?rKr2duuPZAz{(-7Ocm&7BKr(1Db%!w;G{Q|z6E`d9>C!3 zfdexH;E=@HP;pX-f%J-b$vgm#1dYR}rvs?4zb5CC%&AjePE$Jr*}|2YPi8#IN=bjH z5(0&57~06_v=_6CeD{=?Fp(>uGSuUd2{8dO0(GK0z$hb}NEf6<z>*w_vY}=qpczD^ z79dr#+PGTL+INM$DKN%1Mp|OtU!D@TX!glxUp$|E61)|2Aba_Tcz8xWN`Z%flgcZf zg>oio+PX7e=WM}&6{)ZDBTre?qdkiJ!l#8}aJh$%W}@Eje>2K6Fff8v(l&<z_XFrY zo2ue<3V}qhfYOYI4SSGvxT(GOUjY|uy#nypdIgX*eN}4!nx1`(eL4<lj@*31SOJyY zc)rKVeket!i^aw0*lnE7P?-4!Q&j9u5v&H78N3?b3Dcx@j4=E9%DmVL$Ro(1aJmiG z-e460V}1MsGI1?VT!IB35~caVBAV&%0&a1CE_GSMnndvW6CX`5aJkpY&d;Z}Dck?& z_&44!C#A|>?mtF!WiLOecmu-%9;g$m>_hq(?yu-$We=;n<lsp2UigWHko@zj5d2%8 z4I!AWjo*1YtVV+1czJyUJLTs1Aj;Q%wH6jpYOPn-jNoS@0fKkFun7L@N3IRQk2lPp z-~L<(yig6Ym%nd&;Kd|ZcmAvejTZt?y8<{RUsyQ(>BpWMoc=frrwHKm2aow=_)iI^ z{Q;a3FD#sXaE)-93mdH(7gf~CWTj(=7AN!}ndJ?aS9~%{AAhm)-OGX@>P$6j%@Ypd z5@|(%JzI<k(2Vee&kurG(PB^2AEPd@?giU}g#}JlVWn8>Lg0vm!`>!Y7W=#nj2K^} zPG;rk7u@rlFs_23(Ikx41v3hcBY_Ze6JV{Vo<U>Wy$v>+O&-J7T@j>3wvZwZ#Y2(~ z&6eAsmRMYcH)O{eH=LbqK`&yN9)y@7z{x&jj!nbvY}xgydRwz^e%o(9p(<zHRw!;f z%rIqix!zRpCRO$hM-0wLq}YXOr`#6U)5XJ^^mfOgNgK^r9ELK(K-XP#mDW+@;%FF_ z1MZEyNJERXav6gPhDe2>1_R_I)ZI7&|KjlXLcIbck%bb4fqE8H18h^nYYPJultchM z3M_vs(!-x8#<L)WdczLaW6<Ev_Oud|=rSItNnmvJAR8RO%zy-Z8Ijs=ipqMD=3eB* zGNHac%23*1pq@rk->EmAN6f;KZ`ly6%!Eq6f}NSqkh;C`ewEB#rfG_~jQ+uq;~nVb zKgiMrHy<`Put(rbfNfdWd2lulbHN23Hd}$Lq$p^v>07S>c?qaz|3K=^HqVTigq1rR zsF|nWQZo)M41m4y!$N3BVxME#KQvXMY?Iyzq_8f+LZ%I8mpzEJfy#bM+*GtGW4of~ zi;_EhR7kcOL0_hZD4TyI91(N)FQ~i$oPH(#Nc<P*A1vvd?+qgWkr31vh6fCoRuO9i zP4;6xFz}2s>cem)dQptuT|zJbPS-`%fjLs89;1dM2=2vFl(+?wO+$Rb+7N(ZGEoj= zl817y13(r*r?a2)JAmf~#L5D<G5@Dceks2yKgk>mMXW0@Th68ajSb&4XG-lqyf9qq z7VsQ+HSi$}%n6$-EpLGcRYz=^_`%U$d?`FnR)}2mlhI;oQ>Ult4cn)nd3uZrZaTJ% z)fDT7QyvPEB*8HwNpQ@-F)K-M%t#U(GdLeXlHiz;Bq*s495bv7ju}Y;WSwkOlHin) zq^Mc5u>-=roq+2>BSi-Z_vl&dcQ)(}u0q-GWuHZu|7t$RgXb{xi10bgB=kiIdt+sU z6W9#kBy7nja0Gx*h&Xt>#Ssur@R;Z~=+Lst;qoT5egckGFMh^B45SF?^t@?AErb;f z9VA73T#sCdi?P(NMy)tMd%qv?`V?bWpksOo!@ycF2Aw~v5&w_aY9p~g@pw-&EfCB2 ze^Lf|ET>PQ>C9hpiis1P>5@%0kYZVRt|y5?lt$!a04LX95W43?=14jy1>Q9B`BrZ= zKJT^y9H9p*{9p$U;3X|0kvs{=v**b8aRN!J)EZQXoUF2^74i<=g`{AChk)=9ym+=B z!|iF2VL>nr>Xu$Qkj(@zqZb=A>;q>-&Qsp&;580Iv(Kd+OKC-kB|#*p6t+38DSFVQ zsJCcB<-k%82mn~?NwSY(m&P=ry973qtVSQfFKKU2QqY0pgt#hL1%x(dtip!_deiSD zW}dxtF8hA`GHMN57*K(*4~SDV){6jg(nsch2p_bG36}$r7JIB(A0S%mo6in4YaSn} zkT#%9)YWXEzcePJ$6<zuI@u!JhhB>+XpxaZ$I)B-+=<z_{-|LI@S1Jbhz3N`@R`WQ zkN^TP1A$~+KOuUgpUL`u_P|own)<7#?D@0~0Yu8v!q7dKeo415dGW9f*zUxC$!L*4 z{R260AYcF(FLgmCU}p0CpZjmCrDF7`+6CXR0s;rv1DXc=3Q;@#G=8=oh-|Q<-nd~Q zs6w!(-Jl}mU{&z2Kn2Cd2rr{8^H@TvK~awRz8b>b{c=DLtTWVYFb@wXO@kQeFG|<A zmuPv3FiIf+tS63PB*0h!2kg^>BTN2Z=@t{1aB2Xb+$WrUCU%Z{!T}N4Is`@qZHZ5= z-J=N&XyD|!30M-cvGeG9^n6*)*yhF_`Bc>!?+j~hT5hchE%}PJ38?D@9t=Ci`Gv4= zay<Y@0faE^KKtPJW9M)yzw@vnISa7m66Ww)V;0gZ70Z5KoZWyV+rzFY8302<ya5Oi zc(A3YZa$EC;LU?IG9iY>tH`QVM4*9>03Phxc0#5}&xAF*0&LhnAezthSb%f_R~!g| z8C$3r23?VTq|yX1K$9A`U-*QEj3IhNtY|=jtTT83Byg$&DU#>6fbR<TWmSS#%y_>- z<YbjXp5VSP%z%Z(Q^yoKg~5`@FjFYO8hBk6_BZ4akwViBQN(Ov29x@C>*~_P3>hBg zCJ_K#v!ypt=OGnhg&r1CuY;{wpYRo@x*`;}knHS7P2t7pxnS{k<$ThV#8eD71~BwM zjr7!79!fxNCg(l*`e|6nC6jNe+BVl%TXmrLiKeR8(^MfU0a`JV1!MP$Qw+cw1JD6I zpY=gN_|=0RRd^-5+|lf3*3M1UIHU|mQ;%3Rk0`ursojuamq7|_*GE`=gJ2p^8R**p zxzV=)nxIyMs7u5R2wRV4zCl3;HpRe=q<O;nFV(MFA#m^m;a=`Bq$lx!fzl0$pn7YB z-~+=4M4)u91_YL#0Vssf2)ID$ZkFF1CgqQqdiOgb*jr?1Q9`t`0LDt=CUKjoP}RVJ zsHdcHwj)(>3zSD>8kG#N^Hef=?+ucXNnH65C^eZCkut&*5YBN<6nC05VqgkT^s%n( zjr(&LvM0n8=*SPf2{8qDf58~R6u5^Bnh#WPZ;4Eqfz_H{3=Ef5lAlr~i$b&EH?Yuo z$R3W#)p&<bdXhuIsm#^9fG2_)dgF5DIcE}TFnlkbJxSvL4goMpxPyd7BPGOj(=7ph zbyh+ff);r!Cdm@<br1jyeih}976i#HtdkIN*fOBSV17VQlsyZxJz-)|d6J`1_Apo+ z-7Xe#qkVW2i3G>-8+8wXqN47R9WZbMgFw@uS)M--;wf0HfK^H+GbQjo%==7u3Wa(| zoilA<Sz;Y00BJIzj9#-1%RfbV%~?m-rMlOhnW+z2j())5USaGV?!kIbBA2v@Tmoex zk_etw3==N_2rxmAh?!#9H+*6RCYWAy7NK1U6T0O1p-V}DRhFSkfJ6|w1npuTIHF0b zS$Gkh6=*C`7rNwlp-X@U{Hu|MxFzSE44kU97He8@>^*YcC}=@5ZV6wZlO>1w?&Yc) z!$C`wUXio1sDm_$a2rAD&#bygB+D>_Y!a#!*<i01|CRk#43b-t{rE3<l41IdVwm+n zL|AbzFqK8%R}ubY{4DU;!yp<f5rScq;a|cin-0|r|AG(Y-A_AEVQI?4xFcC(?G>g6 z%(5qh=c<q`<P4f^8U$wQ!@?aMHcK~CS(ErL*i@Q@e+d+_@T`Z!zkGMMYvU_Im{uC# zhH%LI-5V}nRx0&u(juLDp^o^jV#GilvmYyO%44jw!AH1QhT2sBAJvrG$gEH!Zep;b zq4L`p@d-}Hw|DXsaj+SW&$T)L8N~qx6%P`bK#<|*b@O-~FwkveRHzfv6>qWNfE%8x zV`zv7R<@-#Xj&BWS}V6h_W&D10go6ie~mLL^RyYUsScHZh@hcBT)-j`30cK)93CVn zLx5b28&47q<kj`Q2U!<#;Jb-V5dX7oaq$0e4S#)2HJCC@I`HMoO*>$Y-4tvwq!jUI zpi#k(n9!heLb2ovp)aWJ1|zp~M+0EtX`MkTrZKAUg$($=Ss?YoqY0lmxjH|{0a3#s zFGhg(06W>8=X%(Nojt+ERW@41b~lp^>wqfrTF48xqvTqmWYNgiQf<g(GZ$WTaO1%< z3MGgmQY5UMyX5P35*o_`={-c%5a=Nc86{da`&BAZ5-SU|83)H+51T)HYcpW7Qy4w) z`)!Y882U_tqVNdOr}?H6#@j&7!U0ggkaIx&9y!}2XA&`^N+1SshZEx=&OL%gL4yu| z9+05LYPJSJONFRL2pSeXf(pts_R|w$8H@#U68OO!mALP<i2+5CtFd*E2&xeJ0p9*! zBL;u^T?;)t@$=2-!O8rCM+b&7$Uc3_c-}MrGrHOCr!4HifHm!zuo_&?8|-6(KGdtj z8WCqWMPv3sEAuDpqjd<SFwI&qQy-Lw4zOWlaKptQU06RCXk^mJ-h$)1fO&P6M*Xl( z1#Tf(G0~+Pp~gvP(6Q%tQwhhUNIe{>ix2d~H+v+#!Vs|6f+~9k##2lV+_~I;iOyx7 zVdqT{FZV9UpaRpmB@3E)M2UFQ*2JcvM}E*3tK(P0kc6+Ta7R9Rjm2@+pE18>{924n z;o@i>f3qm$2}*CZN=UBV=m(^V7p~FTdeO%;2Jtg~KZ)>WsF2-W^1UyVG8BT1aRF52 zPN0f;Y@lj3ThNxj_Smigpw|pcvj|wfy=#EwkI-Hn9XhG4$|I96e4Mj6yB|SI|Jh97 zBXw(R?UAnDb%i7>5`|x@r$q7Vc+W$u!v&T4!alV3>bqZ(1~z+<^slI^-S#I2RVOR~ zxHt#Nc|s|)HG{!nwzqjC>$OL}e9h(lpsv*n$9}}CS(GxWXi~E<d-8m7jd;QjcDUMB z5r{wE+mE8*-65jl_+I~anIRi5pg-#Ekmtfj_|stQ!S++(RKlf9HfAz=?ow~j@bY>3 zWo>(>Wb3iBsz+e~WO2AaRpSiD);urJl<EX#H~`2QZc}wzYj&d^ncd1)gTW<hd`hJx z?j~SMG%CXe$Q2^i=&0WJg;BYo6$M6XVvqad985T<0t&sxh9=R0V5b-RFZK=0!^g#i zId?BGz)y~k!y=yUKI7p-jZ(_{Xd)K#-ly5<ZNoV+aFSMf<7eHt=WwIxz)Ovt#n#HJ z_}th#@w$0^LnqibBtw^x-tmM3S`o-%$t=-HZmN})zl17!TdklyAW`j-&qbpLDLAa< zUPvwFeYx+!H(tK1Qs0f&1Q>|3f$gJl7?D)kQQESBG>eGGt|^b+fl$ggAotB{b3X8O zi-s<olu6ee&#A?f!YO>v{!sD}QxcdGD#~6l>i#!4<$JX0H>;o^gdT(D(1Ui&_{&QE z(5a63oqe=c4>p9V?ECBLVR_U~eTtdiW-?<nLT9<ku7oqAEB$IqujbF@UK<8B6002h zNS`+OJ*E(h<c+q*02+aLZM7W0N)u<7KgjOap!aFR>a<M3dbuHyAYOhjA*kz+1KKZY zXMf!v_UfiQ8`G+8+Trq_Si9*~FRTR67cAS^N6h9oxQ*Zq5!$x1|H|x^Gmon~LE#(> zIH)MFD6cNiYPx69uACvQNN_}_0Rc1+zrCOjFpboD#!eY(0b;o$PpAyW3NJ*0wAkq@ zB7W?H|85mt0t@i!ZUQg<H&M_YX|jfs-!M1<mqu~}I!Wi=WN;cMgIyzHMjc^TVIsz3 zK(^#`Ph1Pla$N_S)z6n^gRurQzCO)@R}v=BV*Ul$3<8!g?pp8&jl>_(%@ocogCodu zdZF;3*RT}PTm*&J^-roXCN`IBhn6%PNGp5t;J@-sYUz|{S0*;fv&OU%o=vPVm`%;B zG0Z-nMf9^ThDbRfb@{XezYy)h6G3$BER|$-p38K9E5^hA3<U#QrJRc=NFxG{@m81~ z<Z@xFY3k?KkiTf8$Ib$<Hsx;`Ol%(Vz*C((1#J!a3#q)02sTymJE>SucpDKLMDSm( zY3VaCz@i7zY3FjUs-O;7?gEpaboT#ia~}W$*XmA$r2xRj;5fgI(GPn*K~(aGlj%(& zvmcG=wvbL{8-)^cgcFAAMS#4QHx55$k?D<v)_tP>h$+xGSH+W!uIRJkAV|8p2UFJs zkN{gP5`Y3avuNPJqcz`~=Im^f&0O5K@fa4W+HK4BGQP9TZ2xjO-DxKg#|aGEW)r5H zMxqy-lBVmU%^0NGL>X(N&enp;X^={xDj1|PC}K$wJd0f_LsL|$+#|VDeDI2tQ6>#_ zko6<8n5R;1M$A*IGvh_gJk^GR@R#FTmNrVlK^|I;+gy&PUHr84LIG}PS0%zgSPZT9 z07;4#GpL97LE~#%Xs~97qs7}~ki;4xY_f~EG|r$x=#X#-&_B|f3VB>9O1ZcOmw=nl zo9a&*Dc~72q)%zjb^y6Z(y7C~82#|Vh1%2ZhHIA{Hv&2T#EG==MYRPdPB0-04IVH; z3>7qLe2FxTPb;CfF!f5?c*Afb&>}%@s`JjPU_xDK@V+*b?rmfFq}%GYz;VhQaVEG_ zED7??VnuZ}Q3GPl<VK4qXh<xg+Qt0x!21()Y#6xtLgbk`xN#Lm*drzs0dw_y8kpsD z3WJf~{Hbk4bDh-hTg_xypteB|pu~RMNCGTA*3=6aK?J=3^Gr<km_?!o1j;RsHbhMg zdcnJWg(Kh<p08L`+16f&6|oY*5DSorG7FYM#en20$}H%+F+fwOClsKG=SFRY<m8PK zng&OyYrf)U`N<b*Qj}`IJ}!=Bb>c}<sYTcr?n0dFxrsv0HuB>Vh09G>$HXrb7NWfe zuoo6iD}v>j59A0OW7D=g11ES!pfSAj7glJsAnwg7oR!qTrk~rQe0`;09gP4Kc(q0O zd#z@0AV(m*t!FxX$f$?3W|DY&?5y&18q@5L$)*6hy-Is8EV%0EUZr4#;%Iy2r==Zh zU2ip;x`8v2DkRvBZ~pwKH@%Iais4Wm^Z%5mT6z{NY>5S}K}|46L;M71mRV_no1i%m zk+*_l16)AK=gpDqEvn2Tskfd5rW}=3!JoeH=_@A02gK_g(v&Ld=mY44eHf7g&$hi0 z(GJ?bjXnUcw2d>h;U335{N{Lvr^EyI<9HAE;rn}&Fx{ar!d-C6sT;k=xQUSGXYPkw zjNS8|`(cmK4?uod58Qu0>~5*l=U)Q@I={T0`pirBe_-)VPiO9V*YE!Q$R|Jk%E;3z zE6Q=7e)t#6&s$g2iayByRaa%9Fq3x+#o7Etw^GfOs}(m_byr&5Q_EF1Uz|0MYU8O( z)x2BI6;J2a%jQ*7LfO)sd#bdI3J$u}GkJF*Uvvu<ccnurW{PQ(X{udXcBKuIymCEr z*VW_BmC9(lTqvGq<*4v<^$ZHPp_k9GXhUVm14{Z`F8X;feoZZl(EvSqKo*HmmJ z?&i+qbF&9r+?~pwL0JqSvs^q|EL|wNGo{7FoFv7M<*PGi^71xUspQMmLaFGUT*%J> zH=EpY;q;m6Np~(+Sjf*NUBF_ldeB`-B+VP#s?4flnfhWTf3u>u&g6>4Qq|4P%;YP; zzkAP_GCJ+vUYf}*xOZ1e<s7Ksd~RVm@6Ik8e3aeXTs2>IXY;_&@(jDnUZhyh9La!B zi9!*j3UVKv;|Sdqi!TPt%a!WP;Mi1dWMq0KpUcn9j!jRE&x{XEPY<0M9vdE>I5jal zH9nD>8$$ZvLg7?7SH3iuTUaQ~3|7iBgL8}3fpX<JS5&FaN;Q>BmBGrT;>_UUay5T( zuvj`#m9bY|(8|jf(CxF0wciUgR|czRN{hM5-~b`Ayfj!Xl@?~s<O)UftC~Btknhjs z$}?vs#`<$hOL&^il`j;E1c<<pzXS(MH-QpxBLn%dHRZ$6Ky47i%q_f7pevLH^W}1> zJa{2jDg1bcQKKuvZ(fKYEai%YnX_PCFGOXe)1};kfMRf9U~suuxHwoXEasn2<uj#n zUa02<s|YA7lun3<c|kgcSw6T}nteg)0%o6YT|TvAn9s9UHG(VWE}S@@2LpP3!}Po6 zO~k?r1G5l52g~`T@`*wTeHC=nu@t;34VISj#lmbpH&~fHYZ#KB(!aE@d>UMfoC+L^ z<yX=!*c`NrS$7UX4GD2~u~2k#r%P%j>YkCC4NLj*Vxdx@ESyE<c}kKtDhd=cry-N8 z`B^pCOr6-{=gP62vQbSSLT+#iRlWj)MPDhF_fiXiQj(&QVW$7S)jG!Wtj}`yuIPPA z_paz(wQ(`OSSnv~g$QyY>CyDci$$ra_)@-dwy?BB-RD$p=4`c`gVweL*;{2&wdCf` zmkP7)QmL9p62x>lKeJpWq>^fUE>~sBGTtoaU16dJy=qysP%NMb)H`8O2TTS4;DDPu zRf6VGJe^#tkLmh8susiLfJG{i*1_b;Xws0Nsr)S7TsQ*+qwghZ7wo;183-C1NUDTi z==v4=dVnguuU6czSSQsMzkg6e@*IiO6f}(-xoS1PxK!o4`4Y$p@G3#gT3E=7isD`< zK!v1gdb7J+Dg1Q);K7$<Ur{QedqSaZ_0kgVq1Qk)TS9uVdJi;L^nD%`_#{O#bx(ti zNcNQi^rK4y&`*jpXDVl)*7cW5rK)H<qSRr;3%N?QUs4Y4EwVq0i-l@s-yJ1)c?lGk z&n>#9{2kPuJWcRYzIp_D=vj_M(m?bVl8Zq4%^U&h33ZDz?ZxQPJy0J`l`aC?qHP(4 zG3OpFRF)QUm)yc4bcjWa%1~W7UtKO2iM*mm1k1(z#U%_iKf7@077AY~?z5$tWflr% zEC9gt#XKe;C=BZ3OdcSfeLfB4sO~QUe?s5o9H;5kdVApvFv*@k-^`a6F0rTw&so3; zOoF_{E#@!_W=a@!!5eICnC>0--Yzt~h?h4m7nyhVW|Yhy<ODJVbMj^P-hI!B!`UOZ z-Se{Sky~yWK8cmm`9ir=6tJAnl?znWh^bi*(z)8SO+qIEaNLtG%U6~css|4)7cZ1^ zOMCa7bivmE`jde4NuyuC`a6Gl;%nA-e*fDaU0V3N+(&nR=+cfif8p=G|N1*$d0+IM zdtbE(_FJA?C|v+yyGNJHg2safi+SjTqG`Lt2NcJ}rKUW<D55U4QFMq)oeO2~g5v4W z)c5lEEg_#Q23V$24+n4A;BlVw`311FnRCm9GDw5!bZ+)MD1%5Tlw45?Dp&+vDq+O~ zq!r2}V=U=v*^Za)mX(*633J5Dq7IiX-Z*=y=w^$TZua=gOm4Y?&J|0=egG6JD>k%> zwU`+|%y6W6FryL>b!vHT4$5~GoVA9hgGqz-yGtOK**vIa0gE9Fri9sw8F653f|y%g zSitfO@XWcu(5X@dZTM_^AW53!WI9n6F1%dM*Sd8&U(5rKq~XFjay7H|I)3z^dkO$p zBHzznJcGfa3-?y%res<QvQI28ov4;f%>d;c;8*N>R+e#+&BNuHoROHP@|Z6n<P))g zTCa{L`da(?`nLD=MJ>C#uWxHh-!`YO@A_SxeSK{md)>aio@8HSZ(pB6u04Hyn|H_3 zxZ91ZPp9~|eM{elt{pqIGXL&gmQJYSp#0O2MY7Vb<S{_73Uw4q9uTnfT$x!R5}hhW zzC!kd*`&w}b8fYIN!`_`2B<s1qYV!O&d9J0icrR_EMu7|^B(A!1u@Q$K?iR<Ii!w} zGvqH8D%Fa*PnNb=dbta^GM2hT<Sb;Uhyy7pXpq&os3PZKLs54cqlXdZ97bum3}l^J zu3S>bp_!D+%S*tS3*8+;$t4%4<)WCvy1HDzq**TJ&V$%w(8s-bT*>Fpsw3r6rP4o} zXLIgCp?DVMbF=-WA_~a}P1WkCKT3fj1>hxhq*N?=LXR-50tJ9mz8nG_dBrTPAN85% zIIq;LrHTl=<szsADikr2ot;HpRGuI`^XhQ9bQT1)R9MQZ<AZm4`1DcWb(?d-6bn#b z-r$k=tzqamO%Juld-4|{42dOy=q(rXGoFa>8uCE}l!<s!S?>m*Lf-|QrQ&II_oc;C zCGZu2AjrS8B*+)35M+YjbYY9R<WiCvm6XHu0E{t7PhjnsDHoPNQ0Qxbv%|2JG`!|m zXQ1~$Lo<4hVkRFD$2h8h+*HsuQCwkBVYZ<b0^)*tJ<FHwh0-!AFU>&dQ+Gq0L&^c1 zAa?R?LDpSm&`oI>MzsszFaRu&TAMaTW+Um?a?z+pEFVZ*yg)Y~N-8w{Ks&|AL1bXu zP>|GdDC5<_TmjXVp~{{|?%UCImwZNeOeK$AAoZ?tsak^i<zb94@apuiU!f$bs-uuu zg`%0z9*|J8h>}_L60a9EfCbfFfCz>*Or?!;)~hXe%^}s$RF_;?Uc&st^puX&o_rFY z%Pn$hvWDQLOd)SX!Ca_s<BojwLaBU~n&K?ruKeflENV70i>6$7B8skS@L<H1YlJ~< zKz9r9*cOx<PIlz>(%7q7Vh!!N1@e3-f|$1D(rM1-5K)9vK~x}8A#1GDW-<F;x?BQF z%wL?z=V#H<OQ6dX&Xks*ev8hHq$9bd+^NC>Gzu)Ju)>0rEzA@^D-a6H)*b3A`f2C) zoz42^B7b9bIe({q+1B<{<bC=L`c8Y$=~n+G@|&u|?zTUs|ICi7cSn?-wZ5(MkumEL z>q}O@eY^7)k>9Ys>uit2BOkDCi)^$ms+U<e+TXUnp+0WE&iR7+H}<8-ZmXyl?Ox}1 zoTKV{`n~Fn_DS_k9dq91yvEtDUZJP;&#R9`eqA52j_L12X3*!ELKPF=Q>}hHil~U= z;QUF;^5kUmAMf@0nN}CQw*0<`UR_?T^QuYi=a6JYotW0TQ!Bm48nJ9W8Q<naQ%c`x z<<mD<!&aN#ZfTv0^hW+h#{sS`jr_J_C2;Q`U=v5rSzAyp>X>)%zG(|3B6zlKAX)2J zd(zekZEKcg{v_^gOSd^zODu+azh1s$y><)@;Jzb?Z&08T2bmFjWTQeH4H{tzUg-43 zNGnQj(iw}jJ27~^(Y9kHV_lM-WPRpMgk{if3rmdRiaHr<D}Fod&FGu9V#p9hEAeR3 zvRe*1JFa7!5xp6IH(3$<jcM#~b8pqVo$K{(-KJZw`xG-+3N;gkj<pY0Pw><Rx^}E1 zh&0_Az0ZnT%6?_6E3rAzDSfb7USlPr$>`STD`Wd3$C5h{o7m!LyU&WG4mew*Df?!G zt?aN;$dk4Ptd`D~=*QzX+HLmDk^NRGzCWT+=N?>g-Z9$WX^o>-X)CHX03QjR$Hw_> zy4CJY_q09YsIJsTWvNKN^m!^~r8g&H7*qJa2YR>aSGIg*>s|Uj>vUwlevQ5_F{G6} znNXb{j(@%LMmrguiXX#Su+>zzeTPmtJ$f>cwvyKA&D)bv^d%~F-sn7lv8k3#&ak7b z`8~0gNMtKO1u)}3<@QBK^#<)Y5&b3r`9IoH639hgk`csQY{grUhn4PqZNzr^?N?wF zdT`(kJM7`M?auB*cVvI0TVLm-``&?ec00SBz45VB3r0`+N||4KtVJcdlJU(^-KP_` zMA6a#(2%w^>09H8A+&%~jkZO$?U`~qtr0xMK@B@B+eyX}u|w|lPD^6K-eGNvY>hcB zvAd2>j<$}*2I9%+9(zaJdSWMPwJj?XjVF2r;(c9P_iXRnl(+-EJDSwimRK@6t~=t_ zCr;|lREHkCSttZOOGR&pB%?Sf-<wOIbI=uNo)jI4fes_7Bmt%OX!Gmf*NR&^UaceD zx5kr?Pgr|;Z%HMits4%4HafR&v)Y_ZzklQG-7Rr|H;$s+m<26~<gqyDCbjL>_}#6? zW6AsCujoxDqY2%w)5#t1T6XF5al-3>&E^sqSrot5;fkX72b|=0w#3@?K6}T#x^1LY z&6RW{^$9i8s*)<gL!4D?r%pOA)yZhL6-^#XCIS78kqzqLr(PA=Vr@Ban=>72KNLyD z5^A5Dt0+gXquTTij5Y1Vl{y<~wG*+V6<0s7RdiEZd)t7uDN()gJIU*<k8M2m)157y zEiX@OdDFf6#pEgEIJ!k<Viq$ij{3H1hjazX0c~5VEu!1h&S><%#&kR8Bw!o4xkoSc z+n`Y#B($^rU_`6Fuk3g9%1+%Cx1NllJSa_n(#q_+-C>@ix8L+JM{h$a|4-sQ{zc6X z8uHSa$bZbmaXFG9soTM4BD#IdQU{#bmNszDHho?4y;D0{4>$?Tx#+JcyWMWJ|1^rZ z-qzww0qT3>-8gyMN++&Y@wC;7ll-Q^OfpDI#(qJcR`5d0*o3<S@=7hKn8G|p>uCSh z)X>I^&c`tOEbZ*l-P>N3I+#f4&5rQg$bS2VMBirZPNIMl2ZsT`cj$vG4*s10@ofcU z$*K&;?#3KR@fobm(VbQoxFT|+ZUEd)THQE}TmuaWy%*p>tu}dU41rOsm8_rqna|s; zX@QSvWrw~MKtkm@u3rj%+Y&#etwbtz988M;Cewf%n4jLA&~pwz4s>s^96N)v7%VWg zHZUUPY{!)Bwhmc320~APm{H%d)vM1_Btckw6&*ltIomK7z{~Xzd8ad!bnHEe7DO~z z(J8z_vB(tW;IG*Df-i(r6qET=hm`(!kFtMOpLA8UV1Z(ju5}j}JO-q<KnVP@wY8&F z?}=|oftlDt01!F}VQ>Vn$CSg!1|eL?7a|yWA4v3X2{;H>mC0m)M*Xz@#fSnGMV!61 zqyIJ9$byl-Bzi10?7UV_wC%%KllCy`jp>{0-4PwX8EkwE5-JXvcfz(Hq%8e*ZO1nX zbPXob;2K9XfJB0@c`|<5x;M^2ZX&ErI;iEZ&|@${Ya8Dp^SnSQ&%c;QDxs}^z?63M zqiEmJZgOu_XapvR0U!b54mEx@24Lxzb;<m)emaUIU;}i><}eLY)>tHBZ^D?PZ8$p8 zR*@TGO1}t<B1JOB3|A358joACZs%?IsE#ud*J<6wnH$X7S$zd%ZgQ0KL+kHZJz%e5 zi!pWb@-yl^1gFgRdm}2cH8Pwbc>kxqqVG&7M}e;}OSDU`=wW2Li<y}Xd{*iIV=Gcp zMl$N|lWJ&qWOQtNVsdIacWP!fKj*&IJ>VX6-Pa}$B=_!3zHZ+E_jSIx@XjUisyCPm z-IKKQc;>>JZtA%SHd90nRECCzhKELmMu*0R#)l?`CWoemriX`yhlfXoN8wN(AD$SV z9G)7U9vK=L9vK-K9T^)LADI}L9GM!K9vvDT9vvAS9UU7TADtMT9Gx1S9vd1P9vc}O z9UB`PADbAP9Ge=O9v>PX9v>MW9UmJXAD<YX9G@DWo*0@Ko*0=Jofw-KpO~1KoS2%J zo*bGSo*bDRogAASpPZPSoSd4Ro*J4Oo*J1Nof?}OpPHDOoSK?~uYVdHnnq*OsAw9Q zrooK<Y5xh|dtQGyIoJNzaOkz|@Lyef7j316BCbluPU_<wuKo6|D|@?kUg_`KePyz` z|Kv^o>%fyY{p@eKHy?QX%F*BYBlni#KVG@*i65N2eKPjg9ly5gsaHKZn0x5HvFyX& zJM!4WFFy9v!z$PFCUqwJrs??y-*jm4u{V9?kq6)W%=?~tWY-5C{MqmSZtmyQM;?2N z`oc%w^1(0aw?6)r+*`l!N4Z~p<WC>_RrTjj{pzDnKlp1;eB+~UKlj%UzC%5e{mno5 zPWE?R@sGLpD)saSo_zcbAMF0{Z+`H-Uwrz5PyJQzhhFoyr#_^<`<ox?{>P7fNU6X0 z&}|#+-}~vYUBCCkXJ>G|?f1Y?)f;iF!doLJ)lY4>qB=IYJ6qh7JOAf_ll%LIp4?x0 z{K^6Ax845k@3{l&hbJfA@$8j}ALyP*oy<+{Zta=+o%CbVgPVH}{(X1PZHLAlySqF0 z)IHD4=kC3;^whm?|48mW^`+d)^Iv}K<?2s+UZI|R?3M3*y607Y{f(zy{lvF&Kjo^w zea$oa^(XE`AGSwNsuS&3)CV^`sZy@1XNRw7v}axU$YWOJ%a0+)-`XlO;Hb<AM@5x- zG=5U0H=jh@+>`p*AG+2bWP0qOvB&HyAL(%_U(SKwzSp^|o^=KWT>QJUGE<t(!|y-q zSvqdRt*rirE*^mq7Y16If@_Q*d-%xFTaL};7GR_+FX!(lRgW=#re^azDxS}p)!=1$ zvCxGRP?Yw%duz5j&+rgRspZeo%a@-;OI})jmS&IRMfh}igBMxfxaHMz6vhl=?z;=t zzO1n#+#=S~m(vn;m_{j<3rvy6E4BL|5jk$5b&_VxJ7LA7A<UTAYInE8KqtlruW7!y zRV)ym!S8r+X}Rj%ABC0n9-1*>jD0x`{6Cp5m%NCjV=&62!MkBer9n*07q@uU3swrV z+wC;>`o^`xFx=A0xGd&p=3vTEbd`e>TWr2^RE)6|lzj=Tg|vEAysoeSZHm4X@^%;@ z*#s<9A#3oxu!?w=r+Wim+byLz^#9Jemxy2ZcoCK=G0k|bz<g6=?Ug&>HYwj*oPoLT zbg2yY<t%972wZP)B^$dAFk3EN$j{Ogz$=D}A}WKc9ao!ob$HJ$_)XyA`xLGuuGY{m z-?JR^r*U=RVjA<ZUY6xOFP7swUd+$;EW>BEy8%}?FYnnN+oqz)@+$k8FR!T7GcDat zMxDbmWUJbOUk>Eo+8H&E-_PJ;NBrma;hq(pi)Yk}@Ou$uc7>kj@Z1x6K7;2zJp2Ff zD4l&rDkB}_px9}$W!B4%^4b5#{Os&E(Z@ynz8)Dk&>#0jAo~6C5!%=<0=}_dTam_p z91pK;xVGc+>)L_)Zd^NY4a73)IDTIX>LKF8`CL2`j=c@(g!OJ**Wuznznwj}-`tuJ zSa-@BLzIv9Wz^k~jJh3ZT>$0)ey_*n_h&Ee_u=w!L_%Vn`*9t>)sKs125}J%d^dz^ z7}p4{QCvSj{kP)xv$zP8F<j%gCUEI=M#i=g_mjB%`loPz1?66iU*Z*u&fxhh?vLWS z7c}@&_+?r4<-3;2PvR!s9E)buo%rp;xJV;ipvj-aFJa4P|NUOPckzs$;2-a&aj{)| z*iYSv>n2<`<JyFawC%&_5bm?M4&yq4i+#HV*D+jM+A@MSmLU^x4iHMUalRP$kAimZ z!7uwtnE1~x!9Dx=S<u1l_$3bc%zr+d2v`0i+<B2+_|Jd-3EYSAc^jU6xN$6mlTT+a zg@E0SXSVfEbv=T`5@WW%4_tBlci<vi`0p<B6a0OtclWX&<wv`@2)MFN&MPv#yK(tA zB|h%ObssMOA8F#{xZVdkBi!Etgp$wjpI`0-?oZ;%;d&bMM|gh|1v%$V;hMoUiz_o= z=)lKQ9%=l?Hh9hP0ss8>r_E!4_cM6@C9nR6QieAC=bytf`ON?B$*BK>>wCC9cU?yP zCtOeC`g>f~9?UyjyKs%+I)v*uu2<rk#Z|=hT3ip|`gvUM!1X>{AI0@0T;Ig?ZCu~S z<y@apt++Ph+J$Qf*NwPt#r2c8PT^X@bw93uiR&G>-i7OfxIT*O3%I_5>%ZapD_rW; z2{=S%muC<bpj&XuV5R(Q|EWuMD}sjlgLt6W(7?#p_`q<6si&aZ!GYh;@Z0Fn!1(0A zu;ljBXP7uTFs#4is9a@H0DV{Lr1kpOtCO+Un}6!0jT`;?lNyQgCL%A?$)uM9KPHp^ z*U#WEgR(buDcFT=ClZZek|yF1TC&Q3O=U@Ub;Xu8=^<cvSZO_?EIkT~z!<Cz<0_&j zR8&u5B|N3#dRir{;USf@hKKQ$h2atG^Bx{mt=90EN?XI@s?8dnP#J4@Qng#dQ>w!n zo>m*Iks$?x=CIlr-wp2}6w8WM$@f`ZDdj5ci6WzgDVX=zqJgs;T<mVh>UPbu8BA){ zYR5?pUVfYT6L^z#+HvlJml|nzRH{8xOE%gbQL-B&T(4`6!Hcy=L$$}-@pV40o&;<6 z?vuO^`jBdmH?+{wo(R2f<$JGWnkCzU-elX_lfk=8(EoP!Uq;dq+-(T%I)l57yi<c~ zk)K{oe&ZtpL;CluA0I_oZ`PX>X-0@fk!lpk29IM{#DQ=e1<HYYZW8Mw&<?C8lv|`8 z{v8I#ApIopcU0~veMv$|{H3%Z5!v`VC4Z;+7j(qG!}wdHB$97pyN0Aok;a9@<B4Rd zr8V7_Y46z3x$#E^94t*>cF|$ShEh3$pfH3Pz-p8%6wf1gofa2c#Uk)u7aNk}@FMMx z!t{&cddRvFhNvyCf&*2eKKf_NrKO*$)d6Pss>M8F`)5Om17ItRBZRFAej*ijs=3Np zR(l}28*Clb!h#gv;uqxGfkLG}2(OF7_6*?peHgLshK+SYLmya5SsUsqVVq*Qs_eC^ zR!-zp9wE9af=^|@@EP;E%HH)yTTSmk0qEnHbu%n;T@B6pLyLC$Eqf^gjD}RPxc695 z@#qGC9u+|y!(WgC{!U7z9VVKfzk+~<<w`%6bBij`&Y;TvVu=9<20<#>h6Qh3u9Ody zDcEJvy@k?g)dEkJR1VsNWo~%L>IBXr5*evlqoxs+j<!iIlC5MLff*g7b{b&`Z8q9b znJ7GYII&;W?F7gGY}jyOHb6jLtM({deku`PLd+>woy<QNh0Ph>p#U=8yMa?&SPE7_ zk#V6c%oyt?3}913?S6f#V~uLm28Vf6XS5w1Xl*HQl?)hRUAV|j3`IMb-imOa`WM5| zX$X7*2(y$wf)~tFty*{bU9KxNP+303IG9s;={xMKp9egm+ZwtY?t$94z2R+LjjF4s zc}uWt&YbO+&^@(jTk|~T?dILh-wKQo<kmm8uv|H#wp?vUYHM@@0JR~F04@Zi=N9^9 z90cKrwF~3i(J+wud}>>?6U95*f@CvLmLIitqkQ*T<xNJIr+iA7v^FAt8z`|q6mF_^ zfO}}w;UIXeDhL4IgzrvEg6fW@@ivoSDQpTT6eSBBqo8VCONHMG2`ZiR@}Y6b2=_n+ z8AHj+m7HyU66joVM(cAT8K+D6U7xb($?jm~0U)F|_%Mr~XWH6=JR2JGpeJgVpJ`;M zc_y~D8=0VM*a(Z34#nWqV?swjp8eRZCM;dvLnw7~W+T74u1O}fCunupdWbF+$%>Us zmqdkeZ^J=vCqs?X9~vGQ8W`%(+ZZ?&&O%09@!ncH8{aZYNaA_C9QNkM9Lojom9wSs z6}X%CF0$&~`5C{_=#Iv6us(7tpI0`vfg7t1`rE7yL9xX^;vIe}*@>5!*ytx3hEnm; zlRG4RW}z?`pjoDi_deCV`n@4@FGI`LAVY}6y_c<~gRGsj?xm(XgCasvUUHl1A~Pcy zjd<yqx^&Qum)_o_>*4x3nsmLc;tkC+)LQ85@j417U%~z!h_Dj2g!Rvm>wCS}xILWb z0zyRVUUx-r#mv>4qF#_G$h)Q-!Oi5eyc#(MI)>7lT4oT9bpir_yC&msn!)7;&o^eK ziZ@db;xeCb+?j&JLms0NNNP$_F<+I_RKUksfbKVEuYv?bwm{&^t5)w7C#cm#%u6F_ zX{3u;RttT}(Iz<{#=*`SQ~-IOk@p;ir~s1IE@|vv)X0A6P>0tcxK>rv(*@+k2JZzc zv8e?}YG++4nwHd!UMl%<Ld0*Jw_Rw_*&E%1+t#bgH9^cKBF1Hb^@N;c-OQ}}nDv?@ zo|M_bG6Y+Dqco9mZ1u)L>}+hpw55)1KJ9chW7(2ryPsv#sw|iml4S?85Vk4d+a$_v zBv}_h<ss6>;$t5ctbJH|?8nOE02UtoP#6cGE)HVVF@#0OFxDI+Tyl(3bsOV?W1Q=a z2`Vy^RAZ*N*qG*8;~<wBh%cK~H*%qI6W19xV`8a}&9SsKuXN>*wr8pr_o}gIp6cMH zy^Xwcp)i-9xiqujanD^jM9rRuA)c8>WTIg&Q1!&M7K^t+-Fp!hA($|cL*ucCJ_O|} ziufng*LSsAAc?%@<585rSH_&T$$GFAmBenamMUc@VMJAbVYZU?ZV}<g9b%PKZmH1k zKY&ZrqBd1>#8Ml~LcX7IwJ>~mOC+s2o1_l`cXc4dh>}}sdFK#b-0yc55l3d6$!(Y= zUTu=2HXt2M0W`tN%u;o=$5cF(0c#3sgiRyiRf@nTl?+uxH71e+cpSgu^_|(dRu9~! z0NCr-5(dkVy*G88R;$E&1GPjptJrqu%)Yagc|8SUvn^nS1UG5J12v_lqpuXI>up55 zcS3+C(syA;66xc>+Vr-%fKb|DkJ64DbbeRUa6m#yPFevLWKcCRVr#W2jP2SRAY8_x zITkxS7K>vc`VrpQ7Dg_5jbJXIt!|MmwM1D<JX}kRwZv5-l>+}|Nnkh`IZlqgs?r`G zgN?o4w`S+}rFMhktPkeMRu${Am-^1^J7h)05>UB;$hvXu2`ahzO`vqy=vTYUQyk+^ zM-79$DY^%uA=XOj#kM75IzU3kOTa!Bgt5b$>A(Z+At^Sh7o+jB`Aertx$>-o4QI=- zDtGzi$}Qg9>}O)MlIKAIkrI7`i0@Qe!uV@wQN{LHXTc+)CcP4=7OL<sMoo`&982zU z6^>9V*{jv|CKUxewUr&q>2uN492}JWglm3QS5#6SD>-Zpz*9Ou%UzQFbJ$1-qbiJ* zt46G0;DhSgkDVsm6pTWN3J=6s#?Co-;cm56d$=3EeP$i{?O2r`q#H%x?NU6sV?FYY z#X7As$pG~Uq8><0BFM<EQy7WCuWm_gMWCzc5S$rd8jlGbwVp`_m9?=l%!E`LwB%I) z0&kDJAA-<|HLJgO9Yo%|K5kyr1ZI6}&KbXPnM^XCA-iv#iL_6vU5yi|$vlX_)$uFS z>A-;APbazlbQk?qt-k$ifc7(el)L!2VM_=pyqK!-A2Yf7*VcTk`bzDoH7gCR(1QBJ z=w|F?&f4a(NZ&P8DkimPeScy%ty#2TO&IhkY)Oj`UvqKa;+7a&vxvXO>|bqoOO9T> z)GEtdYVF<yO*QE4Eqm5%Cs^k8<F0k@n)!kyui0ar-ggZ-jrFf>Z9LYkmHV*{$Xsh) z46wZ(=N<Kr8`d!4bz;wqNB^=a#LB$32Jh$X#`=o7>K`{9T2;PoxgHRdS%Q1U!GVyC zZS%qBRD)RHwj6m*W$W#8Td!uG+g3m1?e&j4YD60t30Bz^h|I7-ty>IdJ8QNv*Ee+m zPCfoT7BIcj^&_k^dxUHU(m-Gjkr%seSifjsuUJ(UZ+2g$aA0y+RTyus<2q|k6LZ(~ zwP~`R4FZ<;3wvXp5n<m8!lC<~GfUpT_8bf|bK$!Oo*$#_Uz?RR@@b#Z4m>ADJ6NOH zMrm5(nT;-xp|yKgD<l@+z>_k3jf{5Wni=hAje;6`USr9I(T=T+xK)gH{95yZ(N0{I zWllbCra1LHnBw#c!4wbHDCF9?+6~X0S>1RQezPvKy6Gx~*JV~W*C^_0Jn2Q(Y@s>y z9LzJ>AHf83_!?I2M}DLw`_WacH_@xT^(8hkVCj&ooGC2261b{UG;DbmW7x<LUf}S! z1-+#%j-{U^`f=(MR#?`C`d4#>i_1&;Uqw{MR<*7Em6XEP-YU<0(O310SnIh(>{Vuy z2F~iN=@eP*_Xw25=-Muk*!mukcr%AcqKQ8w+1wqHYT{A|c|%&7I1DhA;EgrTfYx<< zA?aqWkhZ3tka~wlFjw2x@Pjnzb4Zn0!^siU+Sz1GC>~7mjaT=9bgk<G*(9{P8TJH_ zWIs$pl`RoNp1%}}bgR|m1)HU3Ez_$AA)pa!O~SI*ClTjrBpbQvlvs~Gw>QdBnfKm! z;;RQ+b1^m{)9K_@fj^yE5BSq9O(w*2Yg6K%PS??wPaxCvZaJSWrZde*ce;J;88F?^ zWCl!cXhOZU88_XGIzuyVdSjE~HR7M{YEIn2*fuqP9}IHyDl<ldS=jJkpPO!3MOzKo zw;pIDk9uoweRbQeV&&XE+*n%^AyZ%Fj%%^^=yy4Kms;C)qSsjcT31{8*|JV^YU{L! zzcvF#qW(Rg9~0M7lO<1L9fxVMnMX8bT%avYeV(m>i!<HCquI9Fg_&veS++Aysl#|B zH+W9SPWl`-GVrKNJc^sH?m^tVp3iX0HF*iQHu?p(HE{!OU***U;CHM_1O~c^ft`Vu z#tpe;dT7+|^=+SDjN22cyLxHWOra6d=i>=Lseb{~xnON?k~QG!c1Ji$%#?7pH7VgQ zks>ChD~c0jR&}6mS%QE-ZrY0XHp$qavn2e`x#X@UnH%kxDL=OtUe?-dfWcxay|YP` zVG+^R6qGe7k=cj}M6(XtmZsZ-aJ7!zeg=tBXdbpOy@CB$M?s(N?D8|zMVCx(G;r(+ z+S{}_lre0qn%-O=skSA^*bw0{y|q5$w#JMBJKkOw6SrfN-?CR>p(twDboY+%i$?3w z^v+;tEEmM3OuHnaV0ji;e?rozr>AL-ut4f<h&tQVywZ@2+T9pvc3tyUP$ZCBdm77J zzh;>Lczb=1?7n1;hWm`$V}B^=fCRPn`(!;}5Iz_ne5eiy!}YiuX&C-!V@GeUcb&ax zwHxUdaeRv!Xq0RXg49U0z$2koP5x$bHOOv_G*-W+;0(&>f7Vt9Ll(M!)>i9+OAX`s VXKl4EezJZJ{<yT&A9?@4{{<irej5M) literal 0 HcmV?d00001 diff --git a/plugins/official/git-toolbox/plugin.toml b/plugins/official/git-toolbox/plugin.toml new file mode 100644 index 000000000..ff5bbfce1 --- /dev/null +++ b/plugins/official/git-toolbox/plugin.toml @@ -0,0 +1,26 @@ +id = "openidea.git-toolbox" +name = "Git Toolbox" +version = "0.1.0" +description = "Fetches your repositories in the background and shows the branch with how far it is ahead of and behind its upstream in the status bar." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: auto-fetch every 5 minutes, ahead/behind in the status bar, Fetch Now." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +# Fetches on schedule (also after being reclaimed while idle) and refreshes +# the status bar when the focused editor or a repository changes. +activation = ["on-schedule:300", "on-event:editor-focused", "on-event:vcs-changed", "on-command:*"] + +[capabilities] +# `vcs.write` runs `git fetch` (only in trusted projects). +required = ["project.read", "vcs.read", "vcs.write", "events", "storage"] + +[[contributes.commands]] +id = "fetch" +title = "Git Toolbox: Fetch Now" +category = "Git Toolbox" + +[[contributes.commands]] +id = "refresh" +title = "Git Toolbox: Refresh Status" +category = "Git Toolbox" diff --git a/plugins/official/git-toolbox/src/lib.rs b/plugins/official/git-toolbox/src/lib.rs new file mode 100644 index 000000000..c5f36c805 --- /dev/null +++ b/plugins/official/git-toolbox/src/lib.rs @@ -0,0 +1,242 @@ +//! Git Toolbox for OpenIDEA: background fetch of every open project's +//! repository (on the plugin's `on-schedule`, which also fires after the +//! instance was reclaimed while idle), and the focused project's branch with +//! its ahead/behind counts in the status bar. + +use openidea_plugin::host::{self, storage}; +use openidea_plugin::{ + Error, Event, Invocation, NotificationLevel, Plugin, Project, RepositoryStatus, StatusItem, register_plugin, +}; + +const ITEM: &str = "branch"; +const LAST_FETCH: &str = "last-fetch:"; +/// Root of the project shown in the status bar. +const SHOWN: &str = "shown-root"; + +#[derive(Default)] +pub struct GitToolbox; + +fn now_secs() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// `main ↑1 ↓2`; a detached HEAD shows its commit. +pub fn label(s: &RepositoryStatus) -> String { + let mut out = match (&s.branch, &s.commit) { + (Some(b), _) => b.clone(), + (None, Some(c)) => c.chars().take(8).collect(), + (None, None) => "(no commits)".to_owned(), + }; + if s.upstream.is_some() { + if s.ahead > 0 { + out.push_str(&format!(" ↑{}", s.ahead)); + } + if s.behind > 0 { + out.push_str(&format!(" ↓{}", s.behind)); + } + } + if let Some(op) = &s.operation { + out.push_str(&format!(" ({op})")); + } + out +} + +fn ago(secs: u64) -> String { + match secs { + 0..60 => "just now".into(), + 60..3600 => format!("{} min ago", secs / 60), + _ => format!("{} h ago", secs / 3600), + } +} + +fn tooltip(s: &RepositoryStatus) -> String { + let mut lines = vec![s.root.clone()]; + match &s.upstream { + Some(up) => lines.push(format!("{} ahead, {} behind {up}", s.ahead, s.behind)), + None => lines.push("No upstream branch".into()), + } + if s.changed_files > 0 { + lines.push(format!("{} changed files", s.changed_files)); + } + let last = storage::get(&format!("{LAST_FETCH}{}", s.root)).ok().flatten().and_then(|v| v.parse::<u64>().ok()); + lines.push(match last { + Some(t) => format!("Fetched {}", ago(now_secs().saturating_sub(t))), + None => "Not fetched yet".into(), + }); + lines.push("Click to fetch now".into()); + lines.join("\n") +} + +/// Shows `project`'s repository in the status bar (or hides the item). +fn show(project: &Project) -> Result<(), Error> { + match project.vcs_status()? { + Some(s) => { + let _ = storage::set(SHOWN, &project.root()?); + host::set_status_item( + ITEM, + &StatusItem { text: label(&s), tooltip: Some(tooltip(&s)), command: Some("fetch".into()) }, + ); + } + None => host::remove_status_item(ITEM), + } + Ok(()) +} + +/// The project the status bar follows: the invoking window's, else the one +/// shown last, else the first open one. +fn current(ctx: &Invocation) -> Option<Project> { + if let Some(p) = ctx.project() { + return Some(p); + } + let projects = host::projects().ok()?; + let shown = storage::get(SHOWN).ok().flatten(); + let index = projects.iter().position(|p| p.root().ok() == shown).unwrap_or(0); + projects.into_iter().nth(index) +} + +/// Fetches `projects` in one cancellable task. Returns how many were fetched. +fn fetch(projects: &[Project]) -> Result<usize, Error> { + let task = host::begin_task("Fetching", true); + let total = projects.len() as u32; + let mut fetched = 0; + let mut last_error = None; + for (i, p) in projects.iter().enumerate() { + if task.is_cancelled() { + return Err(Error::Cancelled); + } + let root = p.root()?; + let name = root.rsplit(['/', '\\']).next().unwrap_or(&root).to_owned(); + task.report(i as u32, Some(total), Some(&name)); + if p.vcs_status()?.is_none() { + continue; + } + match p.vcs_fetch() { + Ok(()) => { + fetched += 1; + let _ = storage::set(&format!("{LAST_FETCH}{root}"), &now_secs().to_string()); + } + Err(Error::Cancelled) => return Err(Error::Cancelled), + Err(e) => { + host::log(openidea_plugin::LogLevel::Warn, &format!("fetch {root}: {e}")); + last_error = Some(e); + } + } + } + task.report(total, Some(total), None); + match last_error { + Some(e) if fetched == 0 => Err(e), + _ => Ok(fetched), + } +} + +impl Plugin for GitToolbox { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + let project = current(ctx).ok_or("no project is open")?; + match id { + "fetch" => { + let n = fetch(std::slice::from_ref(&project))?; + show(&project)?; + Ok((n == 0).then(|| "The project is not in a git repository.".to_owned())) + } + "refresh" => { + show(&project)?; + Ok(None) + } + _ => Err(format!("unknown command {id}")), + } + } + + fn handle_events(&mut self, events: &[Event], ctx: &Invocation) { + if events.iter().any(|e| *e == Event::Scheduled) { + let projects = host::projects().unwrap_or_default(); + if let Err(e) = fetch(&projects) + && e != Error::Cancelled + { + host::notify(NotificationLevel::Warning, "Git Toolbox: fetch failed", &e.to_string()); + } + } + if let Some(p) = current(ctx) { + let _ = show(&p); + } + } +} + +register_plugin!(GitToolbox); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + fn status(ahead: u32, behind: u32) -> RepositoryStatus { + RepositoryStatus { + root: "/project".into(), + branch: Some("main".into()), + commit: Some("0123456789abcdef".into()), + upstream: Some("origin/main".into()), + ahead, + behind, + ..RepositoryStatus::default() + } + } + + #[test] + fn labels() { + assert_eq!(label(&status(0, 0)), "main"); + assert_eq!(label(&status(1, 2)), "main ↑1 ↓2"); + let detached = RepositoryStatus { branch: None, ..status(0, 0) }; + assert_eq!(label(&detached), "01234567"); + let rebasing = RepositoryStatus { operation: Some("rebase".into()), ..status(0, 3) }; + assert_eq!(label(&rebasing), "main ↓3 (rebase)"); + } + + #[test] + fn scheduled_fetch_updates_the_status_bar() { + let host = MockHost::install(); + host.add_project("/other"); + host.set_vcs_status(Some(status(0, 4))); + GitToolbox.handle_events(&[Event::Scheduled], &host.invocation()); + assert_eq!(host.fetches(), 2, "every open project"); + let item = &host.status_items()["branch"]; + assert_eq!(item.text, "main ↓4"); + assert!(item.tooltip.as_deref().unwrap().contains("Fetched just now")); + assert_eq!(item.command.as_deref(), Some("fetch")); + let task = &host.tasks()[0]; + assert!(task.ended && task.reports.last() == Some(&(2, Some(2), None))); + } + + #[test] + fn focus_changes_only_refresh() { + let host = MockHost::install(); + host.set_vcs_status(Some(status(1, 0))); + GitToolbox.handle_events(&[Event::EditorFocused(Some("/project/a.rs".into()))], &host.invocation()); + assert_eq!(host.fetches(), 0); + assert_eq!(host.status_items()["branch"].text, "main ↑1"); + host.set_vcs_status(None); + GitToolbox.handle_events(&[Event::VcsChanged("/project".into())], &host.invocation()); + assert!(host.status_items().is_empty(), "not a repository any more"); + } + + #[test] + fn failures_and_cancellation() { + let host = MockHost::install(); + host.set_vcs_status(Some(status(0, 0))); + host.fail_fetch(Some(Error::Failed("no network".into()))); + GitToolbox.handle_events(&[Event::Scheduled], &host.invocation()); + assert_eq!(host.notifications()[0].body, "no network"); + let err = GitToolbox.run_command("fetch", &host.invocation(), None).unwrap_err(); + assert_eq!(err, "no network"); + host.fail_fetch(Some(Error::Cancelled)); + GitToolbox.handle_events(&[Event::Scheduled], &host.invocation()); + assert_eq!(host.notifications().len(), 1, "a cancelled fetch is not an error to report"); + } + + #[test] + fn without_vcs_write_it_still_shows_the_branch() { + let host = MockHost::install().with_capabilities(&["project.read", "vcs.read", "storage"]); + host.set_vcs_status(Some(status(0, 1))); + GitToolbox.handle_events(&[Event::Scheduled], &host.invocation()); + assert!(host.notifications()[0].body.contains("vcs.write")); + assert_eq!(host.status_items()["branch"].text, "main ↓1"); + } +} diff --git a/plugins/official/key-promoter/.gitignore b/plugins/official/key-promoter/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/key-promoter/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/key-promoter/Cargo.lock b/plugins/official/key-promoter/Cargo.lock new file mode 100644 index 000000000..089dc6102 --- /dev/null +++ b/plugins/official/key-promoter/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-key-promoter" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/key-promoter/Cargo.toml b/plugins/official/key-promoter/Cargo.toml new file mode 100644 index 000000000..9ecb09a77 --- /dev/null +++ b/plugins/official/key-promoter/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-key-promoter" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..1ca08c5f42dc808fc2cccad375e3bd542a4633d8 GIT binary patch literal 99579 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 literal 0 HcmV?d00001 diff --git a/plugins/official/key-promoter/plugin.toml b/plugins/official/key-promoter/plugin.toml new file mode 100644 index 000000000..10f032b4a --- /dev/null +++ b/plugins/official/key-promoter/plugin.toml @@ -0,0 +1,31 @@ +id = "openidea.key-promoter" +name = "Key Promoter" +version = "0.1.0" +description = "Shows the keyboard shortcut of an action you ran with the mouse, and counts how often you could have used it." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: shortcut tips, statistics, per-action \"don't show again\"." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +# Actions arrive in batches (a few seconds apart while the plugin is idle), +# so it is not woken for every click. +activation = ["on-event:action-performed", "on-command:*"] + +[capabilities] +# `events.input`: which actions you run and how (keyboard, menu, toolbar…). +required = ["events.input", "storage"] + +[[contributes.commands]] +id = "statistics" +title = "Key Promoter: Show Statistics" +category = "Key Promoter" + +[[contributes.commands]] +id = "reset" +title = "Key Promoter: Reset Statistics" +category = "Key Promoter" + +[[contributes.commands]] +id = "suppress-last" +title = "Key Promoter: Don't Show Tips for the Last Action" +category = "Key Promoter" diff --git a/plugins/official/key-promoter/src/lib.rs b/plugins/official/key-promoter/src/lib.rs new file mode 100644 index 000000000..97ce5d863 --- /dev/null +++ b/plugins/official/key-promoter/src/lib.rs @@ -0,0 +1,161 @@ +//! Key Promoter for OpenIDEA: when you run an action with the mouse (menu, +//! toolbar, context menu) that has a keyboard shortcut, it tells you the +//! shortcut. Counts persist in storage, so the tips survive restarts and the +//! instance being reclaimed while you work. + +use openidea_plugin::host::{self, storage}; +use openidea_plugin::{ActionSource, Event, Invocation, NotificationLevel, Plugin, register_plugin}; + +/// Tips for one action are shown on these counts, then every `REPEAT_EVERY`. +const FIRST_TIPS: [u32; 3] = [1, 3, 10]; +const REPEAT_EVERY: u32 = 25; +const COUNT: &str = "count:"; +const TITLE: &str = "title:"; +const SUPPRESSED: &str = "suppressed:"; +const LAST: &str = "last-action"; + +#[derive(Default)] +pub struct KeyPromoter; + +fn get_u32(key: &str) -> u32 { + storage::get(key).ok().flatten().and_then(|v| v.parse().ok()).unwrap_or(0) +} + +impl Plugin for KeyPromoter { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + match id { + "statistics" => { + let mut rows: Vec<(u32, String)> = storage::keys(COUNT)? + .into_iter() + .map(|k| { + let action = &k[COUNT.len()..]; + let title = storage::get(&format!("{TITLE}{action}")) + .ok() + .flatten() + .unwrap_or_else(|| action.to_owned()); + (get_u32(&k), title) + }) + .collect(); + rows.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + let body = if rows.is_empty() { + "No missed shortcuts yet.".to_owned() + } else { + rows.iter().take(15).map(|(n, t)| format!("{n}× {t}")).collect::<Vec<_>>().join("\n") + }; + host::notify(NotificationLevel::Info, "Key Promoter: missed shortcuts", &body); + Ok(Some(body)) + } + "reset" => { + for k in storage::keys("")? { + storage::delete(&k)?; + } + Ok(Some("Key Promoter statistics were reset.".into())) + } + "suppress-last" => { + let action = storage::get(LAST)?.ok_or("no tip was shown yet")?; + storage::set(&format!("{SUPPRESSED}{action}"), "1")?; + Ok(Some(format!("No more tips for {action}."))) + } + _ => Err(format!("unknown command {id}")), + } + } + + fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) { + // One tip per batch at most: the last action that deserves one. + let mut tip = None; + for e in events { + let Event::ActionPerformed(a) = e else { continue }; + if a.source == ActionSource::Keyboard { + continue; + } + let Some(shortcut) = &a.shortcut else { continue }; + if storage::get(&format!("{SUPPRESSED}{}", a.action)).ok().flatten().is_some() { + continue; + } + let key = format!("{COUNT}{}", a.action); + let n = get_u32(&key) + 1; + if storage::set(&key, &n.to_string()).is_err() { + return; // storage denied or full: nothing to count with + } + let _ = storage::set(&format!("{TITLE}{}", a.action), &a.title); + if FIRST_TIPS.contains(&n) || n % REPEAT_EVERY == 0 { + tip = Some((a.clone(), shortcut.clone(), n)); + } + } + if let Some((a, shortcut, n)) = tip { + let _ = storage::set(LAST, &a.action); + let times = if n == 1 { "once".to_owned() } else { format!("{n} times") }; + host::notify( + NotificationLevel::Info, + &format!("{}: {shortcut}", a.title), + &format!("You ran it with the mouse {times}. Press {shortcut} next time."), + ); + } + } +} + +register_plugin!(KeyPromoter); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::ActionPerformed; + use openidea_plugin::mock::MockHost; + + fn action(id: &str, source: ActionSource, shortcut: Option<&str>) -> Event { + Event::ActionPerformed(ActionPerformed { + action: id.into(), + title: format!("Title of {id}"), + source, + shortcut: shortcut.map(str::to_owned), + }) + } + + #[test] + fn tips_for_mouse_use_of_actions_with_shortcuts() { + let host = MockHost::install(); + let ctx = host.invocation(); + let mut p = KeyPromoter; + p.handle_events( + &[ + action("a", ActionSource::Keyboard, Some("Ctrl+A")), + action("b", ActionSource::Menu, None), + action("c", ActionSource::Toolbar, Some("Ctrl+C")), + ], + &ctx, + ); + let notes = host.notifications(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].title, "Title of c: Ctrl+C"); + assert!(notes[0].body.contains("once")); + // Second use: counted, no tip (next at 3). + p.handle_events(&[action("c", ActionSource::Menu, Some("Ctrl+C"))], &ctx); + assert_eq!(host.notifications().len(), 1); + p.handle_events(&[action("c", ActionSource::Menu, Some("Ctrl+C"))], &ctx); + assert!(host.notifications()[1].body.contains("3 times")); + assert_eq!(host.storage()["count:c"], "3"); + } + + #[test] + fn statistics_survive_a_new_instance_and_can_be_suppressed() { + let host = MockHost::install(); + let ctx = host.invocation(); + KeyPromoter.handle_events(&[action("x", ActionSource::Menu, Some("F2"))], &ctx); + // A fresh instance (after idle reclaim) reads the counts back. + let stats = KeyPromoter.run_command("statistics", &ctx, None).unwrap().unwrap(); + assert_eq!(stats, "1× Title of x"); + KeyPromoter.run_command("suppress-last", &ctx, None).unwrap(); + KeyPromoter.handle_events(&vec![action("x", ActionSource::Menu, Some("F2")); 2], &ctx); + assert_eq!(host.notifications().len(), 2, "the tip and the statistics; none after suppressing"); + KeyPromoter.run_command("reset", &ctx, None).unwrap(); + assert!(host.storage().is_empty()); + } + + #[test] + fn without_storage_it_stays_quiet() { + let host = MockHost::install().with_capabilities(&["events.input"]); + KeyPromoter.handle_events(&[action("x", ActionSource::Menu, Some("F2"))], &host.invocation()); + assert!(host.notifications().is_empty()); + assert!(KeyPromoter.run_command("statistics", &host.invocation(), None).unwrap_err().contains("storage")); + } +} diff --git a/plugins/official/kubernetes/Cargo.lock b/plugins/official/kubernetes/Cargo.lock new file mode 100644 index 000000000..f2b67e04c --- /dev/null +++ b/plugins/official/kubernetes/Cargo.lock @@ -0,0 +1,334 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-kubernetes" +version = "0.1.0" +dependencies = [ + "openidea-plugin", + "serde_json", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/kubernetes/Cargo.toml b/plugins/official/kubernetes/Cargo.toml new file mode 100644 index 000000000..85b529920 --- /dev/null +++ b/plugins/official/kubernetes/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "openidea-plugin-openidea-kubernetes" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/kubernetes/dist/plugin.wasm b/plugins/official/kubernetes/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..acae4b47d939ae67687878c000c5f6228e4bdadb GIT binary patch literal 210999 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 literal 0 HcmV?d00001 diff --git a/plugins/official/kubernetes/plugin.toml b/plugins/official/kubernetes/plugin.toml new file mode 100644 index 000000000..786904bc1 --- /dev/null +++ b/plugins/official/kubernetes/plugin.toml @@ -0,0 +1,44 @@ +id = "openidea.kubernetes" +name = "Kubernetes" +version = "0.1.0" +description = "Browse your Kubernetes cluster — namespaces, pods, deployments and services — follow pod logs and open a shell in a pod. Uses your kubectl and its configuration." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: cluster tree, pod logs and shells in the terminal, context and namespace settings." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-view:cluster", "on-command:*"] + +# kubectl reads ~/.kube/config (outside the project): that access is part of +# the `process:kubectl` grant; the plugin itself reads no files. +[capabilities] +required = ["process:kubectl"] + +[[contributes.views]] +id = "cluster" +title = "Kubernetes" +icon = "toolwindow-services" +location = "right" + +[[contributes.commands]] +id = "refresh" +title = "Kubernetes: Refresh" +category = "Kubernetes" + +[[contributes.commands]] +id = "logs" +title = "Kubernetes: Follow Logs of Selected Pod" +category = "Kubernetes" + +[[contributes.commands]] +id = "shell" +title = "Kubernetes: Open Shell in Selected Pod" +category = "Kubernetes" + +[[contributes.menus]] +menu = "tools" +command = "refresh" + +[contributes.settings] +title = "Kubernetes" +schema = "settings.schema.json" diff --git a/plugins/official/kubernetes/settings.schema.json b/plugins/official/kubernetes/settings.schema.json new file mode 100644 index 000000000..49d969126 --- /dev/null +++ b/plugins/official/kubernetes/settings.schema.json @@ -0,0 +1,27 @@ +{ + "properties": { + "context": { + "type": "string", + "title": "Context", + "description": "kubectl context to use; empty for the current one.", + "default": "" + }, + "namespaces": { + "type": "string", + "title": "Namespaces", + "description": "Comma-separated namespaces to show; empty for all.", + "default": "" + }, + "tailLines": { + "type": "integer", + "title": "Log lines to show first", + "default": 200 + }, + "shell": { + "type": "string", + "title": "Shell in pods", + "enum": ["sh", "bash"], + "default": "sh" + } + } +} diff --git a/plugins/official/kubernetes/src/lib.rs b/plugins/official/kubernetes/src/lib.rs new file mode 100644 index 000000000..9e41bf1b5 --- /dev/null +++ b/plugins/official/kubernetes/src/lib.rs @@ -0,0 +1,369 @@ +//! Kubernetes for OpenIDEA: a tool window with the cluster as a tree +//! (namespaces → pods, deployments, services), loaded level by level as you +//! expand it. Pod logs and shells open in the IDE's terminal (`kubectl logs +//! -f`, `kubectl exec -it`), which streams them; everything goes through the +//! user's own `kubectl` (pinned by `process:kubectl`) and its configuration. +//! In a remote project it is the remote machine's `kubectl`. + +use openidea_plugin::host; +use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Error, Invocation, Plugin, register_plugin}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +pub const VIEW: &str = "cluster"; +const KINDS: [(&str, &str, &str); 3] = + [("pods", "Pods", "pod"), ("deployments", "Deployments", "deployment"), ("services", "Services", "service")]; + +/// One listed object: its name and a short status. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Object { + pub name: String, + pub status: String, +} + +#[derive(Default)] +pub struct Kubernetes { + /// Namespaces, once listed. + namespaces: Option<Vec<String>>, + /// `namespace/kind` → objects, once listed. + objects: BTreeMap<String, Vec<Object>>, + expanded: BTreeSet<String>, + selected: Option<String>, + /// The last kubectl failure (shown instead of the tree). + error: Option<String>, +} + +fn kubectl(args: &[&str]) -> Result<Value, Error> { + let mut all: Vec<String> = Vec::new(); + if let Some(context) = host::setting_string("context").filter(|c| !c.is_empty()) { + all.push("--context".into()); + all.push(context); + } + all.extend(args.iter().map(|a| (*a).to_owned())); + all.extend(["-o".into(), "json".into()]); + let out = host::run_process("kubectl", &all, None)?; + if out.status != Some(0) { + let reason = out.stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or("kubectl failed"); + return Err(Error::Failed(reason.to_owned())); + } + serde_json::from_str(&out.stdout).map_err(|e| Error::Failed(format!("kubectl output: {e}"))) +} + +fn items(list: &Value) -> impl Iterator<Item = &Value> { + list.get("items").and_then(Value::as_array).into_iter().flatten() +} + +fn name(item: &Value) -> String { + item.pointer("/metadata/name").and_then(Value::as_str).unwrap_or("?").to_owned() +} + +/// `Running 2/2`, `CrashLoopBackOff`, `3/3 ready`, `ClusterIP 10.0.0.1`. +pub fn status_of(kind: &str, item: &Value) -> String { + let s = |p: &str| item.pointer(p).and_then(Value::as_str).unwrap_or("").to_owned(); + let n = |p: &str| item.pointer(p).and_then(Value::as_u64).unwrap_or(0); + match kind { + "pods" => { + let statuses = item.pointer("/status/containerStatuses").and_then(Value::as_array); + let waiting = + statuses.into_iter().flatten().find_map(|c| c.pointer("/state/waiting/reason").and_then(Value::as_str)); + let ready = statuses.into_iter().flatten().filter(|c| c.get("ready") == Some(&Value::Bool(true))).count(); + let total = statuses.map_or(0, Vec::len); + match waiting { + Some(reason) => reason.to_owned(), + None => format!("{} {ready}/{total}", s("/status/phase")), + } + } + "deployments" => format!("{}/{} ready", n("/status/readyReplicas"), n("/spec/replicas")), + "services" => format!("{} {}", s("/spec/type"), s("/spec/clusterIP")).trim().to_owned(), + _ => String::new(), + } +} + +impl Kubernetes { + fn namespaces(&mut self) -> Result<Vec<String>, Error> { + if let Some(ns) = &self.namespaces { + return Ok(ns.clone()); + } + let wanted: Vec<String> = host::setting_string("namespaces") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect(); + let ns = if wanted.is_empty() { + let mut all: Vec<String> = items(&kubectl(&["get", "namespaces"])?).map(name).collect(); + all.sort(); + all + } else { + wanted + }; + self.namespaces = Some(ns.clone()); + Ok(ns) + } + + fn objects(&mut self, namespace: &str, kind: &str) -> Result<Vec<Object>, Error> { + let key = format!("{namespace}/{kind}"); + if let Some(o) = self.objects.get(&key) { + return Ok(o.clone()); + } + let list = kubectl(&["get", kind, "-n", namespace])?; + let mut objects: Vec<Object> = + items(&list).map(|i| Object { name: name(i), status: status_of(kind, i) }).collect(); + objects.sort_by(|a, b| a.name.cmp(&b.name)); + self.objects.insert(key, objects.clone()); + Ok(objects) + } + + fn refresh(&mut self) { + self.namespaces = None; + self.objects.clear(); + self.error = None; + host::refresh_view(VIEW); + } + + /// `ns/pods/name` of the selected pod. + fn selected_pod(&self) -> Option<(String, String)> { + let key = self.selected.as_deref()?; + let mut parts = key.splitn(3, '/'); + let (ns, kind, name) = (parts.next()?, parts.next()?, parts.next()?); + (kind == "pods").then(|| (ns.to_owned(), name.to_owned())) + } + + fn open_logs(&self) -> Result<(), Error> { + let (ns, pod) = self.selected_pod().ok_or_else(|| Error::Invalid("select a pod first".into()))?; + let tail = host::setting_number("tailLines").map_or(200, |n| n.max(0.0) as u64); + let mut args = self.context_args(); + args.extend(["logs".into(), "-f".into(), format!("--tail={tail}"), "-n".into(), ns, pod.clone()]); + host::open_terminal(&format!("logs: {pod}"), "kubectl", &args) + } + + fn open_shell(&self) -> Result<(), Error> { + let (ns, pod) = self.selected_pod().ok_or_else(|| Error::Invalid("select a pod first".into()))?; + let shell = host::setting_string("shell").unwrap_or_else(|| "sh".into()); + let mut args = self.context_args(); + args.extend(["exec".into(), "-it".into(), "-n".into(), ns, pod.clone(), "--".into(), shell]); + host::open_terminal(&format!("shell: {pod}"), "kubectl", &args) + } + + fn context_args(&self) -> Vec<String> { + match host::setting_string("context").filter(|c| !c.is_empty()) { + Some(c) => vec!["--context".into(), c], + None => Vec::new(), + } + } + + fn tree(&mut self) -> Result<Vec<TreeItem>, Error> { + let mut out = Vec::new(); + for ns in self.namespaces()? { + let open = self.expanded.contains(&ns); + out.push(TreeItem::new(&ns, &ns).icon("folder").expandable(open)); + if !open { + continue; + } + for (kind, label, icon) in KINDS { + let key = format!("{ns}/{kind}"); + let open_kind = self.expanded.contains(&key); + let mut group = TreeItem::new(&key, label).parent(&ns).icon("folder").expandable(open_kind); + if open_kind { + let objects = self.objects(&ns, kind)?; + group = group.detail(&objects.len().to_string()); + out.push(group); + for o in objects { + out.push( + TreeItem::new(&format!("{key}/{}", o.name), &o.name) + .parent(&key) + .icon(icon) + .detail(&o.status), + ); + } + } else { + out.push(group); + } + } + } + Ok(out) + } +} + +impl Plugin for Kubernetes { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + match id { + "refresh" => self.refresh(), + "logs" => self.open_logs()?, + "shell" => self.open_shell()?, + _ => return Err(format!("unknown command {id}")), + } + Ok(None) + } +} + +impl Views for Kubernetes { + fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + let pod = self.selected_pod().is_some(); + let toolbar = |v: View| { + v.button(Button::new("refresh", "Refresh").icon("refresh")) + .button(Button::new("logs", "Follow Logs").icon("file-log").enabled(pod)) + .button(Button::new("shell", "Open Shell").icon("terminal").enabled(pod)) + }; + if let Some(e) = &self.error { + return Ok(toolbar(View::message(&format!("kubectl: {e}")))); + } + match self.tree() { + Ok(items) => { + let context = host::setting_string("context") + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| "current context".into()); + let namespaces = self.namespaces.as_ref().map_or(0, Vec::len); + Ok(toolbar(View::new(Body::Tree(items))).status(&format!("{context} · {namespaces} namespaces"))) + } + Err(Error::Denied(cap)) => { + Ok(toolbar(View::message(&format!("Allow {cap} in Settings › Plugins to browse the cluster.")))) + } + Err(e) => { + self.error = Some(e.to_string()); + Ok(toolbar(View::message(&format!("kubectl: {e}")))) + } + } + } + + fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + match event { + ViewEvent::Expand(key) => { + self.expanded.insert(key); + } + ViewEvent::Collapse(key) => { + self.expanded.remove(&key); + } + ViewEvent::Select(key) => { + self.selected = Some(key); + } + ViewEvent::Activate(key) => { + self.selected = Some(key); + return if self.selected_pod().is_some() { self.open_logs() } else { Ok(()) }; + } + ViewEvent::Button(b) => match b.as_str() { + "refresh" => { + self.refresh(); + return Ok(()); + } + "logs" => return self.open_logs(), + "shell" => return self.open_shell(), + _ => return Ok(()), + }, + _ => return Ok(()), + } + host::refresh_view(VIEW); + Ok(()) + } +} + +register_plugin!(Kubernetes, views); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::ProcessOutput; + use openidea_plugin::mock::MockHost; + + fn ok(stdout: &str) -> Result<ProcessOutput, Error> { + Ok(ProcessOutput { status: Some(0), stdout: stdout.into(), stderr: String::new() }) + } + + fn cluster(host: &MockHost) { + host.on_process("kubectl", |args, _| { + let a: Vec<&str> = args.iter().map(String::as_str).collect(); + match a.as_slice() { + ["get", "namespaces", ..] => ok(r#"{"items":[{"metadata":{"name":"kube-system"}},{"metadata":{"name":"default"}}]}"#), + ["get", "pods", "-n", "default", ..] => ok(r#"{"items":[ + {"metadata":{"name":"web-1"},"status":{"phase":"Running","containerStatuses":[{"ready":true},{"ready":false}]}}, + {"metadata":{"name":"job-1"},"status":{"phase":"Pending","containerStatuses":[{"ready":false,"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}} + ]}"#), + ["get", kind, "-n", _, ..] if *kind == "deployments" || *kind == "services" => ok(r#"{"items":[]}"#), + other => panic!("unexpected kubectl {other:?}"), + } + }); + } + + fn tree(p: &mut Kubernetes, host: &MockHost) -> Vec<TreeItem> { + match p.render(VIEW, None, &host.invocation()).unwrap().body { + Body::Tree(items) => items, + other => panic!("{other:?}"), + } + } + + #[test] + fn lists_namespaces_then_objects_when_expanded() { + let host = MockHost::install().with_capabilities(&["process:kubectl"]); + cluster(&host); + let mut p = Kubernetes::default(); + let labels = |items: &[TreeItem]| items.iter().map(|i| i.label.clone()).collect::<Vec<_>>(); + assert_eq!(labels(&tree(&mut p, &host)), ["default", "kube-system"]); + p.handle(VIEW, ViewEvent::Expand("default".into()), &host.invocation()).unwrap(); + p.handle(VIEW, ViewEvent::Expand("default/pods".into()), &host.invocation()).unwrap(); + let items = tree(&mut p, &host); + let pods: Vec<(String, Option<String>)> = items + .iter() + .filter(|i| i.parent.as_deref() == Some("default/pods")) + .map(|i| (i.label.clone(), i.detail.clone())) + .collect(); + assert_eq!( + pods, + [("job-1".into(), Some("CrashLoopBackOff".into())), ("web-1".into(), Some("Running 1/2".into()))] + ); + // Cached until Refresh. + let runs = host.process_runs().len(); + tree(&mut p, &host); + assert_eq!(host.process_runs().len(), runs); + p.handle(VIEW, ViewEvent::Button("refresh".into()), &host.invocation()).unwrap(); + tree(&mut p, &host); + assert!(host.process_runs().len() > runs); + } + + #[test] + fn logs_and_shells_open_in_the_terminal() { + let host = MockHost::install().with_capabilities(&["process:kubectl"]); + cluster(&host); + host.set_setting("context", "\"prod\""); + host.set_setting("tailLines", "50"); + let mut p = Kubernetes::default(); + p.handle(VIEW, ViewEvent::Activate("default/pods/web-1".into()), &host.invocation()).unwrap(); + p.handle(VIEW, ViewEvent::Button("shell".into()), &host.invocation()).unwrap(); + let t = host.terminals(); + assert_eq!(t[0].title, "logs: web-1"); + assert_eq!(t[0].args, ["--context", "prod", "logs", "-f", "--tail=50", "-n", "default", "web-1"]); + assert_eq!(t[1].args, ["--context", "prod", "exec", "-it", "-n", "default", "web-1", "--", "sh"]); + // A namespace is not a pod. + p.handle(VIEW, ViewEvent::Select("default".into()), &host.invocation()).unwrap(); + assert!(p.run_command("logs", &host.invocation(), None).is_err()); + } + + #[test] + fn without_kubectl_the_view_explains_what_is_missing() { + let host = MockHost::install().with_capabilities(&[]); + let mut p = Kubernetes::default(); + let view = p.render(VIEW, None, &host.invocation()).unwrap(); + assert_eq!( + view.body, + Body::Message("Allow process:kubectl in Settings › Plugins to browse the cluster.".into()) + ); + + let host = MockHost::install().with_capabilities(&["process:kubectl"]); + host.on_process("kubectl", |_, _| { + Ok(ProcessOutput { + status: Some(1), + stdout: String::new(), + stderr: "The connection to the server was refused".into(), + }) + }); + let mut p = Kubernetes::default(); + let view = p.render(VIEW, None, &host.invocation()).unwrap(); + assert_eq!(view.body, Body::Message("kubectl: The connection to the server was refused".into())); + } +} diff --git a/plugins/official/material-icons/generate.py b/plugins/official/material-icons/generate.py new file mode 100644 index 000000000..d1f024ed5 --- /dev/null +++ b/plugins/official/material-icons/generate.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Generates icons/*.svg and icons/theme.json of the Material Icons plugin. + +Flat, original icons on the Material colour palette: language files are +coloured badges with a short label, well-known files get glyphs, folders are +tinted by role. Edit the tables below and run `python3 generate.py`, then +`openidea-plugin check .`. Not part of the package (plugin.toml does not +reference it), so it is not installed. +""" + +import json +import pathlib + +HERE = pathlib.Path(__file__).resolve().parent +OUT = HERE / "icons" +FONT = "system-ui, -apple-system, Segoe UI, Roboto, sans-serif" + + +def svg(body: str) -> str: + return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">{body}</svg>\n' + + +def badge(color: str, label: str, fg: str = "#fff") -> str: + size = {1: 9.5, 2: 7.4, 3: 5.6}[len(label)] + y = 8 + size * 0.36 + label = label.replace("&", "&").replace("<", "<").replace(">", ">") + return svg( + f'<rect x="1" y="1" width="14" height="14" rx="3" fill="{color}"/>' + f'<text x="8" y="{y:.2f}" fill="{fg}" font-family="{FONT}" font-size="{size}" ' + f'font-weight="700" text-anchor="middle" letter-spacing="-0.2">{label}</text>' + ) + + +def doc(color: str, inner: str = "") -> str: + return svg( + f'<path fill="{color}" d="M4 1h5.6L13 4.4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>' + f'<path fill="#fff" fill-opacity=".45" d="M9.6 1v3.4H13z"/>{inner}' + ) + + +def folder(color: str, open_: bool = False) -> str: + if open_: + return svg( + f'<path fill="{color}" d="M1.5 2.5h4.6L7.6 4h6.4a1 1 0 0 1 1 1v1.5H4.2L2 13.5H1.5a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/>' + f'<path fill="{color}" fill-opacity=".75" d="M4.2 7h11.3l-2.2 6.5H2z"/>' + ) + return svg( + f'<path fill="{color}" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/>' + ) + + +def stroke(d: str, color: str = "#fff", width: float = 1.4) -> str: + return ( + f'<path d="{d}" fill="none" stroke="{color}" stroke-width="{width}" ' + f'stroke-linecap="round" stroke-linejoin="round"/>' + ) + + +def gear(color: str) -> str: + teeth = "".join( + f'<rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate({a} 8 8)"/>' + for a in range(0, 360, 45) + ) + return svg(f'<g fill="{color}">{teeth}</g><circle cx="8" cy="8" r="3.6" fill="none" stroke="{color}" stroke-width="2.8"/>') + + +def atom(color: str) -> str: + rings = "".join( + f'<ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate({a} 8 8)"/>' for a in (0, 60, 120) + ) + return svg(f'<g fill="none" stroke="{color}" stroke-width="1.1">{rings}</g><circle cx="8" cy="8" r="1.4" fill="{color}"/>') + + +def package(color: str) -> str: + return svg( + f'<path fill="{color}" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/>' + '<path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/>' + '<path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/>' + ) + + +GLYPHS = { + "file": doc("#90a4ae"), + "text": doc("#90a4ae", stroke("M5 7.5h6M5 10h6M5 12.5h4", width=1.1)), + "markdown": svg( + '<rect x="0.5" y="3" width="15" height="10" rx="1.6" fill="#42a5f5"/>' + + stroke("M3 10.5v-5l2 2.5 2-2.5v5", width=1.3) + + stroke("M11 5.5v5M9.2 8.8l1.8 1.8 1.8-1.8", width=1.3) + ), + "image": svg( + '<rect x="1" y="2.5" width="14" height="11" rx="1.5" fill="#26a69a"/>' + '<circle cx="5" cy="6" r="1.5" fill="#fff"/>' + '<path fill="#fff" d="M2.5 12l3.5-4 2.5 2.6L11 7.4l3 4.6z"/>' + ), + "archive": svg( + '<rect x="2" y="1" width="12" height="14" rx="1.5" fill="#af8e5b"/>' + '<path fill="#fff" d="M7 1h2v1.6H7zm0 3.2h2v1.6H7zM6.4 7.6h3.2v4.4H6.4z"/>' + '<rect x="7.2" y="9.8" width="1.6" height="1.2" fill="#af8e5b"/>' + ), + "lock": svg( + '<rect x="3" y="7" width="10" height="8" rx="1.5" fill="#90a4ae"/>' + + stroke("M5.5 7V5a2.5 2.5 0 0 1 5 0v2", "#90a4ae", 1.8) + + '<circle cx="8" cy="11" r="1.2" fill="#fff"/>' + ), + "git": svg( + '<path fill="#f4511e" d="M8 .6l7.4 7.4L8 15.4.6 8z"/>' + + stroke("M6.3 5v6M6.3 6.4c0 1.8 3.7.4 3.7 2.1", width=1.1) + + '<g fill="#fff"><circle cx="6.3" cy="5" r="1.1"/><circle cx="6.3" cy="11" r="1.1"/><circle cx="10" cy="8.6" r="1.1"/></g>' + ), + "settings": gear("#78909c"), + "env": gear("#fbc02d"), + "readme": svg( + '<circle cx="8" cy="8" r="7" fill="#42a5f5"/>' + '<rect x="7.1" y="6.8" width="1.8" height="5.2" rx=".6" fill="#fff"/><circle cx="8" cy="4.6" r="1.1" fill="#fff"/>' + ), + "license": svg( + '<rect x="1.5" y="2" width="13" height="9.5" rx="1" fill="#ffca28"/>' + + stroke("M3.6 4.6h6.4M3.6 6.8h4", "#fff", 1.1) + + '<path fill="#ef6c00" d="M9.6 11.4l-.7 3.4 2.1-1.1 2.1 1.1-.7-3.4z"/><circle cx="11" cy="10" r="2.3" fill="#ef6c00"/>' + ), + "shell": svg( + '<rect x="1" y="2" width="14" height="12" rx="2" fill="#4caf50"/>' + + stroke("M4 5.5l2.5 2.5L4 10.5", width=1.5) + + stroke("M8 10.8h4", width=1.5) + ), + "database": svg( + '<path fill="#ffca28" d="M2.5 3.5v9c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2v-9z"/>' + '<ellipse cx="8" cy="3.5" rx="5.5" ry="2" fill="#ffe082"/>' + + stroke("M2.5 7.5c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2", "#ffe082", 0.9) + ), + "html": svg('<rect x="1" y="1" width="14" height="14" rx="3" fill="#e44d26"/>' + stroke("M6 5L3.5 8 6 11M10 5l2.5 3L10 11")), + "xml": svg('<rect x="1" y="1" width="14" height="14" rx="3" fill="#ff9800"/>' + stroke("M5.5 5L3 8l2.5 3M10.5 5L13 8l-2.5 3M9 4.5l-2 7", width=1.2)), + "json": badge("#fbc02d", "{}", "#5d4037"), + "react": atom("#00bcd4"), + "react-ts": atom("#0288d1"), + "docker": svg( + '<rect x="1" y="1" width="14" height="14" rx="3" fill="#0288d1"/>' + '<g fill="#fff"><rect x="3" y="7" width="2.2" height="2"/><rect x="5.6" y="7" width="2.2" height="2"/>' + '<rect x="8.2" y="7" width="2.2" height="2"/><rect x="5.6" y="4.6" width="2.2" height="2"/>' + '<rect x="8.2" y="4.6" width="2.2" height="2"/></g>' + '<path fill="#fff" d="M2.6 9.6h10.6c.4-.8 1-1.2 1.6-1.2-.3 3-2.6 4.6-6.4 4.6-3.2 0-5.2-1.2-5.8-3.4z"/>' + ), + "npm": package("#cb3837"), + "cargo": package("#ff7043"), + "gomod": package("#00acc1"), + "pnpm": package("#f9ad00"), + "test": svg( + '<rect x="1" y="1" width="14" height="14" rx="3" fill="#8bc34a"/>' + stroke("M4.2 8.2l2.4 2.4 5.2-5.2", width=1.7) + ), + "vite": badge("#9575cd", "V"), + "tsconfig": badge("#0288d1", "TS", "#bbdefb"), + "makefile": badge("#ef5350", "M"), + "editorconfig": gear("#90a4ae"), + "font": badge("#ef5350", "Aa"), + "pdf": badge("#e53935", "PDF"), + "svg": badge("#ffb300", "SVG", "#4e342e"), + "audio": svg('<rect x="1" y="1" width="14" height="14" rx="3" fill="#ec407a"/><path fill="#fff" d="M6 4.5l6-1.5v7.3a1.8 1.8 0 1 1-1.2-1.7V5.4L7.2 6.3v5.4a1.8 1.8 0 1 1-1.2-1.7z"/>'), + "video": svg('<rect x="1" y="2.5" width="14" height="11" rx="2" fill="#ff7043"/><path fill="#fff" d="M6.4 5.5v5l4-2.5z"/>'), +} + +# extension → (icon id, colour, label[, label colour]) +LANGUAGES = { + "rust": ("#ff7043", "RS", ["rs"]), + "typescript": ("#0288d1", "TS", ["ts", "mts", "cts"]), + "typescript-def": ("#0277bd", "D", ["d.ts", "d.mts", "d.cts"]), + "javascript": ("#fbc02d", "JS", ["js", "mjs", "cjs"], "#3e2723"), + "python": ("#3572a5", "PY", ["py", "pyi", "pyw"], "#ffd54f"), + "go": ("#00acc1", "GO", ["go"]), + "java": ("#e53935", "J", ["java"]), + "class": ("#8d6e63", "CL", ["class", "jar"]), + "kotlin": ("#7e57c2", "KT", ["kt", "kts"]), + "groovy": ("#4db6ac", "GR", ["groovy", "gradle"]), + "scala": ("#d32f2f", "SC", ["scala", "sc", "sbt"]), + "c": ("#5c6bc0", "C", ["c"]), + "cpp": ("#1e88e5", "C++", ["cpp", "cc", "cxx", "c++"]), + "header": ("#9575cd", "H", ["h", "hpp", "hh", "hxx", "inl"]), + "csharp": ("#388e3c", "C#", ["cs", "csx"]), + "fsharp": ("#378bba", "F#", ["fs", "fsi", "fsx"]), + "swift": ("#ff5722", "SW", ["swift"]), + "objc": ("#607d8b", "OC", ["m", "mm"]), + "ruby": ("#c62828", "RB", ["rb", "erb", "rake", "gemspec"]), + "php": ("#7986cb", "PHP", ["php"]), + "perl": ("#5c6bc0", "PL", ["pl", "pm"]), + "lua": ("#3949ab", "LUA", ["lua"]), + "r": ("#1976d2", "R", ["r", "rmd"]), + "julia": ("#9558b2", "JL", ["jl"]), + "dart": ("#40c4ff", "D", ["dart"], "#01579b"), + "elixir": ("#7e57c2", "EX", ["ex", "exs", "heex"]), + "erlang": ("#c2185b", "ER", ["erl", "hrl"]), + "haskell": ("#7e57c2", "HS", ["hs", "lhs"]), + "ocaml": ("#ef6c00", "ML", ["ml", "mli"]), + "clojure": ("#64dd17", "CLJ", ["clj", "cljs", "cljc", "edn"], "#1b5e20"), + "zig": ("#f9a825", "Z", ["zig", "zon"], "#3e2723"), + "nim": ("#ffc107", "NIM", ["nim"], "#3e2723"), + "solidity": ("#546e7a", "SOL", ["sol"]), + "vue": ("#41b883", "V", ["vue"]), + "svelte": ("#ff3e00", "S", ["svelte"]), + "astro": ("#ff5d01", "A", ["astro"]), + "css": ("#42a5f5", "#", ["css"]), + "sass": ("#ec407a", "#", ["scss", "sass"]), + "less": ("#1565c0", "LS", ["less"]), + "yaml": ("#e53935", "YML", ["yaml", "yml"]), + "toml": ("#8d6e63", "TML", ["toml"]), + "ini": ("#78909c", "INI", ["ini", "cfg", "conf", "properties"]), + "csv": ("#43a047", "CSV", ["csv", "tsv"]), + "graphql": ("#e535ab", "GQ", ["graphql", "gql"]), + "proto": ("#26a69a", "PB", ["proto"]), + "wasm": ("#654ff0", "WA", ["wasm", "wat"]), + "nix": ("#5277c3", "NIX", ["nix"]), + "terraform": ("#7b42bc", "TF", ["tf", "tfvars", "hcl"]), + "powershell": ("#0277bd", "PS", ["ps1", "psm1", "psd1"]), + "batch": ("#546e7a", "BAT", ["bat", "cmd"]), + "tex": ("#008080", "TEX", ["tex", "sty", "bib"]), + "vim": ("#43a047", "VI", ["vim"]), + "diff": ("#66bb6a", "+-", ["diff", "patch"]), + "log": ("#78909c", "LOG", ["log"]), + "notebook": ("#f57c00", "NB", ["ipynb"]), + "http": ("#3f51b5", "API", ["http", "rest"]), +} + +GLYPH_EXTENSIONS = { + "text": ["txt", "text"], + "markdown": ["md", "markdown", "mdx"], + "image": ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "avif", "tif", "tiff", "icns"], + "svg": ["svg"], + "pdf": ["pdf"], + "archive": ["zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar", "zst"], + "lock": ["lock"], + "shell": ["sh", "bash", "zsh", "fish"], + "database": ["sql", "sqlite", "db", "prisma"], + "html": ["html", "htm", "xhtml"], + "xml": ["xml", "xsd", "xsl", "plist", "iml"], + "json": ["json", "jsonc", "json5"], + "react": ["jsx"], + "react-ts": ["tsx"], + "test": [f"{kind}.{ext}" for kind in ("test", "spec") for ext in ("ts", "tsx", "js", "jsx", "mjs", "mts")] + + ["test.rs", "test.py"], + "font": ["ttf", "otf", "woff", "woff2"], + "audio": ["mp3", "wav", "ogg", "flac", "m4a"], + "video": ["mp4", "mov", "webm", "mkv", "avi"], + "env": ["env"], +} + +FILE_NAMES = { + "readme": ["readme", "readme.md", "readme.txt", "readme.rst"], + "license": ["license", "licence", "license.md", "licence.md", "license.txt", "copying", "notice"], + "git": [".gitignore", ".gitattributes", ".gitmodules", ".gitkeep", ".mailmap"], + "docker": ["dockerfile", "containerfile", ".dockerignore", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"], + "npm": ["package.json", ".npmrc", ".npmignore"], + "cargo": ["cargo.toml", "rust-toolchain", "rust-toolchain.toml"], + "gomod": ["go.mod", "go.sum", "go.work"], + "pnpm": ["pnpm-workspace.yaml"], + "lock": ["cargo.lock", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "poetry.lock", "uv.lock", "gemfile.lock", "composer.lock"], + "env": [".env", ".env.local", ".env.development", ".env.production", ".env.example"], + "tsconfig": ["tsconfig.json", "jsconfig.json", "tsconfig.base.json"], + "vite": ["vite.config.ts", "vite.config.js", "vite.config.mts", "vitest.config.ts", "vitest.config.mts"], + "makefile": ["makefile", "gnumakefile", "justfile", "cmakelists.txt"], + "editorconfig": [".editorconfig", ".prettierrc", ".prettierignore", ".oxlintrc.json", ".eslintrc", ".eslintrc.json", "mise.toml"], + "settings": [".clippy.toml", "clippy.toml", "rustfmt.toml", ".rustfmt.toml", "deny.toml"], +} + +# folder name → (icon id, colour) +FOLDERS = { + "folder-src": ("#4caf50", ["src", "source", "sources", "lib", "libs", "pkg", "internal", "crates", "packages"]), + "folder-test": ("#8bc34a", ["test", "tests", "__tests__", "spec", "specs", "e2e", "fixtures", "testdata"]), + "folder-docs": ("#42a5f5", ["doc", "docs", "documentation", "wiki"]), + "folder-dist": ("#e57373", ["dist", "build", "out", "target", "bin", "obj", "release", "coverage"]), + "folder-deps": ("#9ccc65", ["node_modules", "vendor", ".venv", "venv", "third_party", "deps"]), + "folder-git": ("#ff7043", [".git", ".github", ".gitlab", ".husky"]), + "folder-config": ("#78909c", ["config", "configs", ".config", ".vscode", ".idea", ".cargo", "settings", "etc"]), + "folder-assets": ("#26a69a", ["assets", "static", "public", "images", "img", "icons", "fonts", "media", "resources", "res"]), + "folder-scripts": ("#ffb74d", ["scripts", "script", "tools", "tasks", "ci"]), + "folder-components": ("#ffca28", ["components", "ui", "widgets", "views", "pages", "features"]), + "folder-styles": ("#ec407a", ["styles", "css", "scss", "theme", "themes"]), + "folder-plugins": ("#ab47bc", ["plugins", "extensions", "addons", "sdk"]), + "folder-i18n": ("#5c6bc0", ["i18n", "locales", "locale", "lang", "translations"]), + "folder-api": ("#26c6da", ["api", "server", "routes", "handlers", "rpc"]), + "folder-db": ("#ffa726", ["db", "database", "migrations", "sql", "schema"]), +} + + +def main() -> None: + OUT.mkdir(exist_ok=True) + for old in OUT.glob("*.svg"): + old.unlink() + icons: dict[str, str] = {} + + def add(name: str, content: str) -> None: + assert name not in icons, f"duplicate icon {name}" + icons[name] = content + (OUT / f"{name}.svg").write_text(content) + + for name, content in GLYPHS.items(): + add(name, content) + add("folder", folder("#90a4ae")) + add("folder-open", folder("#90a4ae", open_=True)) + + extensions: dict[str, str] = {} + + def map_ext(ext: str, icon: str) -> None: + assert ext not in extensions, f"extension {ext} mapped twice" + extensions[ext] = icon + + for name, (color, label, exts, *fg) in LANGUAGES.items(): + add(name, badge(color, label, *fg)) + for ext in exts: + map_ext(ext, name) + for icon, exts in GLYPH_EXTENSIONS.items(): + for ext in exts: + map_ext(ext, icon) + + file_names = {n: icon for icon, names in FILE_NAMES.items() for n in names} + folder_names: dict[str, str] = {} + for icon, (color, names) in FOLDERS.items(): + add(icon, folder(color)) + for n in names: + assert n not in folder_names, f"folder {n} mapped twice" + folder_names[n] = icon + + for mapping in (extensions, file_names, folder_names): + for key, icon in mapping.items(): + assert icon in icons, f"{key} → unknown icon {icon}" + + theme = { + "iconDefinitions": {name: f"icons/{name}.svg" for name in sorted(icons)}, + "file": "file", + "folder": "folder", + "folderExpanded": "folder-open", + "fileExtensions": dict(sorted(extensions.items())), + "fileNames": dict(sorted(file_names.items())), + "folderNames": dict(sorted(folder_names.items())), + } + (OUT / "theme.json").write_text(json.dumps(theme, indent=2) + "\n") + print(f"{len(icons)} icons, {len(extensions)} extensions, {len(file_names)} file names, {len(folder_names)} folders") + + +if __name__ == "__main__": + main() diff --git a/plugins/official/material-icons/icons/archive.svg b/plugins/official/material-icons/icons/archive.svg new file mode 100644 index 000000000..871bd0649 --- /dev/null +++ b/plugins/official/material-icons/icons/archive.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="2" y="1" width="12" height="14" rx="1.5" fill="#af8e5b"/><path fill="#fff" d="M7 1h2v1.6H7zm0 3.2h2v1.6H7zM6.4 7.6h3.2v4.4H6.4z"/><rect x="7.2" y="9.8" width="1.6" height="1.2" fill="#af8e5b"/></svg> diff --git a/plugins/official/material-icons/icons/astro.svg b/plugins/official/material-icons/icons/astro.svg new file mode 100644 index 000000000..7bdb6e1e2 --- /dev/null +++ b/plugins/official/material-icons/icons/astro.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff5d01"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">A</text></svg> diff --git a/plugins/official/material-icons/icons/audio.svg b/plugins/official/material-icons/icons/audio.svg new file mode 100644 index 000000000..515294d82 --- /dev/null +++ b/plugins/official/material-icons/icons/audio.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ec407a"/><path fill="#fff" d="M6 4.5l6-1.5v7.3a1.8 1.8 0 1 1-1.2-1.7V5.4L7.2 6.3v5.4a1.8 1.8 0 1 1-1.2-1.7z"/></svg> diff --git a/plugins/official/material-icons/icons/batch.svg b/plugins/official/material-icons/icons/batch.svg new file mode 100644 index 000000000..6d1779b51 --- /dev/null +++ b/plugins/official/material-icons/icons/batch.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#546e7a"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">BAT</text></svg> diff --git a/plugins/official/material-icons/icons/c.svg b/plugins/official/material-icons/icons/c.svg new file mode 100644 index 000000000..408c3ee98 --- /dev/null +++ b/plugins/official/material-icons/icons/c.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#5c6bc0"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">C</text></svg> diff --git a/plugins/official/material-icons/icons/cargo.svg b/plugins/official/material-icons/icons/cargo.svg new file mode 100644 index 000000000..47b71a700 --- /dev/null +++ b/plugins/official/material-icons/icons/cargo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/><path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/><path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/></svg> diff --git a/plugins/official/material-icons/icons/class.svg b/plugins/official/material-icons/icons/class.svg new file mode 100644 index 000000000..f0ac74afd --- /dev/null +++ b/plugins/official/material-icons/icons/class.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#8d6e63"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">CL</text></svg> diff --git a/plugins/official/material-icons/icons/clojure.svg b/plugins/official/material-icons/icons/clojure.svg new file mode 100644 index 000000000..28c9b47e2 --- /dev/null +++ b/plugins/official/material-icons/icons/clojure.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#64dd17"/><text x="8" y="10.02" fill="#1b5e20" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">CLJ</text></svg> diff --git a/plugins/official/material-icons/icons/cpp.svg b/plugins/official/material-icons/icons/cpp.svg new file mode 100644 index 000000000..1a7fd7436 --- /dev/null +++ b/plugins/official/material-icons/icons/cpp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#1e88e5"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">C++</text></svg> diff --git a/plugins/official/material-icons/icons/csharp.svg b/plugins/official/material-icons/icons/csharp.svg new file mode 100644 index 000000000..0559e5080 --- /dev/null +++ b/plugins/official/material-icons/icons/csharp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#388e3c"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">C#</text></svg> diff --git a/plugins/official/material-icons/icons/css.svg b/plugins/official/material-icons/icons/css.svg new file mode 100644 index 000000000..deb851eed --- /dev/null +++ b/plugins/official/material-icons/icons/css.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#42a5f5"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">#</text></svg> diff --git a/plugins/official/material-icons/icons/csv.svg b/plugins/official/material-icons/icons/csv.svg new file mode 100644 index 000000000..be856f62a --- /dev/null +++ b/plugins/official/material-icons/icons/csv.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#43a047"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">CSV</text></svg> diff --git a/plugins/official/material-icons/icons/dart.svg b/plugins/official/material-icons/icons/dart.svg new file mode 100644 index 000000000..e6b1b6d11 --- /dev/null +++ b/plugins/official/material-icons/icons/dart.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#40c4ff"/><text x="8" y="11.42" fill="#01579b" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">D</text></svg> diff --git a/plugins/official/material-icons/icons/database.svg b/plugins/official/material-icons/icons/database.svg new file mode 100644 index 000000000..6b2e64bdb --- /dev/null +++ b/plugins/official/material-icons/icons/database.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2.5 3.5v9c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2v-9z"/><ellipse cx="8" cy="3.5" rx="5.5" ry="2" fill="#ffe082"/><path d="M2.5 7.5c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2" fill="none" stroke="#ffe082" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/diff.svg b/plugins/official/material-icons/icons/diff.svg new file mode 100644 index 000000000..387aeaa6c --- /dev/null +++ b/plugins/official/material-icons/icons/diff.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#66bb6a"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">+-</text></svg> diff --git a/plugins/official/material-icons/icons/docker.svg b/plugins/official/material-icons/icons/docker.svg new file mode 100644 index 000000000..a5d9eeb42 --- /dev/null +++ b/plugins/official/material-icons/icons/docker.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0288d1"/><g fill="#fff"><rect x="3" y="7" width="2.2" height="2"/><rect x="5.6" y="7" width="2.2" height="2"/><rect x="8.2" y="7" width="2.2" height="2"/><rect x="5.6" y="4.6" width="2.2" height="2"/><rect x="8.2" y="4.6" width="2.2" height="2"/></g><path fill="#fff" d="M2.6 9.6h10.6c.4-.8 1-1.2 1.6-1.2-.3 3-2.6 4.6-6.4 4.6-3.2 0-5.2-1.2-5.8-3.4z"/></svg> diff --git a/plugins/official/material-icons/icons/editorconfig.svg b/plugins/official/material-icons/icons/editorconfig.svg new file mode 100644 index 000000000..ddbdbebac --- /dev/null +++ b/plugins/official/material-icons/icons/editorconfig.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="#90a4ae"><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(0 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(45 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(90 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(135 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(180 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(225 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(270 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(315 8 8)"/></g><circle cx="8" cy="8" r="3.6" fill="none" stroke="#90a4ae" stroke-width="2.8"/></svg> diff --git a/plugins/official/material-icons/icons/elixir.svg b/plugins/official/material-icons/icons/elixir.svg new file mode 100644 index 000000000..3e13bbf97 --- /dev/null +++ b/plugins/official/material-icons/icons/elixir.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7e57c2"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">EX</text></svg> diff --git a/plugins/official/material-icons/icons/env.svg b/plugins/official/material-icons/icons/env.svg new file mode 100644 index 000000000..cde14e40e --- /dev/null +++ b/plugins/official/material-icons/icons/env.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="#fbc02d"><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(0 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(45 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(90 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(135 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(180 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(225 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(270 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(315 8 8)"/></g><circle cx="8" cy="8" r="3.6" fill="none" stroke="#fbc02d" stroke-width="2.8"/></svg> diff --git a/plugins/official/material-icons/icons/erlang.svg b/plugins/official/material-icons/icons/erlang.svg new file mode 100644 index 000000000..50e2c0a05 --- /dev/null +++ b/plugins/official/material-icons/icons/erlang.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#c2185b"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">ER</text></svg> diff --git a/plugins/official/material-icons/icons/file.svg b/plugins/official/material-icons/icons/file.svg new file mode 100644 index 000000000..7a89750ac --- /dev/null +++ b/plugins/official/material-icons/icons/file.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1h5.6L13 4.4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/><path fill="#fff" fill-opacity=".45" d="M9.6 1v3.4H13z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-api.svg b/plugins/official/material-icons/icons/folder-api.svg new file mode 100644 index 000000000..c24ee8939 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-api.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26c6da" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-assets.svg b/plugins/official/material-icons/icons/folder-assets.svg new file mode 100644 index 000000000..6230bf1f7 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-assets.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-components.svg b/plugins/official/material-icons/icons/folder-components.svg new file mode 100644 index 000000000..9b63a7f45 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-components.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-config.svg b/plugins/official/material-icons/icons/folder-config.svg new file mode 100644 index 000000000..41d451d9b --- /dev/null +++ b/plugins/official/material-icons/icons/folder-config.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#78909c" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-db.svg b/plugins/official/material-icons/icons/folder-db.svg new file mode 100644 index 000000000..ccaab633b --- /dev/null +++ b/plugins/official/material-icons/icons/folder-db.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffa726" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-deps.svg b/plugins/official/material-icons/icons/folder-deps.svg new file mode 100644 index 000000000..0ec809976 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-deps.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#9ccc65" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-dist.svg b/plugins/official/material-icons/icons/folder-dist.svg new file mode 100644 index 000000000..69f3fb5be --- /dev/null +++ b/plugins/official/material-icons/icons/folder-dist.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#e57373" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-docs.svg b/plugins/official/material-icons/icons/folder-docs.svg new file mode 100644 index 000000000..f3a28601f --- /dev/null +++ b/plugins/official/material-icons/icons/folder-docs.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#42a5f5" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-git.svg b/plugins/official/material-icons/icons/folder-git.svg new file mode 100644 index 000000000..ba7bd3942 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-git.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-i18n.svg b/plugins/official/material-icons/icons/folder-i18n.svg new file mode 100644 index 000000000..6118c98e3 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-i18n.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#5c6bc0" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-open.svg b/plugins/official/material-icons/icons/folder-open.svg new file mode 100644 index 000000000..0accab518 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-open.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 2.5h4.6L7.6 4h6.4a1 1 0 0 1 1 1v1.5H4.2L2 13.5H1.5a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path fill="#90a4ae" fill-opacity=".75" d="M4.2 7h11.3l-2.2 6.5H2z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-plugins.svg b/plugins/official/material-icons/icons/folder-plugins.svg new file mode 100644 index 000000000..c75c82fde --- /dev/null +++ b/plugins/official/material-icons/icons/folder-plugins.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ab47bc" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-scripts.svg b/plugins/official/material-icons/icons/folder-scripts.svg new file mode 100644 index 000000000..e8a7cc798 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-scripts.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffb74d" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-src.svg b/plugins/official/material-icons/icons/folder-src.svg new file mode 100644 index 000000000..26336c369 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-src.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#4caf50" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-styles.svg b/plugins/official/material-icons/icons/folder-styles.svg new file mode 100644 index 000000000..4ac2a7ab9 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-styles.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ec407a" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder-test.svg b/plugins/official/material-icons/icons/folder-test.svg new file mode 100644 index 000000000..29b2cbcf4 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-test.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#8bc34a" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/folder.svg b/plugins/official/material-icons/icons/folder.svg new file mode 100644 index 000000000..4002def3a --- /dev/null +++ b/plugins/official/material-icons/icons/folder.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/></svg> diff --git a/plugins/official/material-icons/icons/font.svg b/plugins/official/material-icons/icons/font.svg new file mode 100644 index 000000000..d7e454a4d --- /dev/null +++ b/plugins/official/material-icons/icons/font.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ef5350"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">Aa</text></svg> diff --git a/plugins/official/material-icons/icons/fsharp.svg b/plugins/official/material-icons/icons/fsharp.svg new file mode 100644 index 000000000..cbf21c7b5 --- /dev/null +++ b/plugins/official/material-icons/icons/fsharp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#378bba"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">F#</text></svg> diff --git a/plugins/official/material-icons/icons/git.svg b/plugins/official/material-icons/icons/git.svg new file mode 100644 index 000000000..02ff7956f --- /dev/null +++ b/plugins/official/material-icons/icons/git.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#f4511e" d="M8 .6l7.4 7.4L8 15.4.6 8z"/><path d="M6.3 5v6M6.3 6.4c0 1.8 3.7.4 3.7 2.1" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/><g fill="#fff"><circle cx="6.3" cy="5" r="1.1"/><circle cx="6.3" cy="11" r="1.1"/><circle cx="10" cy="8.6" r="1.1"/></g></svg> diff --git a/plugins/official/material-icons/icons/go.svg b/plugins/official/material-icons/icons/go.svg new file mode 100644 index 000000000..4525fef3d --- /dev/null +++ b/plugins/official/material-icons/icons/go.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#00acc1"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">GO</text></svg> diff --git a/plugins/official/material-icons/icons/gomod.svg b/plugins/official/material-icons/icons/gomod.svg new file mode 100644 index 000000000..a0a8000ce --- /dev/null +++ b/plugins/official/material-icons/icons/gomod.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#00acc1" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/><path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/><path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/></svg> diff --git a/plugins/official/material-icons/icons/graphql.svg b/plugins/official/material-icons/icons/graphql.svg new file mode 100644 index 000000000..7126760d2 --- /dev/null +++ b/plugins/official/material-icons/icons/graphql.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e535ab"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">GQ</text></svg> diff --git a/plugins/official/material-icons/icons/groovy.svg b/plugins/official/material-icons/icons/groovy.svg new file mode 100644 index 000000000..8967bb44f --- /dev/null +++ b/plugins/official/material-icons/icons/groovy.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#4db6ac"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">GR</text></svg> diff --git a/plugins/official/material-icons/icons/haskell.svg b/plugins/official/material-icons/icons/haskell.svg new file mode 100644 index 000000000..f20b1be38 --- /dev/null +++ b/plugins/official/material-icons/icons/haskell.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7e57c2"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">HS</text></svg> diff --git a/plugins/official/material-icons/icons/header.svg b/plugins/official/material-icons/icons/header.svg new file mode 100644 index 000000000..cf7e9e785 --- /dev/null +++ b/plugins/official/material-icons/icons/header.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#9575cd"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">H</text></svg> diff --git a/plugins/official/material-icons/icons/html.svg b/plugins/official/material-icons/icons/html.svg new file mode 100644 index 000000000..40dec7249 --- /dev/null +++ b/plugins/official/material-icons/icons/html.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e44d26"/><path d="M6 5L3.5 8 6 11M10 5l2.5 3L10 11" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/http.svg b/plugins/official/material-icons/icons/http.svg new file mode 100644 index 000000000..c4a7ed510 --- /dev/null +++ b/plugins/official/material-icons/icons/http.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#3f51b5"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">API</text></svg> diff --git a/plugins/official/material-icons/icons/image.svg b/plugins/official/material-icons/icons/image.svg new file mode 100644 index 000000000..8f67a7abf --- /dev/null +++ b/plugins/official/material-icons/icons/image.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="1.5" fill="#26a69a"/><circle cx="5" cy="6" r="1.5" fill="#fff"/><path fill="#fff" d="M2.5 12l3.5-4 2.5 2.6L11 7.4l3 4.6z"/></svg> diff --git a/plugins/official/material-icons/icons/ini.svg b/plugins/official/material-icons/icons/ini.svg new file mode 100644 index 000000000..a70903c4f --- /dev/null +++ b/plugins/official/material-icons/icons/ini.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#78909c"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">INI</text></svg> diff --git a/plugins/official/material-icons/icons/java.svg b/plugins/official/material-icons/icons/java.svg new file mode 100644 index 000000000..6a72019b2 --- /dev/null +++ b/plugins/official/material-icons/icons/java.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e53935"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">J</text></svg> diff --git a/plugins/official/material-icons/icons/javascript.svg b/plugins/official/material-icons/icons/javascript.svg new file mode 100644 index 000000000..e43c32ab9 --- /dev/null +++ b/plugins/official/material-icons/icons/javascript.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#fbc02d"/><text x="8" y="10.66" fill="#3e2723" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">JS</text></svg> diff --git a/plugins/official/material-icons/icons/json.svg b/plugins/official/material-icons/icons/json.svg new file mode 100644 index 000000000..841dce8a1 --- /dev/null +++ b/plugins/official/material-icons/icons/json.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#fbc02d"/><text x="8" y="10.66" fill="#5d4037" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">{}</text></svg> diff --git a/plugins/official/material-icons/icons/julia.svg b/plugins/official/material-icons/icons/julia.svg new file mode 100644 index 000000000..4fb97b14c --- /dev/null +++ b/plugins/official/material-icons/icons/julia.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#9558b2"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">JL</text></svg> diff --git a/plugins/official/material-icons/icons/kotlin.svg b/plugins/official/material-icons/icons/kotlin.svg new file mode 100644 index 000000000..af61a9557 --- /dev/null +++ b/plugins/official/material-icons/icons/kotlin.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7e57c2"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">KT</text></svg> diff --git a/plugins/official/material-icons/icons/less.svg b/plugins/official/material-icons/icons/less.svg new file mode 100644 index 000000000..a6534d17a --- /dev/null +++ b/plugins/official/material-icons/icons/less.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#1565c0"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">LS</text></svg> diff --git a/plugins/official/material-icons/icons/license.svg b/plugins/official/material-icons/icons/license.svg new file mode 100644 index 000000000..a58c14bb3 --- /dev/null +++ b/plugins/official/material-icons/icons/license.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1.5" y="2" width="13" height="9.5" rx="1" fill="#ffca28"/><path d="M3.6 4.6h6.4M3.6 6.8h4" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/><path fill="#ef6c00" d="M9.6 11.4l-.7 3.4 2.1-1.1 2.1 1.1-.7-3.4z"/><circle cx="11" cy="10" r="2.3" fill="#ef6c00"/></svg> diff --git a/plugins/official/material-icons/icons/lock.svg b/plugins/official/material-icons/icons/lock.svg new file mode 100644 index 000000000..1ee46f47c --- /dev/null +++ b/plugins/official/material-icons/icons/lock.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="7" width="10" height="8" rx="1.5" fill="#90a4ae"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" fill="none" stroke="#90a4ae" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="8" cy="11" r="1.2" fill="#fff"/></svg> diff --git a/plugins/official/material-icons/icons/log.svg b/plugins/official/material-icons/icons/log.svg new file mode 100644 index 000000000..0a8efff5f --- /dev/null +++ b/plugins/official/material-icons/icons/log.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#78909c"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">LOG</text></svg> diff --git a/plugins/official/material-icons/icons/lua.svg b/plugins/official/material-icons/icons/lua.svg new file mode 100644 index 000000000..018aa5799 --- /dev/null +++ b/plugins/official/material-icons/icons/lua.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#3949ab"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">LUA</text></svg> diff --git a/plugins/official/material-icons/icons/makefile.svg b/plugins/official/material-icons/icons/makefile.svg new file mode 100644 index 000000000..7fe3ad1fc --- /dev/null +++ b/plugins/official/material-icons/icons/makefile.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ef5350"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">M</text></svg> diff --git a/plugins/official/material-icons/icons/markdown.svg b/plugins/official/material-icons/icons/markdown.svg new file mode 100644 index 000000000..78c8400ea --- /dev/null +++ b/plugins/official/material-icons/icons/markdown.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="0.5" y="3" width="15" height="10" rx="1.6" fill="#42a5f5"/><path d="M3 10.5v-5l2 2.5 2-2.5v5" fill="none" stroke="#fff" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/><path d="M11 5.5v5M9.2 8.8l1.8 1.8 1.8-1.8" fill="none" stroke="#fff" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/nim.svg b/plugins/official/material-icons/icons/nim.svg new file mode 100644 index 000000000..defeaf39f --- /dev/null +++ b/plugins/official/material-icons/icons/nim.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ffc107"/><text x="8" y="10.02" fill="#3e2723" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">NIM</text></svg> diff --git a/plugins/official/material-icons/icons/nix.svg b/plugins/official/material-icons/icons/nix.svg new file mode 100644 index 000000000..dace2f37d --- /dev/null +++ b/plugins/official/material-icons/icons/nix.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#5277c3"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">NIX</text></svg> diff --git a/plugins/official/material-icons/icons/notebook.svg b/plugins/official/material-icons/icons/notebook.svg new file mode 100644 index 000000000..67b85bf8a --- /dev/null +++ b/plugins/official/material-icons/icons/notebook.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#f57c00"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">NB</text></svg> diff --git a/plugins/official/material-icons/icons/npm.svg b/plugins/official/material-icons/icons/npm.svg new file mode 100644 index 000000000..aec46265c --- /dev/null +++ b/plugins/official/material-icons/icons/npm.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cb3837" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/><path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/><path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/></svg> diff --git a/plugins/official/material-icons/icons/objc.svg b/plugins/official/material-icons/icons/objc.svg new file mode 100644 index 000000000..429ce16b0 --- /dev/null +++ b/plugins/official/material-icons/icons/objc.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#607d8b"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">OC</text></svg> diff --git a/plugins/official/material-icons/icons/ocaml.svg b/plugins/official/material-icons/icons/ocaml.svg new file mode 100644 index 000000000..4be3ebdbf --- /dev/null +++ b/plugins/official/material-icons/icons/ocaml.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ef6c00"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">ML</text></svg> diff --git a/plugins/official/material-icons/icons/pdf.svg b/plugins/official/material-icons/icons/pdf.svg new file mode 100644 index 000000000..777d161ad --- /dev/null +++ b/plugins/official/material-icons/icons/pdf.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e53935"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PDF</text></svg> diff --git a/plugins/official/material-icons/icons/perl.svg b/plugins/official/material-icons/icons/perl.svg new file mode 100644 index 000000000..82516150b --- /dev/null +++ b/plugins/official/material-icons/icons/perl.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#5c6bc0"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PL</text></svg> diff --git a/plugins/official/material-icons/icons/php.svg b/plugins/official/material-icons/icons/php.svg new file mode 100644 index 000000000..20d1ae8e2 --- /dev/null +++ b/plugins/official/material-icons/icons/php.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7986cb"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PHP</text></svg> diff --git a/plugins/official/material-icons/icons/pnpm.svg b/plugins/official/material-icons/icons/pnpm.svg new file mode 100644 index 000000000..e581f13c9 --- /dev/null +++ b/plugins/official/material-icons/icons/pnpm.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#f9ad00" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/><path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/><path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/></svg> diff --git a/plugins/official/material-icons/icons/powershell.svg b/plugins/official/material-icons/icons/powershell.svg new file mode 100644 index 000000000..35fe8ce0b --- /dev/null +++ b/plugins/official/material-icons/icons/powershell.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0277bd"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PS</text></svg> diff --git a/plugins/official/material-icons/icons/proto.svg b/plugins/official/material-icons/icons/proto.svg new file mode 100644 index 000000000..49b84d9e7 --- /dev/null +++ b/plugins/official/material-icons/icons/proto.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#26a69a"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PB</text></svg> diff --git a/plugins/official/material-icons/icons/python.svg b/plugins/official/material-icons/icons/python.svg new file mode 100644 index 000000000..a22894cce --- /dev/null +++ b/plugins/official/material-icons/icons/python.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#3572a5"/><text x="8" y="10.66" fill="#ffd54f" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">PY</text></svg> diff --git a/plugins/official/material-icons/icons/r.svg b/plugins/official/material-icons/icons/r.svg new file mode 100644 index 000000000..316127087 --- /dev/null +++ b/plugins/official/material-icons/icons/r.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#1976d2"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">R</text></svg> diff --git a/plugins/official/material-icons/icons/react-ts.svg b/plugins/official/material-icons/icons/react-ts.svg new file mode 100644 index 000000000..30c7b00ea --- /dev/null +++ b/plugins/official/material-icons/icons/react-ts.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="none" stroke="#0288d1" stroke-width="1.1"><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(0 8 8)"/><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(60 8 8)"/><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(120 8 8)"/></g><circle cx="8" cy="8" r="1.4" fill="#0288d1"/></svg> diff --git a/plugins/official/material-icons/icons/react.svg b/plugins/official/material-icons/icons/react.svg new file mode 100644 index 000000000..3c8af66c1 --- /dev/null +++ b/plugins/official/material-icons/icons/react.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="none" stroke="#00bcd4" stroke-width="1.1"><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(0 8 8)"/><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(60 8 8)"/><ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate(120 8 8)"/></g><circle cx="8" cy="8" r="1.4" fill="#00bcd4"/></svg> diff --git a/plugins/official/material-icons/icons/readme.svg b/plugins/official/material-icons/icons/readme.svg new file mode 100644 index 000000000..39c5652c1 --- /dev/null +++ b/plugins/official/material-icons/icons/readme.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="#42a5f5"/><rect x="7.1" y="6.8" width="1.8" height="5.2" rx=".6" fill="#fff"/><circle cx="8" cy="4.6" r="1.1" fill="#fff"/></svg> diff --git a/plugins/official/material-icons/icons/ruby.svg b/plugins/official/material-icons/icons/ruby.svg new file mode 100644 index 000000000..ed8beec63 --- /dev/null +++ b/plugins/official/material-icons/icons/ruby.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#c62828"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">RB</text></svg> diff --git a/plugins/official/material-icons/icons/rust.svg b/plugins/official/material-icons/icons/rust.svg new file mode 100644 index 000000000..3bd8a2bfc --- /dev/null +++ b/plugins/official/material-icons/icons/rust.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff7043"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">RS</text></svg> diff --git a/plugins/official/material-icons/icons/sass.svg b/plugins/official/material-icons/icons/sass.svg new file mode 100644 index 000000000..13bd12f62 --- /dev/null +++ b/plugins/official/material-icons/icons/sass.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ec407a"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">#</text></svg> diff --git a/plugins/official/material-icons/icons/scala.svg b/plugins/official/material-icons/icons/scala.svg new file mode 100644 index 000000000..34cc32383 --- /dev/null +++ b/plugins/official/material-icons/icons/scala.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#d32f2f"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">SC</text></svg> diff --git a/plugins/official/material-icons/icons/settings.svg b/plugins/official/material-icons/icons/settings.svg new file mode 100644 index 000000000..85a771c23 --- /dev/null +++ b/plugins/official/material-icons/icons/settings.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="#78909c"><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(0 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(45 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(90 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(135 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(180 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(225 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(270 8 8)"/><rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate(315 8 8)"/></g><circle cx="8" cy="8" r="3.6" fill="none" stroke="#78909c" stroke-width="2.8"/></svg> diff --git a/plugins/official/material-icons/icons/shell.svg b/plugins/official/material-icons/icons/shell.svg new file mode 100644 index 000000000..bf37b0976 --- /dev/null +++ b/plugins/official/material-icons/icons/shell.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2" width="14" height="12" rx="2" fill="#4caf50"/><path d="M4 5.5l2.5 2.5L4 10.5" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 10.8h4" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/solidity.svg b/plugins/official/material-icons/icons/solidity.svg new file mode 100644 index 000000000..d6d6508f9 --- /dev/null +++ b/plugins/official/material-icons/icons/solidity.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#546e7a"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">SOL</text></svg> diff --git a/plugins/official/material-icons/icons/svelte.svg b/plugins/official/material-icons/icons/svelte.svg new file mode 100644 index 000000000..152e614f9 --- /dev/null +++ b/plugins/official/material-icons/icons/svelte.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff3e00"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">S</text></svg> diff --git a/plugins/official/material-icons/icons/svg.svg b/plugins/official/material-icons/icons/svg.svg new file mode 100644 index 000000000..1115a97fd --- /dev/null +++ b/plugins/official/material-icons/icons/svg.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ffb300"/><text x="8" y="10.02" fill="#4e342e" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">SVG</text></svg> diff --git a/plugins/official/material-icons/icons/swift.svg b/plugins/official/material-icons/icons/swift.svg new file mode 100644 index 000000000..1d6d44619 --- /dev/null +++ b/plugins/official/material-icons/icons/swift.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff5722"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">SW</text></svg> diff --git a/plugins/official/material-icons/icons/terraform.svg b/plugins/official/material-icons/icons/terraform.svg new file mode 100644 index 000000000..5ff38d03d --- /dev/null +++ b/plugins/official/material-icons/icons/terraform.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#7b42bc"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TF</text></svg> diff --git a/plugins/official/material-icons/icons/test.svg b/plugins/official/material-icons/icons/test.svg new file mode 100644 index 000000000..b49224552 --- /dev/null +++ b/plugins/official/material-icons/icons/test.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#8bc34a"/><path d="M4.2 8.2l2.4 2.4 5.2-5.2" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/tex.svg b/plugins/official/material-icons/icons/tex.svg new file mode 100644 index 000000000..8d647f822 --- /dev/null +++ b/plugins/official/material-icons/icons/tex.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#008080"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TEX</text></svg> diff --git a/plugins/official/material-icons/icons/text.svg b/plugins/official/material-icons/icons/text.svg new file mode 100644 index 000000000..1ad8d7b17 --- /dev/null +++ b/plugins/official/material-icons/icons/text.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1h5.6L13 4.4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/><path fill="#fff" fill-opacity=".45" d="M9.6 1v3.4H13z"/><path d="M5 7.5h6M5 10h6M5 12.5h4" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/theme.json b/plugins/official/material-icons/icons/theme.json new file mode 100644 index 000000000..919c17fe3 --- /dev/null +++ b/plugins/official/material-icons/icons/theme.json @@ -0,0 +1,471 @@ +{ + "iconDefinitions": { + "archive": "icons/archive.svg", + "astro": "icons/astro.svg", + "audio": "icons/audio.svg", + "batch": "icons/batch.svg", + "c": "icons/c.svg", + "cargo": "icons/cargo.svg", + "class": "icons/class.svg", + "clojure": "icons/clojure.svg", + "cpp": "icons/cpp.svg", + "csharp": "icons/csharp.svg", + "css": "icons/css.svg", + "csv": "icons/csv.svg", + "dart": "icons/dart.svg", + "database": "icons/database.svg", + "diff": "icons/diff.svg", + "docker": "icons/docker.svg", + "editorconfig": "icons/editorconfig.svg", + "elixir": "icons/elixir.svg", + "env": "icons/env.svg", + "erlang": "icons/erlang.svg", + "file": "icons/file.svg", + "folder": "icons/folder.svg", + "folder-api": "icons/folder-api.svg", + "folder-assets": "icons/folder-assets.svg", + "folder-components": "icons/folder-components.svg", + "folder-config": "icons/folder-config.svg", + "folder-db": "icons/folder-db.svg", + "folder-deps": "icons/folder-deps.svg", + "folder-dist": "icons/folder-dist.svg", + "folder-docs": "icons/folder-docs.svg", + "folder-git": "icons/folder-git.svg", + "folder-i18n": "icons/folder-i18n.svg", + "folder-open": "icons/folder-open.svg", + "folder-plugins": "icons/folder-plugins.svg", + "folder-scripts": "icons/folder-scripts.svg", + "folder-src": "icons/folder-src.svg", + "folder-styles": "icons/folder-styles.svg", + "folder-test": "icons/folder-test.svg", + "font": "icons/font.svg", + "fsharp": "icons/fsharp.svg", + "git": "icons/git.svg", + "go": "icons/go.svg", + "gomod": "icons/gomod.svg", + "graphql": "icons/graphql.svg", + "groovy": "icons/groovy.svg", + "haskell": "icons/haskell.svg", + "header": "icons/header.svg", + "html": "icons/html.svg", + "http": "icons/http.svg", + "image": "icons/image.svg", + "ini": "icons/ini.svg", + "java": "icons/java.svg", + "javascript": "icons/javascript.svg", + "json": "icons/json.svg", + "julia": "icons/julia.svg", + "kotlin": "icons/kotlin.svg", + "less": "icons/less.svg", + "license": "icons/license.svg", + "lock": "icons/lock.svg", + "log": "icons/log.svg", + "lua": "icons/lua.svg", + "makefile": "icons/makefile.svg", + "markdown": "icons/markdown.svg", + "nim": "icons/nim.svg", + "nix": "icons/nix.svg", + "notebook": "icons/notebook.svg", + "npm": "icons/npm.svg", + "objc": "icons/objc.svg", + "ocaml": "icons/ocaml.svg", + "pdf": "icons/pdf.svg", + "perl": "icons/perl.svg", + "php": "icons/php.svg", + "pnpm": "icons/pnpm.svg", + "powershell": "icons/powershell.svg", + "proto": "icons/proto.svg", + "python": "icons/python.svg", + "r": "icons/r.svg", + "react": "icons/react.svg", + "react-ts": "icons/react-ts.svg", + "readme": "icons/readme.svg", + "ruby": "icons/ruby.svg", + "rust": "icons/rust.svg", + "sass": "icons/sass.svg", + "scala": "icons/scala.svg", + "settings": "icons/settings.svg", + "shell": "icons/shell.svg", + "solidity": "icons/solidity.svg", + "svelte": "icons/svelte.svg", + "svg": "icons/svg.svg", + "swift": "icons/swift.svg", + "terraform": "icons/terraform.svg", + "test": "icons/test.svg", + "tex": "icons/tex.svg", + "text": "icons/text.svg", + "toml": "icons/toml.svg", + "tsconfig": "icons/tsconfig.svg", + "typescript": "icons/typescript.svg", + "typescript-def": "icons/typescript-def.svg", + "video": "icons/video.svg", + "vim": "icons/vim.svg", + "vite": "icons/vite.svg", + "vue": "icons/vue.svg", + "wasm": "icons/wasm.svg", + "xml": "icons/xml.svg", + "yaml": "icons/yaml.svg", + "zig": "icons/zig.svg" + }, + "file": "file", + "folder": "folder", + "folderExpanded": "folder-open", + "fileExtensions": { + "7z": "archive", + "astro": "astro", + "avi": "video", + "avif": "image", + "bash": "shell", + "bat": "batch", + "bib": "tex", + "bmp": "image", + "bz2": "archive", + "c": "c", + "c++": "cpp", + "cc": "cpp", + "cfg": "ini", + "cjs": "javascript", + "class": "class", + "clj": "clojure", + "cljc": "clojure", + "cljs": "clojure", + "cmd": "batch", + "conf": "ini", + "cpp": "cpp", + "cs": "csharp", + "css": "css", + "csv": "csv", + "csx": "csharp", + "cts": "typescript", + "cxx": "cpp", + "d.cts": "typescript-def", + "d.mts": "typescript-def", + "d.ts": "typescript-def", + "dart": "dart", + "db": "database", + "diff": "diff", + "edn": "clojure", + "env": "env", + "erb": "ruby", + "erl": "erlang", + "ex": "elixir", + "exs": "elixir", + "fish": "shell", + "flac": "audio", + "fs": "fsharp", + "fsi": "fsharp", + "fsx": "fsharp", + "gemspec": "ruby", + "gif": "image", + "go": "go", + "gql": "graphql", + "gradle": "groovy", + "graphql": "graphql", + "groovy": "groovy", + "gz": "archive", + "h": "header", + "hcl": "terraform", + "heex": "elixir", + "hh": "header", + "hpp": "header", + "hrl": "erlang", + "hs": "haskell", + "htm": "html", + "html": "html", + "http": "http", + "hxx": "header", + "icns": "image", + "ico": "image", + "iml": "xml", + "ini": "ini", + "inl": "header", + "ipynb": "notebook", + "jar": "class", + "java": "java", + "jl": "julia", + "jpeg": "image", + "jpg": "image", + "js": "javascript", + "json": "json", + "json5": "json", + "jsonc": "json", + "jsx": "react", + "kt": "kotlin", + "kts": "kotlin", + "less": "less", + "lhs": "haskell", + "lock": "lock", + "log": "log", + "lua": "lua", + "m": "objc", + "m4a": "audio", + "markdown": "markdown", + "md": "markdown", + "mdx": "markdown", + "mjs": "javascript", + "mkv": "video", + "ml": "ocaml", + "mli": "ocaml", + "mm": "objc", + "mov": "video", + "mp3": "audio", + "mp4": "video", + "mts": "typescript", + "nim": "nim", + "nix": "nix", + "ogg": "audio", + "otf": "font", + "patch": "diff", + "pdf": "pdf", + "php": "php", + "pl": "perl", + "plist": "xml", + "pm": "perl", + "png": "image", + "prisma": "database", + "properties": "ini", + "proto": "proto", + "ps1": "powershell", + "psd1": "powershell", + "psm1": "powershell", + "py": "python", + "pyi": "python", + "pyw": "python", + "r": "r", + "rake": "ruby", + "rar": "archive", + "rb": "ruby", + "rest": "http", + "rmd": "r", + "rs": "rust", + "sass": "sass", + "sbt": "scala", + "sc": "scala", + "scala": "scala", + "scss": "sass", + "sh": "shell", + "sol": "solidity", + "spec.js": "test", + "spec.jsx": "test", + "spec.mjs": "test", + "spec.mts": "test", + "spec.ts": "test", + "spec.tsx": "test", + "sql": "database", + "sqlite": "database", + "sty": "tex", + "svelte": "svelte", + "svg": "svg", + "swift": "swift", + "tar": "archive", + "test.js": "test", + "test.jsx": "test", + "test.mjs": "test", + "test.mts": "test", + "test.py": "test", + "test.rs": "test", + "test.ts": "test", + "test.tsx": "test", + "tex": "tex", + "text": "text", + "tf": "terraform", + "tfvars": "terraform", + "tgz": "archive", + "tif": "image", + "tiff": "image", + "toml": "toml", + "ts": "typescript", + "tsv": "csv", + "tsx": "react-ts", + "ttf": "font", + "txt": "text", + "vim": "vim", + "vue": "vue", + "wasm": "wasm", + "wat": "wasm", + "wav": "audio", + "webm": "video", + "webp": "image", + "woff": "font", + "woff2": "font", + "xhtml": "html", + "xml": "xml", + "xsd": "xml", + "xsl": "xml", + "xz": "archive", + "yaml": "yaml", + "yml": "yaml", + "zig": "zig", + "zip": "archive", + "zon": "zig", + "zsh": "shell", + "zst": "archive" + }, + "fileNames": { + ".clippy.toml": "settings", + ".dockerignore": "docker", + ".editorconfig": "editorconfig", + ".env": "env", + ".env.development": "env", + ".env.example": "env", + ".env.local": "env", + ".env.production": "env", + ".eslintrc": "editorconfig", + ".eslintrc.json": "editorconfig", + ".gitattributes": "git", + ".gitignore": "git", + ".gitkeep": "git", + ".gitmodules": "git", + ".mailmap": "git", + ".npmignore": "npm", + ".npmrc": "npm", + ".oxlintrc.json": "editorconfig", + ".prettierignore": "editorconfig", + ".prettierrc": "editorconfig", + ".rustfmt.toml": "settings", + "bun.lockb": "lock", + "cargo.lock": "lock", + "cargo.toml": "cargo", + "clippy.toml": "settings", + "cmakelists.txt": "makefile", + "compose.yaml": "docker", + "compose.yml": "docker", + "composer.lock": "lock", + "containerfile": "docker", + "copying": "license", + "deny.toml": "settings", + "docker-compose.yaml": "docker", + "docker-compose.yml": "docker", + "dockerfile": "docker", + "gemfile.lock": "lock", + "gnumakefile": "makefile", + "go.mod": "gomod", + "go.sum": "gomod", + "go.work": "gomod", + "jsconfig.json": "tsconfig", + "justfile": "makefile", + "licence": "license", + "licence.md": "license", + "license": "license", + "license.md": "license", + "license.txt": "license", + "makefile": "makefile", + "mise.toml": "editorconfig", + "notice": "license", + "package-lock.json": "lock", + "package.json": "npm", + "pnpm-lock.yaml": "lock", + "pnpm-workspace.yaml": "pnpm", + "poetry.lock": "lock", + "readme": "readme", + "readme.md": "readme", + "readme.rst": "readme", + "readme.txt": "readme", + "rust-toolchain": "cargo", + "rust-toolchain.toml": "cargo", + "rustfmt.toml": "settings", + "tsconfig.base.json": "tsconfig", + "tsconfig.json": "tsconfig", + "uv.lock": "lock", + "vite.config.js": "vite", + "vite.config.mts": "vite", + "vite.config.ts": "vite", + "vitest.config.mts": "vite", + "vitest.config.ts": "vite", + "yarn.lock": "lock" + }, + "folderNames": { + ".cargo": "folder-config", + ".config": "folder-config", + ".git": "folder-git", + ".github": "folder-git", + ".gitlab": "folder-git", + ".husky": "folder-git", + ".idea": "folder-config", + ".venv": "folder-deps", + ".vscode": "folder-config", + "__tests__": "folder-test", + "addons": "folder-plugins", + "api": "folder-api", + "assets": "folder-assets", + "bin": "folder-dist", + "build": "folder-dist", + "ci": "folder-scripts", + "components": "folder-components", + "config": "folder-config", + "configs": "folder-config", + "coverage": "folder-dist", + "crates": "folder-src", + "css": "folder-styles", + "database": "folder-db", + "db": "folder-db", + "deps": "folder-deps", + "dist": "folder-dist", + "doc": "folder-docs", + "docs": "folder-docs", + "documentation": "folder-docs", + "e2e": "folder-test", + "etc": "folder-config", + "extensions": "folder-plugins", + "features": "folder-components", + "fixtures": "folder-test", + "fonts": "folder-assets", + "handlers": "folder-api", + "i18n": "folder-i18n", + "icons": "folder-assets", + "images": "folder-assets", + "img": "folder-assets", + "internal": "folder-src", + "lang": "folder-i18n", + "lib": "folder-src", + "libs": "folder-src", + "locale": "folder-i18n", + "locales": "folder-i18n", + "media": "folder-assets", + "migrations": "folder-db", + "node_modules": "folder-deps", + "obj": "folder-dist", + "out": "folder-dist", + "packages": "folder-src", + "pages": "folder-components", + "pkg": "folder-src", + "plugins": "folder-plugins", + "public": "folder-assets", + "release": "folder-dist", + "res": "folder-assets", + "resources": "folder-assets", + "routes": "folder-api", + "rpc": "folder-api", + "schema": "folder-db", + "script": "folder-scripts", + "scripts": "folder-scripts", + "scss": "folder-styles", + "sdk": "folder-plugins", + "server": "folder-api", + "settings": "folder-config", + "source": "folder-src", + "sources": "folder-src", + "spec": "folder-test", + "specs": "folder-test", + "sql": "folder-db", + "src": "folder-src", + "static": "folder-assets", + "styles": "folder-styles", + "target": "folder-dist", + "tasks": "folder-scripts", + "test": "folder-test", + "testdata": "folder-test", + "tests": "folder-test", + "theme": "folder-styles", + "themes": "folder-styles", + "third_party": "folder-deps", + "tools": "folder-scripts", + "translations": "folder-i18n", + "ui": "folder-components", + "vendor": "folder-deps", + "venv": "folder-deps", + "views": "folder-components", + "widgets": "folder-components", + "wiki": "folder-docs" + } +} diff --git a/plugins/official/material-icons/icons/toml.svg b/plugins/official/material-icons/icons/toml.svg new file mode 100644 index 000000000..e322c4c8c --- /dev/null +++ b/plugins/official/material-icons/icons/toml.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#8d6e63"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TML</text></svg> diff --git a/plugins/official/material-icons/icons/tsconfig.svg b/plugins/official/material-icons/icons/tsconfig.svg new file mode 100644 index 000000000..b1e3d3216 --- /dev/null +++ b/plugins/official/material-icons/icons/tsconfig.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0288d1"/><text x="8" y="10.66" fill="#bbdefb" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TS</text></svg> diff --git a/plugins/official/material-icons/icons/typescript-def.svg b/plugins/official/material-icons/icons/typescript-def.svg new file mode 100644 index 000000000..2bdef4cd3 --- /dev/null +++ b/plugins/official/material-icons/icons/typescript-def.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0277bd"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">D</text></svg> diff --git a/plugins/official/material-icons/icons/typescript.svg b/plugins/official/material-icons/icons/typescript.svg new file mode 100644 index 000000000..ff3be5032 --- /dev/null +++ b/plugins/official/material-icons/icons/typescript.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#0288d1"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">TS</text></svg> diff --git a/plugins/official/material-icons/icons/video.svg b/plugins/official/material-icons/icons/video.svg new file mode 100644 index 000000000..7bc2ac04a --- /dev/null +++ b/plugins/official/material-icons/icons/video.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="2" fill="#ff7043"/><path fill="#fff" d="M6.4 5.5v5l4-2.5z"/></svg> diff --git a/plugins/official/material-icons/icons/vim.svg b/plugins/official/material-icons/icons/vim.svg new file mode 100644 index 000000000..46ff8ce6f --- /dev/null +++ b/plugins/official/material-icons/icons/vim.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#43a047"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">VI</text></svg> diff --git a/plugins/official/material-icons/icons/vite.svg b/plugins/official/material-icons/icons/vite.svg new file mode 100644 index 000000000..402a8dee8 --- /dev/null +++ b/plugins/official/material-icons/icons/vite.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#9575cd"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">V</text></svg> diff --git a/plugins/official/material-icons/icons/vue.svg b/plugins/official/material-icons/icons/vue.svg new file mode 100644 index 000000000..0481473ac --- /dev/null +++ b/plugins/official/material-icons/icons/vue.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#41b883"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">V</text></svg> diff --git a/plugins/official/material-icons/icons/wasm.svg b/plugins/official/material-icons/icons/wasm.svg new file mode 100644 index 000000000..b520537de --- /dev/null +++ b/plugins/official/material-icons/icons/wasm.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#654ff0"/><text x="8" y="10.66" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="7.4" font-weight="700" text-anchor="middle" letter-spacing="-0.2">WA</text></svg> diff --git a/plugins/official/material-icons/icons/xml.svg b/plugins/official/material-icons/icons/xml.svg new file mode 100644 index 000000000..4f3edab6b --- /dev/null +++ b/plugins/official/material-icons/icons/xml.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff9800"/><path d="M5.5 5L3 8l2.5 3M10.5 5L13 8l-2.5 3M9 4.5l-2 7" fill="none" stroke="#fff" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg> diff --git a/plugins/official/material-icons/icons/yaml.svg b/plugins/official/material-icons/icons/yaml.svg new file mode 100644 index 000000000..e1f12636a --- /dev/null +++ b/plugins/official/material-icons/icons/yaml.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#e53935"/><text x="8" y="10.02" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="5.6" font-weight="700" text-anchor="middle" letter-spacing="-0.2">YML</text></svg> diff --git a/plugins/official/material-icons/icons/zig.svg b/plugins/official/material-icons/icons/zig.svg new file mode 100644 index 000000000..26894c3fb --- /dev/null +++ b/plugins/official/material-icons/icons/zig.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#f9a825"/><text x="8" y="11.42" fill="#3e2723" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">Z</text></svg> diff --git a/plugins/official/material-icons/plugin.toml b/plugins/official/material-icons/plugin.toml new file mode 100644 index 000000000..4c56e7a6c --- /dev/null +++ b/plugins/official/material-icons/plugin.toml @@ -0,0 +1,15 @@ +id = "openidea.material-icons" +name = "Material Icons" +version = "0.1.0" +description = "Colourful file and folder icons on the Material palette: 100+ icons for languages, well-known files and project folders." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: 106 icons covering 189 extensions, 71 file names and 92 folder names." +engines.openidea = "^1.0" + +# Apply it in Settings › Plugins › Material Icons. The icons are generated by +# generate.py (not part of the package); edit its tables and re-run it. +[[contributes.iconThemes]] +id = "material" +label = "Material Icons" +path = "icons/theme.json" diff --git a/plugins/official/mybatis/.gitignore b/plugins/official/mybatis/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/mybatis/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/mybatis/Cargo.lock b/plugins/official/mybatis/Cargo.lock new file mode 100644 index 000000000..f4c6da463 --- /dev/null +++ b/plugins/official/mybatis/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-mybatis" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/mybatis/Cargo.toml b/plugins/official/mybatis/Cargo.toml new file mode 100644 index 000000000..415bdf6ae --- /dev/null +++ b/plugins/official/mybatis/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-mybatis" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..dc36a16478574ffd6b1c4df9aa281b5d98884eab GIT binary patch literal 158332 zcmeFa3zQw#S?^h|^XhZDRgybS6Dg$5aWm7pBRLIANE*k(?b31_8JmS>EzcUSxw#7| zQYTWYEy>+&$5}X0CzgW)c^Kj(zL19?PHch$1_BtaAx#iq5?}BI6G9$0B5@L9oFIY& zfjD@6|8MWAQ>Pz#IEKaCHE!$FuB!d~_V?c3-WBYdzkWCfgT~vVaMy5nL%n`r`pBW_ z1L1s7n_t*BI~@eABS#muzVhgeNA$39=*Ug`W)CUZ+Q09}{^{A-=>tJff91YIBzT$x zwduLJqjPZ(gmY0cd+5mYYg+pc?wi}Ue_?v=HA&Dsc69#G!l9!_;vgc2TA5pj<LM&@ zV#?0#JMyaOI7sxy(zRq^`sRiCBxvcnb^5@eg*d3I>SOyB4$c>ug68be{rg<C&G6WK zIG41J&EEK`Lr1nAIxs)tlQ&Jz&C~LHd*87`TZ@~{{(Z;xz2eX;otmB>N`l7zqes+M z9JGhBFxYtX7!Wxyz3(zt$%_|Wb8LEk>U%GpymYb_)Q3Vw>W19-%P`!vfeMbz9i5rp zzYvE}9ED*YB4Kzi2xr3iFbb!_WPW<~m5ekAM*q|G(+dZW9{A6`+@*6zk1m9@a4K?n z+(?!4yH@0!-gjW@D*=62*Pv&@hU$)exmV2|eZ_oj_R#f*7G4uHli&r*i@);F5m$UZ zY=wt{@YoPlv{gkAbTn0NAoJny(0CZUXgMTaeRS^d{IPxer#&>H#)fbg;~fG?irEN< zz9$T_<t6(We9^e^b%56hudg=`Prv3BNB7MgNUonga${rR=+W6%?3?TON!hB0_4(<2 zbNdh0jxHRWo=bvZP>WgJI)C)Wx&0td5FXb2*WKg!+QOlQ+3ESZdmT3C4<4Oc*ni_f zlmw|Sd2D*_l}G2UpFR)=F*MK?M7OHXhSEcJJHPKHyBiS-9-V_C_TM<qlW1ivfS`TT z{`sw-J=kotGQa=e^nn|VR)h~XO&<ZjYD3*H*t8rr_Od{*r15`-yO=ttW_EVpD`ux- zLn4npVF<llT+XV`a_K9eg~QC^u`oRL8c;1d1hIiWi7E+eL(dO`4X@ref9SG9M_;T* zQ<Il|-=+V1tyUifnUk`q#;d|z?eNtg^r0hAsd^mfwt3*jIZtYlFuY#v9;6HM0D<_( z(N}|&hrr`7dG*0*y3k014f8j?Vt)VJp;t_AEoupyL;p({bge`C)${omUw;&ybM(le z{afvU?_Z;_Bitp<(3qbF9S+RDx_xBdk)!@btmVMIh3SPu*BcI0dJ!3%s|~z*-|Xx_ z-Oa|Zp{{3M;m}wZTvWiw7@F@ytA26BND3xbf!1j_=*3CUIdtUMjSE}n7v`q-T^|F8 z;iES$RG$cBu$iKUC_DttMbDevH@_g9bD-Vo8EO*L0ReaxFw-4Z=~mBRYpSU|0Q^RH zFoc8!c#k5jfVR2?iVSq+QgCwr;jOQpJG3wj)gOCpI1{L#V>_*;k=&7m`A#sL1RGbc z!WACrw?@|S)w3E_9y;=>t*@NDasFVqE<Dui7fX}iMXMJp5hh$e^nx&WUNI>S7V~~u zI`s|UZgUXUjqeM08Ib7I;2UA`p-`9#7Z`fgwC3vozzO4i8~bMuz1ThVZH%0?jdkbM z22;068$9)Gr1kY0#{d#FCjBthFUPLxC|Z6LhmO#|cZI>I$9EgVp(DQj4UKWp(9vjq zK0Fq9QeCH_(?@PPG<Wm}{B<FWhF%l~FDNUhyz<pt)OcaID+XVvBCNw47H*tJi-5&U z-+X8xtdNhOsAs<5Xyam|vk{bm>kl2-H@nryB@TyrVerDT#lg3}?DHDWx3cTXw+@{t zd`TF5_sYWVwJ&{f{URVcvaI384}`nwqNC?0!LnB#M-H1bL^s(WD3wiv89+K&quch@ zH#}A!rQvnUd)=7wEq_-MEPuDW>5G;(EmTd`81(?ozTg~<Z+sSw3y06rcpsTJg<t%5 zGeE-(0}$omaBOdQJUqTPI(|GL73@up^Ixzx=GQ&ux2|V)U$caJ9qbKw7lg0d8@^77 zU~j|Ts<<x^?Ct2$>-=+awaR;L+U1Q^ZpF#Vo2D<JwDqX^EZFP1QPfb@9^Kx4;W^cs z@A^-3(@yQ!dSLG8vH#pxdxGd(+SJ9%%6eVw+yz%vCxh@@?X9g_2JyMdo<*??8s{op zYMDW_io~59T?Plx>*!n%tyEwppT8_;p)v*0szPIO<Ffo!!M#?QP0PwS7UQLT5S^8S zO}=nh;gXqH%RzK@7B>0aYZZ5$2%@z)_T+LFU%+~RJx?0<UKO@$D4DC~H8nG6t`78b zt0F<TddahCAVIu(ne$Rb)@txv3Q4f4jGgQY;-KYvwM>(ktSB7IvJ||t%)*19wF*}C z1<d3sa%ojms>p@O)db2~g~~pyD5TCs=TzJ$WeEgB=gzl^Z4k6Kd9iMG!7{bo)f~ll zCGSi+&7j$gn~ho=H$wi!&1Q4B*^HX4R+GOt4*44i8~UHWW;3aU_FprK!X{>V#9xU0 z+#z4n-k=to>%XuOjYQ$d2*3IZN5YY4U6@AE`VCQ(M$M=mT@-a1wAF0ZnzWcSsGHWK zCOx7{c7t$ix+ZI31C><&)teWsUzesEYR%DFr_mVbNEnS^l*OYI3*rVZ8w@$@Foq}& zn_Q_r>U3%&oyJJNV6zd{YGDUhL}6U(G)E))!^mq*T5NTi*rPn5FJY1-v7l4qzc>Jz zSjBN%s|jQ=zx;)@WHgE!ol)J;R;Px6LR(Z7=!gHPrWUkXEoA>tJL<F|Y9pQK!vE-s zx{G)mCbdq~6f`<b8cmvU^1NoPHp+Z9n`Gj@W^!@RjO(?FFMfWKG-{1Ttwu?vn;ynA zq0052YUjU532LXu$HKjPYjGH?uYWGgj)(c;;!@B)9^}9Fpu5^ls3-RG)HmGYJO9C5 z@AlVNIUe^fm;A$~9zNu69{1Od9uAj3r~Tt+-s>t{r#JWhoV$7G@7?ujpXo7wO_k|B zpXsSHF4O(ra@WPhQ`YG}vEM(n-$#RX<74#=_3NjvKRWlCDA=+em)4KqYFqe`<^F~J zN3XwrAJ((;yj(JO<B{O;aQoW%Q9N$K|20<>*njlE^j7a%3I00Vb|EFNKQ@bje)LH2 z#D$bQFpZ<ie1^f_Tu4vleB65dzPZD6K6o<hod+bo8vcjnxy&E46<5xI+37hCG<O$# zEu2`Md-l*Pr}w{R|E%|kT_Ql;gj*u`+i=VByk~{%nUy_RnZJbR-<>;m1&Ut}zxP~y zIdsIJ2eK4X_V?!=zyQFXTG^%Lz`?6pfaihm`_@LJ<>ks!zY)G<?NZCZwQ@+m9OC?i zPH@%C;&}?*7Pn5}Q-c%y$UEZS4gYKSmGCFR)8YG~Q_;tw-;4e#`da+s$tR+}h`t!T zE&luHH=@6e-WdN_{6utn{HFMCq92ccBK~^xK=k@}F@9_ORCFf#TJ*QkZ$)2?PQ<sy zZ;sy*e>wV6^qbLpl5a-ei2fn^$LRCXlhLn7-;Vw%`se8B=wG65MYqMzM2|;ri2pA7 zO7!;lqw!MwyYa{3--}PhpNk)lzY_mV{N?x^$(_mH#&1d9n*3z)!Q|WV$>dj(UrK&C zxj*?(@{7s6$@`O`pZd+lJCi7A|3)0Pj%Q&O&2-~D=q6d%twmGuRF-t>(NtFJMOpl^ z;UJ5<wXEJv$kO0ny%V*gDc(n0f}q#Tk6%NUC=X}I)y$s>Z|EXr+F7(U2=o7VgFSrF z4c&wS&HP8MCU2707qZ%1w@E3Qt|@m-&nOa6WKO*x^RXW$bB*ks1(!KqmN`~t-fvid zai0k&s45e3QDGJx&gwJ0q}#}v>H8KJ7lW4$2W@)J_-a|)YhF1VS~JIIda*_l<;Q<; zIIxj5^VjikD-T;sy`g++Hyvr^jG{$feU8`mbF_;b>s$`{ev5mXH*V0t+BT|In@YGF z8n|;KjsRG{msyh44rk4oZlj$AStF|--X0~SLPoTmiF;)j9?!$B`XA(<f99FshWw+) zXYv<ZML&al&rCi<%3gjb1c*!;3IJ85KYao?xCdnXmCy>LKclBzWyr^`RE@!vSvWV> zei+T9iXR#|wMMiOb|Y%FsUvA9!=&kDFA*^74lNR)MU>SIE$Zq+;?Y760ce9Fb%z!W zLxH(&of!o!BIS<sj3ORLmf6rE${L0i$_!czWOiuLsGx;%fffRCmK@G%Glq`!{M9pg zO(3Y}*_C#=4TwZGj(Xm_sn^I~1WII$mkx)tck#=H6S6LD<!Us*Gb`7~C;h9_fqQq! z>xFP+h8$+VqddCdrNc2JTJa*}h4o96))q|pm)O{|m`=q4YD`ZGGzGE+nl?%_Z7%g% z4hfrrstC|V(Z~xlC5K1kW)7Mfi8hv@sYA?W8~g?AEko1du5NdAs%2DzaI;5(T%b1X z4%pEV!LBLTH3hraV0R_3i`4><(+eOcR9HM6TnT`p)d3W)_9D7rSokD5Z!n=`VRj9Q zA{tRpK{PcZnoqJAaU9^r^e}=KWT6-zxU2hKA^;mltMvh#2*7Y9@Og<TFg{>tD^#|j zCIWDgqgmttzz*33V7>Kxpy`3gfB|r0;I3wOH37I*0=Q-fW{;`>t_eBmhyYv{fa?yx zHIUN-*ic$848Xit2H;wWn!>;efcq~B0EdUWni;`MI4bDI9<*XczY_viW2-=ss_1Q) zB{MzX=pkA`*;)zF#u6xE5QWMR69q(TB}B>Lk-8z2^>Shmq7F&L?*&l@-{P)7S<r$Z zfMrHd*8il^|7V03HkmLs;fILNa6=L=hSk-CBbtO>UAzril*p?)k=HQZ)=;5(De`Pc zO^P%eZ!>~I<iW}ZMIJ@^BCk=1yr%IsWrnx)<s+FLZ);X~n{t`Rv(^gAEICu3qcwyy zE2uJ|i$a>HlL+;UE}>e-(Rd}a(L2w$Y_y)Efi>~U=boWK?Z#+hgl!_U$)YZFABaXv z!i<eDA8y9O$HUh}$1j0tE(ZBhaJW|+w={*Bi7d{N^i2{{nuh9MR-1^BXLP)_H5h@Y znX!5Ysmbjyl7!dIUMvbBwXJKH$!^2XCvz+A&jnu88EPYG;~8dHXxS1pbT7R?55*x0 z#v6gfP<3w^+m#ej?+{zBE#6m|u9s4`82p@O;8?C$SD)n;cZqR?1iZ?_8-|^DP4Y#P z=*LY)&=e0+iTKyqgZ#DmEqb(=T(z;AwDVBXRD+0zgH9dP2Mm{kg<N)`EE!Pj;bga? zyYc)upb!vsm-U9T#^GL^wSO2U&4bb22r%K_)f>C(^kAZ!>N3_{pVc<U&F%&d*$r7o zt+u{52yWjM-nvKZwn5&wJ1jy;se^x4Z4`?bk}wwku+ba`SBm$vGL(&U6V!{LEV*)c zC|jpXoVDOD5Oh-)c#Q}V<&_zc3JJo3ZNu66!(D#5DacFlhK8`b-0{`QjU1+0RmtEf z&1|g88kfAmQ1g+;mKKAV^yyo{MH+#4{EO3rFZytcJK~s{K@c;RfcKzXUxGGppltzK z{xRrbM%EH&*3AI3PR8XT2j<aL!8}q0bKG4o5MKoRyF)Ywa1FE?drzuc%K$noNNO(6 z3CZ=@=wa%Fc!9$YLrTnrpr7g@=&u6B^?GOh5cRADr2+&HVY>~Wg|76SP8+B2LZ*(8 zfxRyUGma;$6P<#iS?pnd=U>~T{Dc@p&^{7IaPv&sein-SP1Z;G#4LU0K|-GcU1D9D z-N2v|=FJ&cNrwJLF&L_D$dUemrdrQvHq0k9;wZn)Alas;?cbI3Q@t>V7l&_+5PO~3 zEas<IKTPIW;Awy<^%=40IE&OMT8p~SWW+5$KOv3Ox((r5tSoRnWvSEZf?90|C1FT; zN3}GOW>lhoS8a5i?Aa5Ho{g}q;0Nhuu=}ut6SpupabV+*m?03O6M!uV@Q)1);feTc zLwHQK;=jW&{3;GfKMi4j8ZaGg8i2p1p_$2m(lo#_O29V^@J#_e5#T{-2l$2nkM$_P z!)gS0fgCl0diYlXJa-0o(DDa`n;!6?2Rw5NQXAl#6~JT0y-utTTowYYH<4#Qz0jjC zXdHSpKk+3)^E(`x`-f+~Xb-;tdlEwmH-j>uc^<fCq8$XPeF2wlAzQP<y~L4`8a`L! zu*@(&4vr|J5l^pH(3O#D`cD`-LEg!e^cza)HJZ1YN4n_3(6X93Qw-xHHjExrjcJkO z=}d-mVIu0Rr~G1IqtBAfak}e$@A|~!UwHa&AO88{x9P#yuDkDg%P;=ZA3l7hb=!$O z1yp)re(Qf(f+y!Mq^oy35dGBIK$Jk~A}z?9X-?lR$YmI4uB(~{=36&Oo|PP@;4}kM zkb@HGUpZ0mcAVg=!FY|Sb)dIdH-2-WFh&~x>%$ns6d<euEkA3#g<ka2N$=C-G1XQ0 zO+=~G3Am{8j0q)3ZI03%7Ybbmel)Ohu9Neb>o<n@NxV`C;@2GFgKwz+pr^B9;MX&B zJWBtY5LjZi1Ba_Ak@mo4w4VG_FbhhIxgyA5Re<873u+T{k??vGqf=$++g(K^`n3P~ ze`W%_&`CjL#hat%j(E(af*moMgmHrOUQdk1K7w6h21|jkWe~AS$gXRjlQ6$lfqjZD zJ8m3iO`dyp!o<9#FA@?m%2qHxUW;O^_jXao&D31R{9uSMKdc%<w<_n0H^;#M=i@<h zfb*#hm5kC~T1j)_YX!}bM%akvfVV+0hTFRFp9kPP&BcA1i_eGV?qX@LLUSJ8t}>_S zOMBRq%eMurKJn8_FmL2>yYB(u{aCN|Vl%m#xc{(Q=kjNsiEn@beD#b}nH=vY&$+?O z@%C|KR@I$|`I^THvb*ZZ`bldit6>%8?Hr<|95W#9)~9_z4DrvU<J7Md^$T7g+6Ls} z0O&p_^Nt^kau<pb@KB7vE(UL^O@Gd~KwRQ#`vb&#u7u2aVxp;wOgi(6M}4_acJ3_o zUi$M2yySHxbx_z~7vUvcUPCAgO*G79LK#dP6v{jp6v~F00x9g?czz*Xtwu3bLD=96 zu;TQWS4H4bXpq?p4G`FbMjx90dI<byBRIXi&^R3)?lm06U~_-~d%0kiyxg&g)qXv8 z%ZxBQZi2`8H$NTDqenUveKMGL=uDPI{zw3<F}!<B4fb@e1!R)+E@Om^iD(aoqrlq8 zxVg4R7Y;;InfQ<{Sxf#4F7jWD;8n%!lK<kPIH-m(lk&%@9a}Q}gdrnUEI97a$nOg= z%krl%X1{c+<R0(t1Fa;kt*wuM*|%eLWR1<d*by(v0#PA)+0jp$e!QskKD1TKIuYGZ zu{Rf48~K~n%0zU6Clgec{;7)4bKjbv6ga%BaQhCt(b|28c28Qxo1-Ptclq=Kr0?*J zq`zRG!km*GMIRqR7aU^HaeCJY8k63w=5`w(L)Z2-_%sr&E|JaH#*lgvqxb=TUdXl3 z@{s&Ny935q;)TuKk1xTqQg3f+zJ5psENw42aO2a^q#W)Q;_3H|`=BJ>Z5U5?rt<J* za_OjRSM<=IXL`eKQgK~eO=q}hQklh}Y`C1%j=MD<HaQOWM)F3PyN0Wv1@Rr0h0_8U zG0u>@Co^9|L#t`o2)!$)k?)+zdvt#!eU~7muIyB|k`AnKm1&XvvU0NOt3K7j)yllo zTR2Sa{)&LugP88qtctR?gFPO}hO&10Tf!9y+=loOPqJY@0jJyqe97UT`aLSjc+}8C zY8$-m5pE`;mosQCd$HeGx_n9a5oJL}`?kac)x0UW%(-EB`eoI7*jold#JIU5`joHy z&X846s=NzlfRsXYxu}Nni?SU~S>F7!0qG@|{*<0S=+Y47_UHkZenijdl|~75rC(6n z^53wgB+@N#?vES>MGk`;<54#z4Xr&%ZR@oQ#?Tg4Bw1THg#W9VwM!h*>I=7qL*zKz zYdIXU+);rx-<IFEk2g4@THsA=Xg)EMe;aavuat;0A;h8ED<_?i+`+)!<R(iTA$_Cc zZUUAkU*lBikK5ouD7@D;cot5h89(Wdt(bIpW`EK%VSpxGP??3kxg5(kl~ZJhCVF#B zjO%02@G0Lb_3#6tP3JA5Y>7E=j}mie!mu@AZPsJ!7{;XBb~bw}F=xzU4h)kX8mGr6 z;R{QmSbYtLD;_@#SJuFfzcLWvs#pnVVGRfaofJs(=pdu2OoTvO&V+8tsn87w>gGZ> zr^8ZbbVQgHT<s%+)yroI&iU!ObKoVqPt`mwwQz^X6kF5(0TKc`3r<+(iRddm(g0Ue zdVu$NC_RNTZXWIdH^jM>@zM=(p=z?E2ELjd(jG5C0577PZE~QK>IvbcAPM>=yqr<o zb?Xw`lDn~)^f8myrqxqEfpth>l>!=8&2QWo@?u<&%JlIyjHUCMrsYcMxs4@RegC?~ zQaL{t>?Xp-Sm6=6Ap7a}24NXGHPS&cngZqVJ`cG3OxEckDDPTVX7g1$u4L|Ntxlc~ zuA_dM3(%9J_qxFaQLaKnpm3&Yc!tXjPaX^!o+T`UzDXFIk4XrIYFOT_7G8)Ex(tXb z`No9+(Gb#rxGKZo^>T!Sovi}If?+%x>-{+b;ss&if4~doPzJoag8(V-t7TaO7v>#N z;ls>=_CVG%n8%f@&HPMU$+}my{-()HGlTDtwt$NDR=Yww;BF*+)A4D&dNb~1nNQ$@ z$4<K|ZMj9ai1s#Vqs#iTw7lC()o#B+efh8bzMK&QrNJlcx;;7_t?bO>^jgiIhT1N) zHz)B)3_#{&Qi058*mG7`kXp-L=5RMAjei43<!!UyQ1x&$B>6TiH^{xjGJTDewmc!= zOv@79MTt%G+Zv>25A+*Ddvtp=snq7Evm=^t*>|$^N2$wax~K$N=rB&w%zt!dgdlL% z37Yw9SkuVs7SM)?M0;AE7@`|(1)X8NY>!5{fa@JDbP7tqPlo`q?xeqC9l=Ks;DX0` z!Q|3nOAGglR4+mWtxdNWQ_q^xM~TT}aPmTsay}_3EiJ9Av?BtGvgTn`(s^lbnCz(h zS{&#+J*1_LRSIXy73Q}K$&{A`-kBnMX#LR(`5d-KcNo*NMlov@7!%GIvI05=l~1S@ z;7C@1ll8GJTo9oXg8x#n_}KnJXfcx-w_?hAIAb(nb@iJtBw{`yMMybI(pI8|untko zilLJl82YT{hq2ae=<S#GItD35f|l-vzDnUtxe^<?OjYtWW{T_zQbWhYqfic=WHEFC zSDaUn3Dtnz)WZ3QOLQP<{~sc!UpI1!JV|r)*>OBR?2(X1<J5ql$yAZGFxGxk0))H+ z<&i=3S7WRZy%@qY)CcPbUU8`6(8VJ^WC2#TKwhoC32g&*Gt)=rMfeC>s>iPHD7~@} zH67`abq%seyS9!uibo6zGJGfavWsy6c+R(yF;~eZ$fDJi;LKV7#6=)f2hj@*XUUXq zo(033K(;#+VSRw%J^YWtaVD?@%&F<@NZ!tC3!aIRy|y5aCG^TQa_Hf2h3a_Z<XE>< zD#yB|aJGX&dW*B2i9)N_Ov4?8L+K3Y-N*jjJOn^jXhye3QzB47mQvJ^{;r>Tl%oli zHqBE-Oq-3Tlo^-SIN25nXA~m+hLXL=^JA`#=W5ERGyQMAMjm&l2V`n;F=S%x@*AOr zpgM9B@GJBNx3n2BY6A&`R=t*}4U60}8d4>-MN<I0ewa8&;4e!VxZ6yU-&5Ku1r6ps zLO>a#hr0+&@NYQy`&)3JfWM^-{+2TMuSIv3!{!C1>*u9?Dhe9~4M~L59Y8f)Mqw~W zdWs2^->M)gCr`xM#<gHwSF}P4@)F{arwOC>(XLx07zhoVA?hd8d#g6=%ZE_%A%tFr zn13jdl3n7X+Em+-pr~g<ny0#pBOszh%mJ=5lhxDTvk4_`VS98rIa<u!_GqV=yQQ{~ zZ<}~E77)2lx#_pUjtN7rPHBaye(<g(?4JDld3}dd4!$bu2XPQCufg<njjGS9KV5-h zcDNTgAUb%(;?3Q}YcQ;)nlSK7){$EmsCiCjvYOsN#0AEA|B8XNc{pp}{9y6#4x%Fx zpxwx$g#u-W)!=R>zcVTkMr;d_3s}u(2_I7q;W*;o)FdLb1X1Qv0XHVEuA1p_3wGmz zF)^j}M)a34F5oyWs=%f2l#7CRhVvmMg-8>drp7Fb^8TVWHV8q~-at6%!25LvUOxe0 z{uws`6Lj&ZfeH9xBsnPcn{W~2U|s2Ek63_;9F)!vyL9mLa!?=>?huSoQl?aZSyKUq zvY@I0ppHnkc@=;n%L|(dQ1f!o@*)R)cF8jscnx*Qi9^Q}&;$w@j3(eF4Y3WK!(L>1 zaS@!6$@Ha|NYN9MMA4RAJ?$5_rqx21!XoNwWs%&=)&nMk5(J$K1-e5qEeUEZ9s?bx z76T*A_YjaobE5=w-EOxZV*_5n22P(Z8*s8!6x);n>4=*?8Lm1O0O}1v_$_9&l&w&z zIIxklD4Pz6vB9MkUz+}bqiP9Oi`N)+p-RRmhF}y(M=1QfY8Z1hY(lmVY1e_VikG{V zuX8P5OQVnC@F=*%AC_FAlvWqYCVUlQ6R33c{B$X$9;&d3$(3y4i5NEFj0<BEh)rV? zr}0cdnl&%MOshE`CUGWSn@KzspD&YmT<bK<5xqDomq?&|XL4yOqkOV2=_PKd0b)QB zINrDpX8#>9g?|(z3#K4xUEvA^?=-B(0;o#sf~;YGB~LIa0_XeE8vGI=SEcoef)!~k z;VvmH`mac9$ym=7cnZgjywYSTFSL1PLff91aIRtf67{HFi9s|fd;&@~J~1R6LCeG9 z6D%Rwb;#)m#ciKYJP}hDYP<M^^~tI<J^>cko*MX7l}|9`#wVC^+vTKHLC+}Axl4Va z!YJsATi`}>dr%}~avr0NU0YgzuV-}&#n|mq41SMGp+!Q8EK33@E?Zbe;WB}1lFK#- zm&>Gd48;+97NeW2XS~gnE2H3U2Y!dP@M4UADU}Lm)RKoDwoO4$59^q!{IGP#5|-4= zj}_d;I!de0s-s13OlGQz_&RCFmf9oj*iyTt9kb3ai?1&IbAk)X%QzRboXl3Udth47 zV}^kTf`PjveW2*1aZ?RH=`kk6xylK;+}Y^eKy8dR+EL?d=^v>ANpm<aJiiFago_3x zcgyem@)9ag`nD6~wakLK%g^LZBKDVYvk;}*<{8qbOI}GdNgbF$N(6+o3iMf~qE{=e zU<pZF=0<@?Ml-LBqm#0SGPz0_3hyuk2X1oxWYmLy2a|%{7akqdfp@rtD{Sig(XZG@ zyawl0j3EDpi{H!0@z_9)C)uxFNWYV;#Md2<0#Tgl0G@obiqtzjQsWbG7@7)g^D|(T zits;v703U8;dO{VE_;xKT&|TutUz|d@J-Up42D?Hrz2;qSxr>Z9X6Ckfqa~?&oa$2 zr?y82N#TY+;QCkB`jT&b7@Q8%-!IU``f2T~=x3#W{jSK2S0@aOK~;^Y7>5vb6Agp5 zLTt?AQ7-fgUyU%9qG*+dwjwl)4PowY6<v{iZKU1l(RCuTRD&>=JB^1sjYmUK!;`|> zF#W_byfvgpdpRfGURU64yvO71$zpY>{iV<Xw;`BS@fOo=1>SlCR(K1=nrir@c!k5( z$K=>c#QF`Zo$V|9cC6h}#_la;c5?-%<w6ur!*mU&Pe;!UQgf7M$*HkPVvo~+mHsot z<MT@4R1>Kd)QPLo>DU&~+yIZg9*l7z%EYKw7CFLLiY#6$TFJdmfhwVi5;bW2CW&=F zOSo>Y$wP?~%{Ic)#1+}yV`@a^l+WR8iDwt)OfZuJj539uJ`f?a!>o9o5xPTw)S$Uh z%Zs(cQ;yQTx$!&Ng5_OCXkcdp$`foWVOr$bWyDU$5L6J!B}@oeDPZCefHiz3c!Dyd znDor0knsW%&&Xc^k{F{*JqIMVFbYJpB^a3s(k#GK1HW8MwDhosQ#hQ+H&_{pt_dqd zkU`BOANex%&7MtIe+M<Qwcz*JaINK4r;ORk2WTt_1%K{Gvx$>xQIWn!d63>~e1XXu zwJq2rQmyL;ux8@w0{9*Yiwl5Ajr~u+{I&8fFuIz>W>!?`%8U7Qx-JTgxCP^9s)yMV z?sgUUfN6q2>*Gxpv>zQDoj2o+J)2Zxf0y*G{^$@x>QSW8MK(Idc8`q>@il5DI*B#L zCLzWSWEtdVVg;zkpJro=G`3|G`jJsxN4o>S!D3{p1?E#3Ra8;~qk3OuR0O8_QBl2o zZRvkkZ=)$y2v0y)i>J6_h-_!m#rCOyCpnCPDD-bBEC5Y{+&uGw+<bG22tcH>Qp_wW zKaxrsf4>#qhFP4vC_+TrU#Rts&qc&<zlRg*;bUr2li+$ep>1|dgOo*W*pf{lKzjGW zy-vtC-8Men5_x8M(g*wXxY}HO3x;?ueKT-3BI6c(aiJ|Wg|-k61(z;ETX2m;IX3vU zT$r(Ibaz>>n@dm1r#-yf-Ayv7)Wzz|_GpjNQq}CTlZz_d9&ID5q0l6&?&_w7m-g6k zi@<dw0G;rEVAJGb`qnr2EH|Tfkdq-OoQ~8jphk`E68~iVQ^)8Z9TTSdcIlYz)I6P0 z+hkEUfg+u`!ho82sC}mB4pAl69fc5T2GoMqXi$#cTVVVc{%e~Ly0U3ycelCj5=!K| zyHnj|HI$y&c8>(U=L7@2!D8GAov!(g4!HgUt<BLkAE6-C0N=ZVM*4a0#IxI-<*kjl zKYl@j@q<V%N=QS406ppnt_8_yd!yW9-Uk@vgtIuo%C0TIml3M(5%HAU!r_DW?o7fB zh2Xh=0LPm%`6&XE>7inCEDa&iK2dY9F!z+CAj`uHdEp<D6oK&&H!F@HD^GIBJsg$q z)jcGMxucx8eTF6X)9#2a9d=hr3t;yjmnOR_xg^?MnWg#7n6KtH)Sk6$17v_8L@gyR zvfT`oETwR^-v1=&or1R^$8fezPx(o*5vBM^(sw${RZp#5YO)=0c`bdLy=rG2FI`EQ zEr8LI4fRw0E$bWp87cn^caeW)jw90*paNq<+OK&ZBzm1@$>%5K^Ba`1VHOl5QNcLl ze;4WE`QJsx{~iqr1}KmK2;+kn;Xy(zfHI}hYD6CFuDUG9C@pRXHfu)iFL0*)Gg3t_ zfqE7L+(cE*r>!FCqkZ1o$)j`d=6>Hib5^HltusUSG%Whj=gR&14ZU-};Xa}p9--Th z)cU&8nJUUML`9`77)KWNASr}4JvMp*JaC8gZq@{PP%yM9>UwubPaG$HHxY1O6jK46 zYM?z7gG9g#g@BB)n-M^LX^$;2D9XYhZef4LZHY#3U_x~x@h>gAiU+@0*Gny=*q$%? zdk%e3EyVjX`#omrPF~2UIpMFjaI3bqM>kW0$zKI6|C#YWy)X)-_x3hV=FlgUOVu?t z3JNRO^U1K#0N(93=qdNzp{UC7JGaLTV+nO{W6Ak)HKKSdjhD~}UE%tUKrBk_0s1YT z4&S?<5M}Xok`Iy;8>f*|WDtMj=>hp#EHx`%i=`eVXXbK#mYfqV$@yERf>7u*8B)>A zlc77tVj_CTg0`8@{GlS6QjCx?!Yqs(EG_u}NnPi3b5c_~(9L;9ShTc3dy6L_bI<TD zOppRiwsD~*jCZm0Eqc8f6_qhq;R(quOQGyv<KAKbTtO<nEZ76`z<Ld~C5sPiqn*g@ zOi6BE)c#XXZg*5MI)7OF2)xE}kJC@~$?h5%N^)RD$|CN`5e?ixKH4Z;<re0F^xT3K z9JHlVCATn&bh|ll=rKZ>N5&?gPFD>>(YlY*nOt{#Pc$&=N1-_JHvJ3r5f<?&yGDw9 zb#kcvhZS}23_PW<aS$*#&7-LZ@)WTeIdqI>jbfvGlToZ}b6Nz@vSPpmZ^e-ERtzaw zF`(ua;VH0Clu-KHq7-UJ_ukTDH@3h=+>hRF5$OCtwZFTD_ruXWTbqyTxEvrXsKjzP z_<?LyW#YiTDj&fc(9bftvyj;BTl#xmP;x_8hl+Q!)S>~$hx1KG96{Ro9E>{l6~)vE zE0PPVmzabPt!1q;SjuFH*dEimDgBU%s(yS~`#cfr)BkP|%X)HF#QMw%#QMP55Gz;< zv3y*qi+X|8N-i+fvL1vzTog=EMIDZFIScBb^BEn0HrhNO<}rviKNg-3CQO_K6Vj)R z=Szg0H&RTjM2bJ)L{A+M0JRa7;E2vPqwzo+^SNN7##074I}pg9WfjROfcw|Zd$Hqv zV8nLBnI5ikmQ|>;tRhwmx_0^&%jrlv$j6TL2<bxIo%bTYfsQy+W<37`nPX+<egB9v z<$~d{Zf1g)B&#MIg4As~y5ztEBNC>Ad64x0OJ&XCCRQ2Z8}i^~I@<zzX%;#Y79tB$ zMvUqJ?lN3`LIoBlycF>qFoZv#L)b*OnNSWsfZ3H?3xxx7Dt)h!A+*gdNxDQ2gf|9O z=cS_-QNBBd#Wtz*7{J!RxOAPX2FKzSy#KO5SOnZrNOXZa^_hWTS+ji|%9^WCFs0_8 zXFrNNgqZ~S5`37BZzS;X9}I<%`kKke=kvD1@@(yI7H0~urC!)T?ZOeKR(TJdTxzHj zsF32;Y8!Km#<bo+r$gU()NkC-2y&qVur_)5s6*${QGG;hHe7s1ZeODa5?6#R9n<4( zy=ca*TMP4Gj&*9t4Q!N`-;Ifv3)sYVhYLcS1qLZ((gj4EcASM9a;0;YC20RYtAZ3C zbP3aw3F~2kX>%2rDx(ML@_g-oIww${h?fJE`U<E%9vf6$wRF5_emPXFl5;{;3vJ7w zs#U)45UMLW=b`$hFye^&*Lh8+veFz$V-3^K_vMhe9GS$;(WrCWAo@3Xi%BXt6KUEZ z!oL?wX$<9|()kv#@>dshz1@4ii{5zK3ABNreXg$j)xTX={w`#_s$y)_ypCYo^R>Gk zOx9TvIAX%|r12KG!1m}mH!yJqQ~9??*IG*ZCid_I>mAxTW5ec_ptSUU6P~cmD@@TL zu^VFMa*dga?CW&RUB?A7Qhi+Zh+?e#mt1-LD_%jV5ouK>3pYiCld4pY689QrT`}SK zJVP<!isw(fh0!=<@Vo^5Ib_)HHMRu~$a@;5@3UJYZjm#64Ae*M{ChMwVt9}~ZGDXZ z+c+tlgKruXz=3=0vBQC>7p5PvVu3@*pVtk;5ihqBNn9G$O^nXWLJ)2}bFvulPaa6# zpwh`;6no>&6R2#GvMWoKDX&C`GFXP9@J^d@3+gPU+(K{%I-B{RPqteBg4!0)r<hk3 zDYc~%-pcg44Pe)}DtEgEu5cHIMHNe+#pAAce_E6Nv?itZiOk@T2j={R&fq%-XYgHX z&)_CMfrSZCLhZGEIst1E*AtB_eT%3<NKgV0e(xP#4kE${u?0^K7ApHI8a6YEVc>On zuXht%=aE_<_9MQhGG|Kz_DeBEF`ycpArK(_umP7gCg}-!CE)`|doDZO{>jUQCbEi= zCNenfB7;-arGMi}gO0vOaX#8lX8k1d_-v=<rw6CzXV#vY2|qReY`v<`7CLz8FcNS| zS2>V$EK3}D5I9l#i>?}h1{jD~1R5?o^PSovSaAp&XizJhEp9H+uli1upo#jRiSS2y zfJH*_XA7El4}#`BYlCK|2hF#ZgXTRI^Cas-Ml6L<b4k!WoyO|i9I*CL9&o|aA&ho- zvI*=p(;SobZ>^Eg;r$DiffbzlHQxzLm@{P(mA9}41fFLLl9PiV`PsEWQeew7%R%yf zVGD#;vQ-C$AfAdUe|o5}X-Y5NJfDLh94obPq!xrxoY)3!hG(1KwZT^wy}#4hY1|o; zmdyrc`8P7?(>JTf=&O|?N@G;Pv!zUiQr+4QL?|m!p{&R))8{yFX$u80mg7W)!}0{@ z_~3MqFa3}JprMH+@9d$_${<MOy6G~gZpfS1tuvB+b9BXycrO!A>T<cpIL;8qTateY zR&58w<e>}%B;)po!wyyHn@JvYMNWjhnk#Sq5aKh;1%;=8lSEYKExo#Gr30JeV>|Gy zS?Zt*m94qh+4>HaD-1-iCZwA9pr<Ds4gKUqKiT*6{BHqJcBc{$cv|EL`r;*!crjzP zeF<=}iO%iZ!~QIyo(Jhu0$jW2U?+57dPb5k2(*Q#pSRuqkE<e7gstGG9^}E50s|>0 zSmg_!N&5#ZWM~zHtc-+VI|F<ruAEPC0PzhChjz!7q1w#@!ZdW0sm@e=M4dG~1H?0& zmyalYy(y0s|IjV{Rr9`c%1ubWY2hl8iwM7)x`Jrzx39}XBEBt%I%_AinOxO51re-l zys8AQG3u56khd7a`A(f6=jE2$xdP;G^^LRcbzUF`#dfY7`SJ6aZxq?(PQkbN)(H$g z@8(&4d3KOr>65(8X7D@pNuC$7f9H*-w?0SscE`URzO6*|pZvFLd?m8~)N_LD=QZDB zD{%eCJhH(n&x_L1H!Pp;cMRHlYcQBoE6L1>F5$VCVlh&^Ou9w!iWlxIT#uEO6~uRb ze&Ce=DSB1P5ycptA8g=4V=FuI_KMDqFUG^i)D)+A*%rhA{lt!K4rQf`BS0~<vdr#A z-5u-hV*D)K$SGrQP%5O~MrbNXYJ0RrxwP137uGMN8vK}{Scq-l)zN<sow|K_r*7;U zH+?b508qz+^nWNfG869w5O6M6^bCZzg&|34_qY=&#>=Oxg?&+T7*>SykFF-1i&j6s zW~-=|3MV62uV@zCk}xc3aJTu9S;wu4HQK&ZBa#AAta%h_oV2ok@M=tHPnd?bW_Hth z+CwNl=^NF4d#jSm^+VU*SkYeLVQRNuQs!PO3mO$+A3^$$WxqVOw=X-}Fsuh_)!E)h zv!y-ha&0L(x5a~2I%r{Mx-32dJ17}O+daRcC<^y*LvOcl@?q-Hl=~9^ajNHx!r42J z+UoX>bmJ1ObOY+C0j$V@$LY5P?}6Oy3iQ_+JGC&~&)8f&8wi@7&~AT=ueTLmzKY@N zy?k5(GkirUVhhsEM=|=xEF~u<R<PMtGFwRE_qbDil7qmx0pDS_F`iBiIo2X7YuH__ z0-67fq=l<);xHt1SRU8&jfBpzCyW=02q&zoSQ3%3W4EZc|EOve7Mx>z_m4Hu<}P^~ z{hE+@8w+36t*&Ezv-)<vI9Ci_hv?NDf1?wOH*wM0onAw_H47Q6#>m4<TnF(|wfs@; zhh)|TUHMJ1`)n79&Q_(zr(D#GeDq+G&Jk947bdCPwXonlY#~wBB4b-<MP&&QR=9eP z5n;t;w!5xCJUYdL13e!xuXRj4IG7Fp5FvZm%)Gc-XIl+tBR>R`*4fXM8}+E;3AyF3 zO?MSIYZfn3?TJ0{8HfCpEj_lVulU)PopPw-BuTNKlTG_d`J+2+DO}KFolr=lV;YE0 zvBfy1#cXnrLpFIWFAr^Mf4Jg*sc>)Pn)w7(kzbC(8<SfMM7T>dsDoEoPyhvypQR+X zEUYLgr~Ee9MN%PzUT&e!ETlqMpG}33G2084bN%11Xf5)j)dvxWrA~URZ#M-rq=i$W zBiKEo2u0$*iH_K*lto8Ox)jk76D~z`L?)CM7QwfE)~Q(9_QBGMCV)6KmRmFdq&Q7O z6F|yhXT9Fvg2>qazZ%r5JCE;tPM}_dG^_%2sC`YCqZO?V+za+v|Cd3A^K87<Ebo+C zSmo<qdqQniAY41T&`<49b!u&ci%qR|ZZXMBt?@Vud<panE@?Nr?WW^y*s@36ONIN$ zCY6AH2u0Hy6Zm9PZriGCcNn^x3$x+vgrR}FCvi5!<g+_>^~vuq4&0-y(U*vZ0+tNU zF=UP&(U9pNDKuK`4>nw;3x1g3t&S$_DBui^sN)co>ez6VB6O5A5ga1|*AVGUIYi*P z88a|2+y;&(Ew3xEtwUVpn7qu_aKi>3K@353g}d}YcDJDbJ-RH=ZhjzQ;YOrbhm<M| z4ApfPN#gwyBQ!B`Za_UZR8n|#SD_Lt%4JkC;67QMO1>HOkMEJ+^IRCqq-px#lT73g zsmxN^mG@0Ax->mIIpNb|v*EDEW}rDZ&D(5pM<ktW?wH8lI;THr#y1s{she?k!v=Y7 zwzwPBwocy8x+EvR3YRoqC#giZ086Kwxg*9#K%l~SN$nMuTgn`vq>z+iikt~#)WBIZ zWw>vF1u#>O1qw;Cac{GT)>sETfQt|q!-jB%!3RESvO3}PJ*UqNJ`^6ORqz2Le;Gau zc(hi>hr5$-xFqLu;R7IN{tY3N+~P1oNsIfoIYDA7@Y|(<&oD{O{zNc!N1%Q?J!u`V z)npqP@3ET!);8hZ7_Xo~V|pg~0KJu&v@#ASoB$LDv{#xp;iDBAGv0wqtp_!m7?oe% z@)=&LvzAdT2p%W?u^yv%1L(QVIqg-OXSENZmT{N3B1<7$0M3iqS2*rBNR5Jta5P1r z6kCSHrhtYR3(rUyV-dpfY`30~Vxy*GXFP)?v)NG3CiTpab;_qU$2;}5o^7zBH4Q-@ zwABG&EhnvNZNQQ`)L3EXZW??{c&s62>s{E9^E0O4Zn_rkK2pYazUw)W_@%pY737si zbQ$&zdPG-8-`gr3JG(TJ<Uxuse??fJ2^AE6;e}qMKVRrfgZ!Rg|J7-`FBG2}+P)xO zKj6At9XOw>7-?w7#kws@=Bl9>NM2TS-#@4o4Vq!Fm{rZNofR`|$8&6k6^pNh8CEXZ zKVChFuFAR~UYQa^J(v2lO|UU8j(>IbSAw=-W|(c_hsHsT()?1Auw2hHO!q!%)Nj&_ z_<tCIsc38guq0~C?T$SZjOc!M>p9WA+)WVwZ~X8hEcp@AR@qm@y9YvyH?AKcfKTQ= z$p=H*r)dWc*iQov;tzAGUvk29Fs5tK9l7k*CfQsgbiP+%-5kAKH=19U-}Cw<vT;k> z{*cw7(mErhaiNk~3RWfEs<6)L8KV7!MuR4GK*QBh@pZ{S&NWd>-rTEKd|+Zd&_}-0 z%S@A$`dlrMm#b3~jMSM!_%sX<4-zzXD}xK#7p&TUU=WEvXtykdYCg@H!~ujsO-Rw0 zU)n$6N<agy6YZ~8i0ACK8YA_3E<8rc&=>~>mYB1qrYlimx^A!3bzMM@G*KWefrrk@ ziBh^uN)jk&<Sy$d;PoQKQV0#nGf)OOXIkrK?PS2jqci8y?Lk;S=4HD!L-!ba>w?Vb z!X3Q|m&fxRK%J`?(>SHv3aan%>KyI$t%$E#7VGx+L-ZZj$MhcK-FTj*<zvEY^M>7B z&IR{@94ELBu5ov}x!_xQITyl3uXA_TW*z&aK!@`>(hs=$<i6oCXLpoOPTCX1XYnLU zBz8O6M0S_0+LPSO-k=?*sg^v6FkTE@j(f>*duaMEM~+i98tWYv6b)r>k6;rW9W`*b zuH2vG>ia;uu0<R(AOb(9A!vk?%aJ}NM&O>}+oCf&d&6iuv*~?qC^{$}#anL<xG!*7 z=ML_GrVUbzK~<(7R({(Pr*BHyxKKq7mYK2;>&kYYp^4AhP=;+e!9Ke?(v4A|n(>tL zK#2wGqoNiSAqP0O@L??wC^ck07^Sqq&vqZNA5aSjUOR-<>dvc&a9iDZ{m_6F=3YSr zR9b65o$C@kfd;5r@g^~wdbWWSJY5fU)FCsfrx%g*dS5_ti@lEk1e$^YKE2n|TgR(r zw#kGTFPTIZ!A(ft4sGl*{89($&|q1~7K%fV+HyuS(!KO~QCCZVLZ$#`wsP4KJb_~H z`6vHjoIh_SQ-+-IbmiCKDpKHVT7*mnAZizqS?lbN9q2h6K&?wBW%e6Rt5y7&4rWmc zY>5Ellw#2E52eP@6G_o3!8ox-RHId!Ym6+{vuz~+Hh2I)Qq~0&iNP-Hv@RfFEbv4Y zRBofT3fYb0DqKQdc#vM?YlH->MnZ@OYh)@lDnym9QiZKfjwUjfYiuDnMBEA7y)auR zv6HP+bWKO@-eW=~2{ecG7E*f2Z6YEvn=y-mFKBf7n<uld{i6fxMP8?}aR}3e?gTCC zfHm*mj!nvc(uo&)sc0B*)wD4D`-O9p2j-ycN8TX_3BkZ6SjfSjO`=NJlD4h42G}2T zC2xC6>~D+<ccWZje-m6_f8*{h<HB5R;{y9L(V`7-Eg4b#;*59fuVwtO#q^gCp0XS) zlK32AemkiLNAEFtsYz{);3?O*)Pl?qdwYwNhN4370mhYn#aLDgT7_e&2DUSf6%31e zzTyhXlDOP4EUSEK2eE@THrl0V!LP_R3%|0)4vPI3c`}`Y$F~x+gn0!tR_C2<f<z0B zrX;hOxE=z>Z8kPz&sc~t9at0<d8047nHzws?}vkuEptCO4$%S`;VjyfmJNZGq{!jC zt6AGUFj;ITEs8JU+zm6^1Di#~z8E}l;HS;bZZ`nNszH8RpeoUmS%b$3ivwOS7Mc_2 z`DGbIw%TNoXIee7h084Lf|A{s;qoj%rn^1RVfsO_dqASFXzX7_H*1Z^Wio!vK2Xb~ zm8miR!q{{1!b)%Jmp0x@=F642$keu6U|89RW5?u!8CNs%MA35sAzt(tvbAMq2{V>- zTvz_@XEjV4z{-lp`t|;CD;nx=V~~c?9w0G#BGn8~h<@yDk_#ku1s5=E%H3Vg1rp-} z7%Y~UT(|qXxi^ixEnUvhP&<DbqH6cGj<!ksc7Asu@u>qc+s%dbo)gBjND}S00~N+n zXdCfC_g*^4F_Q3fc0hwnCL=f{*tQ?zVKtW87K?A&p=8!v`h?M<J%Jc$XA2+hsV#Wz zf&1OvVGU0ELhbvV*6torx<P^7nVRIa2l7o|&sdMg&3r(sS4g9jd&Dc`i1y$$Ar60n zE>m21x`3i1ER_<oX~zZdd3sNpTZ3`tj`NQ;i9B`Ii5(q~N{etyJzU06cj=HVY^ZBY zg1Uwfbp3PGI?Pc6Iy-3tVDzGq!GUj*L>nZ9nv{ilRRxo)Dj@7?6;>3H){*CNgJch< zL~a1X!W%9NMqO4Qxjs(@fOye`08t2qfVas+4sxYF9NRt4MQ3|AniRFIcX0_rJyV^B zEabg_8KLND*CNt8LVLlirV%X%53H&>wu|5@iS3mGJd&~Ha4HPiaH3HR$ChQQIN_lt z)w#e45zQW&2SWj9l`mgg<;#s#`Lem)j4wM5{XEBE3*C@D#@X|%tD3ia1udEr!O?k< zlGo&H9(5*?N;vcAd|0xSiWYT&PuHpMvLzhI{>3QA_7m#hSEwBoJ{vW4V^jwVa}<xx zZ7@<i>1Pv#(b3%-Of%S!T_lmcL0Z=)D5OwU`0N?w?W<!(KZ_~`yGR#Rtds{Bh>{0r zEt;wOEArsi>Jb}43yVn}|Hc46!$%^~-w>=OxNtYlg~{8=1@znI?k2e~^eeb9K=er6 zU5+{_m!f?2o|!eYEWm@3yEXC?7)*^mcf$oBUS^*%TBNxHN^XvB7B92Z3FBq<UZKy& zDD3JKDm`hyb{|6H!1Re|G34C4ZH3angWX0HBs`z8I*m`+=xDjhr^t5;1Y)h-EbJ^Y z0nVm&D#(<-*7Q3C4fuGeRyS61(8%$7h-HKz&Cmr3d!jU1w3Y>^^cL3URy%YUij%5l ztfXd^IAf?VWt^!b{0<8TxMZ~HD=~81Da|-?cfe2?08@Fw`5_o9&@V9`Dz~%33OfX+ zj5kB5#3|339ajs}Y_-7l25Zp+I}0r^u$1-yP7u@H8`(Nv1t?-wsNG6OD6PH7W`m&M ziwv<rz+t5(W*i!u4PyGY7N^xKujtc^>uMyj!j%9_;Q6xn(5LjRJJ_VOC_~@t+=O1^ zXZ`g9WP?Z)*_a(<-;Z*nfg1I$@&M>THHul63MKnb;=`#@=val417!qT1Gm8uktAIU z>MzwNiKj$)?5HC6HuY)KD=ui`w=3#XPL45jMF<U$cBXY;m^q)mpk$EWB78EXu4t&x za#~WK@G_u2p=~2K3hjy3I82XYkDMot5EEK2U&eZg;o)9}7&5i;W&4BY)GyUnkh0YB z{HN!gN?CMKs-)?bTt}<QpOqf#mo`0<Oj$LPN;By~mjMNFTRFn^dt)N7E`eDr253{O z8cH&MAQhQE#skezlGy;)lZ9bC&{T05GmwKca^rz5TYPMRwS^gEOwJVb!g9jqZ^_|M z<Pg`}Lj{;A;%c)>ZiYPMez9a{M|{k^N4R5W$ZODY>=je#?=%vlH!A3$9?7BN`9BjT zmw?qhA@OAOW!9eLza3LQ3b-h$gEzTsLJc67sxP<$-H}k_qg8hL3SMZw%~>>o3FX2& zfsGc`fExj#f(Gc_joYp#Iv7Rjkh^na>e5}POj>P8U@u}YB9-F1Q21rR^b$SVf7eXv z=;2Xc7eC1?#sjh|Ha!_Nh!~y;e?S>34o-GxjOt0>Va{|R3O&XVsAp1;75rk}VEecy zz8CBx2r-xaf3rIWUsbI3m(}RC->Jk0EOkD-7&`onv>|&BzJk_fEg<JPr_xmJScV!s z0Sq1~I1+JD2bI&Q+xb#KE_9`J9sKrORtwLwYT*N_R^4Zp74D=p8rki-eue8f#<HVb zKSU#24e?r+ZI8=#4cUxt$yS4=X{9UwGpob=V)hkd&L6WUI#9;ZN;ADvCMda+k*MCl z;j~7-o)t___`#!`$8u!cuD0ZcgLmL7m-6nX&5zHd?fFlkbm>JjmJBf1JfcUna~cuI z^<)C`L}y>}H6pgM*<mxwI&sW(!dRW}gt5CGrrN-mo#_^@#G|4U$NHVPS>J`hk%%5b z$Fv)aPz>uLY7X&WcxhOsr+){me|!?s@bEe!?;PnMZS>kA2Bl@Bzbe^DZO|!I%Ywtr ztc3@na@@}5mQG_rE0ZLL9!yEF524!vTE;V?cE{<3Ty!d;E#}Z3Duf;Lz_ch*lNRnO z*M2fAzI?o7>~09?3I-UnW90TkT%|v$ncdE00A#uC4NmFTn2ErRXfZ5KR!UNy+F)|r z>W-<j|F6EpsKgQ~Mvy*8GTQ-56X{Q=9cskbY&y3QugwB2{B-oVTt|9$jL)t-7ooC7 z?UyX@1W5+f#ZtF$;f{zt#2Y`XNjnnZSd?OT;{{L3;*luS{yi7{RpK?kL_rry*=G)P zelna+0XeX6&QBIC@>@9hR34|YZxN?}tM0Eo*jQ2;N7|TO9D1m!2U=?yHDB^2Z&X(D zm=Mz1BkNyc<`S3;3oTZsh3SR3fV0979ZuRvT37`pEnK~0MT5jN@Erx;0N}qYuow;< z!exM>R?SYe;=nJAX)U5(apKcmzaUzQp)2s_qD&B+!aTc!P)sVJW`!o4Lf&%f9_tnI zzcb~U>u5k3X@96zb<4_U)Etmg1e!{CvPoU@V0WWj&@U#qAW_HNUB-nu+Qx+$n{;<u zAQs7uhKae37jqmxcp@~_l+>aZa(r|}rWw>Jc_Jmo+S<u8`@&D1;Wz9sF^vPASrM=% zy>#RTri1nAgs$8#a+Lz(+INVk+9#qZv_mdCQ4o2FbMlT`!2tLTj#{Wr2ek46159$3 z)aK|48QYQuB6}#uKnnFk_BGQ_wHO+gT4vdBfuuZ;<GCpY?uCW{rP)`(Y}B{tkiF|@ zi8dE{u%kUn3mQ7=_JDf<8^A&Pa&E|sd?v_dhd%4gnzwz0+n{h#_tZkb&E1w{*yhf& zr&rj5o>k2t1v&L^aC2}o{Z$bLub95{>yDlr0PQ>F!6iRdB^;{^C%H|7y6>70e!Ch9 z!fBdEMu+ma3cQ+Sl`+B1s`<|f0#EGY<bEmrTzc0CX9<hHrC(O(+7HKxxL4fgV3TFM zyAE)1-gMBZJloBOy=NBj)r9bgTL;^O3N@zBgl+n6$3h(|Q#^X+IE-?nf3KFszxc+5 zF^^O!F<T5ZMKYscz<l^Xvz`2j+|v0eJ_ezsz;y|9w~^IVLw2~4#Eo@;wdS$|zHy=Q z>dp1*_H}KJCUmei=dx_p7a<U<OzO}*ZjZA%W}h8&eX>2yDAasZiu~Unb#`TM%vv8l zp<)v}VzYK;y~;Z|D<E0@o~wGTjm0;l^(}_hMt#*y8;v;m%E!Y?@Sw({vl?K>Nx?*X zMVx-&w}Zh7MRqm(5#xZA*?chW@7g#+?5<a*jh{=vA<(%bJx(9i`c>m<t(7o^NOP8O zn-&m*E-eAi>F1Z+u1euoKJY|`?Q4NjtV@nHNcxV&rA6r0PP=$aOv25$HHehY+-Ian zZ9@CQUgygV0FyOJw;(u4KoY?Myz(b2$MRZRZ4XKrk)|*%EsNIt{KM#K9X1a%rGH}B z@iyu<sgzA2G6Ntdo+3`|<KDU6Q%TcQ)v^-Q6TV;HX|)-9gNm?S(4@3HMAvjA86WQ8 zAhBWh$?$&bIs%#Y$S6gxs7`nTuc6I95C~TE7r6pP6Y|r+Qdf^C0M0Oo%E^RTqrEOL zErb}YcNo9Ds}-ftrGTkF!v^P~lI$AML0wyej$9-9s!5{DqzoNqSo5lx^>EQ8OZY6I zF7&>GvJ7(C$<tH2hfO22H-R;zSG~rTfPFOi@oOmM6ir+H2V8b)9NP$VYTOW2H}fC4 znoN90iDk37u4UtEg$n|$J1(wexsK^%Ec<96$HfigitU`Zp~^XNrp<H6dIY?XD&`>( zn`8`mWD~TQDvjbw`-_XoRT~9RCQyCHY!(01JyyWHdF;`Jb+&nka6-=*k*<zqi9MUu zyE=kGB3aT*R-<MVFka4bB`3#QTBk}IoN@LE&cnr&jPs2jr#;E%qc3^lc;RCOG=y#5 z8&M7G94(La(!*NPkb8{NkW~YK*`P?$nePpP+wV~a13C@u+iK<}bz}&4G5@aKD2EOc zSi5>7h|gC!@QV34hOvmjt&@pMjYDWm;f$u65lZ`j*qtZLDh4__HI+-VgZG*mf0%>5 zi8InhCKNCcfHZde3Pv2wtwC_5yl(0khnZ5>rffYQ*OR%ktQ6F<yi}Bp99Aydh)QE# ziZ0pNR_W71^+5DMSM1X><Frx$(MU#s+|*;O$wOA5w+37Ewsmxv3;P<>VX+d#kp5l0 z@vzu~93nVd*Mj)2@YX#7Ts8tQ>4P1NsJLI?k8e`&ce4be0LU}ic;<%;kR6}~hx{@} z=5$e2W_IQsQvy6SL!4NxiQu9ZGy{xT^C6bT<oqy1y&R;gOvDJ&&>fMHOCX(6pvhX0 zC5r<b_+S))ZMBJ^YC0e+>M-oEBlW7(LiC)tg`cexf4u~wPyNB;zZmz=yj7Hfmos!7 zRe)_w65=Qi_I(md*<Ke*VJid<q?#Pt+lFl+;crX!HSA6>hzCe1+!9^jEq+#Tal`M> z`$QFJ2#Vh<ZCxHv$A4cstixcnya+GUMK-~8K-<QTTiyK7PMiRPOHN42(WdRPLpLP1 z94c;io(yhO_E|kvj1-Eo^h|Smc+foX4uOV8_&t2&Wie9u41fcppKvK4g6#BA+Ud%u z;b!_J(E+dA*I(2uDOTogCWRGzKtU0fnw52AsRgZGl46BJ&KgNSv@#dsD%pZsI?h1Q z3pf(IdR#|ky1+niA&I6O$lcy&(+|LS=v{gV1IK3fT3&~&CS|b$X3~#JN=xw4IBrWa zf>Md|zVP~uQUl?y`mBxjMH_=vS4#uBx@$b^M=?uZ>4KVw!@e>nP1947up%^pX}JJ1 z4p<$!q<|Xt;R}#Mcc?O6lcc&*lN8NFP8S6NaJLBPIe2A3`!6aOzEs6<DHu@^Vy-TF z(17*31ti0IK;-A0IaUg+pY&L-kmLb`-?R$C>my|Vl=ztQMR=_?0S^LUoWte7a)d`q zEOFk=CS4XnyDsl7yL?^Q<vmQ(otUPgj4FbHmiLMeI$S*&dJ<w!t~4z^-~`=y62by| zP+6g&2dsz%EgLc2&%FYQJsCZIV#$#atD7Ymf%$jhK^OUve7A*MTJmm^x9I5vAsDs_ zC7e<~av%STbBcJeRFc9dxXN(!1Mf{g4D|=?zmeW-6acD<ElH_jnMVkg2kuN*k%*Je zfDCb{iQ!o+H3|QT)TCI-qXsXxoW*>`URy$UkhWB_N=#d-NkK~HXd%`ayRpzQ(KCuT zr75w@rZmapsxx+#IbN38=}xtZ?xb8g-3NJ^zEAWosipC%eXDd)dK5#8a&po&)1P!r zuj<t|`ZI8)=H-jW=Rn0Uuiu1jV1TQ+??0NljVti7KwGh!KzL43jFF>$+$lGK2+g7j zDd>RZnOKUVmD1s8O;@;{pLT^~SJ>Xm3go@A)qO24cbIRuDL-b7arl}OVoWD13+2(h zfD6Nd?wDG~jpQt|hPdEtus7;!ga7kQwn6ywQq+L-l{5K`0(F>w{+VZj8}bhvpUGc& z)v$#9|BnYA4}LyeRVWI{^8-3lTEfQJQxlFYjDym=4&;|JzO3WaGJRqS31U*unGevP zyq3r!zHVg7iH=~SV0xDcL|;F=%M4K%hnA92{y<n&DSM<ZAhV?XYt8<*1ZCIPyqrQu zlcjD42%zOkB%1DoacPvU6HUMidLm@tBHIrnz=kcqIt1U*E#%F6bY_M!q~W1N4`KUQ zJs52$dH4%#q>MfzXpf9-Dl<U9YG$w!@*fZ^LY^!38SG=BS>Ta+&jZI3<-Ey%AmiTI z=<?8XU~O{Zbam))hF(e`64(P;^KZ#;gj<(Q@PZy&XahRFS{cINLc7HS>UvBDvwyNW zeWoTGyAlFP)`V&2ABATF3$#Pg3{0*HlG-KcV^!w|A`B+1lPxB>;`~6UIK@x?7ca1s zbZ^Zc!0v_g3DfIp@@rT|^J^GT8Sgj^dxcbW_T@A0CIpi5Aw7pt&mbzyrNS+nN*J0h znp7|bFG@KAz^_}df%kq=nNl217d)lC^P1A$;CxCoV+_ptn)3;w+I*^hg9o;|!V*k0 z#aYMAoAYZI)1}D;uR5AAIC?o;aM(bvrDD**`_SpYHY`IflX5s&k9G>9GmCHZL5}o8 zTbLv&P$c95AY=uB;$r5ZO;F99MlLeJ%P1$1_I#qD6~@t*mrz{_;|TMcCi~xo8pKdH z-Rkd|Vp|SQTnuh$y1u^02EOv-8!J9_{?y=0SF-L)K05I7ubv-{7BLcwQE8@$S@EyN zKXVfa`o*7+#M~q{C0L|T5zl}f$W%Nd#;o8466_7*1+9V?Aoguy6{(Z6`Ym1{j~Y~J zJHu#}k1{$3kW<+(rSSlo!%nR5T6htcJ$O~Az2%&R5huz+t=RTZfb=ZsQp|{0!$6BN z<!ZE0!_P*`W>m}Yg&E_5qeEDFyvB(nCOfn}S_-KNB}QKkAUcY{iMFA&Fug<U+W8d; zy<3ud5NjO$B!3-RF%yIGU(MPZ!VASvL7t7g<qkg<F+I}A2yWw1BRwg;;-*XE%$H<4 z64j=kYbblN_}1J%%eKl|GB75hDGLo!j|r_e@LZ9<B>g$n#WT1Ld9XF`S-A$3lrzw} zr<-l~@~Xtaqf>$f*Cj|s8xl!A<UMW1B=tiyVCFs-2_awzzMM<*!}4-BK73+Fw3o?j zOLeuy_Ll=)Ann==v&bXm{cc1<rH>a4-({+PK2OcxPfv^;=;k9Ko;CQtVxpKKd^_TI zT*U9pS=1~U0RHzFQ=e&+d6qQ~r|<Wm!NX^U2Nh0e(O*`F1K<{j`6HScTx1V%-9?E- zLhP7k4>hf{q#v_sFiYbh7=$cyOpzrQkFA+iUk`ZmhBq(Y5vvVn6BT9<4L_``Tu*r_ z*W}aC<J#M)=1J3>VOebPkj?zvi%ZTV0vijH$oga_)QB7c>$DELL6BJbn05uW`NUtW z@1c2#V&AT#o9y6Nw5B%42Y19bi@fyNZ-efTgYJ-^3x|{FE!Zx?MYmFkPy|dx;1eSt zf5{Xew^f-!eK}M3mOhgqB3y+lzyu1efFI*qqU#D*7<XJD({c$c3O^g&#?dFTH3b2B zG`?d^z=XAbi<1f@8s)5p{Yb;2L-LgYjEDLm2vOHQ#fbmncEBTqP*_h1+c(1@XsqKv z)p4o?7lFz&h)xMABL$C808IkplpxNwF+aD|kJ0P!A-}7ngg<m>Hi3~#6e-=ii|xWR zO6p|j;sQB?f;Cipo)BF#8@7dB6SC-QEB_>fw5oF(r|!mKOMwK9{3Tk25Kj_lnzoL@ z81!Jc2FN;B4eJCJv_&F6G(Kdl8{?3{l5+6BruohpP=O!7fI?<@RImn6P~%~+MuYFB z;`?Atnzag1!iw1WVa>#OAq|0}L{?OBMypN$kS|CI)eUO`!<v?`CXwvma0J5|HNn&n z`^uB_XC3#D1Ao1080~Gq*()H&Q;g%HY`v*n)Fp|~YXJ#G<o}>($sWFrhbY9o(WTz= za+X+vY}7uo1F!TsUfa)cagk%)4YoK$rMI}ZdE*B81TNP4hy+$cch4KRlXcA3cR+GP zPu&^(G9+5r`oltcX;A7?wm}Lpmn(<ORAB_=1R~hF7in|DMc{|^Z$sV^Kj5OhZ0k1% z!LE}Jz2jHkdEa|}{i)zKHYqf<VzDc`?Zh6$o2>*$5CWGCp6maX<NB?&xW2%8e<)w= z`s|{a?x<Qnj6gEfY-AS+XVzyN_vWC^+(6KqZFcTDVPenBCt?^Szo|^|OaoK88T^&1 zmatn7qNgecd!#U+FqpWH3%cluv;q(?nsUNu;YWezM%;WJ@G;LMKC3H!hEM6bWqb6f zfBvw)U*bwV4dO+%N2l#(d-RCE8qZgojzee%1GR|LKjc_}!Lz=K&^Cp@32o{!q1|FM z=8<V-L;yK`wiLsf6rh>@k?M7@x-*oPqexYU_HU-ye)!iNQgjsmg-s}x7zQhq7>LMq z4oR8(!(_FjTyYA1p*p$Z#@*A0c#7kdip1>hG!#)J8S0Tokf#Q<W*b$7C#%z7tIzz{ z^32B2;rp`s9CH#^lqwP;rP!e13qCqnP{wDk=C)s;Gbj|@_%q8gnk~VcVAE!~!p@-X z^kxC0elvqY&;o#%?F=2I6rCd*ye$Pv(%g$&lMCudp#_~9?1*{^)AYUk$l%L3UyNqP zrH{B}88n(Q>)iucf3>pW-#-FGB*R3@lPQZzg20ejBa|Fu8ifa9Q1vBaTbW*AT<Vox z*cz{v0r6Twfh0a3iXk+`fEw@{2Whz@T^Zv{6m-5zSS3ZygyOJLi0uPmMG-V@Yo;jT zJebXj2NQ0F#gWB_)px#x2?k+ML*g<i0R`?&X&>@Q@MSPMVP6L0PFhra^+pB>-Dn=m zuNve0ltYL038}hnjX>B9+5ud(fNrS|PcmbQ+@cjZtx<W@SeSm+Bm#Npg)<ZJn-`lF z0;vp`j-|!`|J6!EIfd4Cxy|>>A)6a7vFSzq?=N}SCm_qOXWJC2Ik&v54<j=q4XX$e z<K-r;F(Tp0VP!pYToGapxyrXs1g9UFhNRVPH$XkajS>2_3}7N>!z2A+!n0%zQ-#z^ zP2TFm#DFQsX`RW6Fv<M`Td8X<7nUzNy3jvP?|uUqE=5IA3|zE$P_uW5e-}ts(<{qX zeCt<^7*|{SH{+-hymY!B^a3W}m-#uhV334TCmkg1MG?7U9%Kw+QSvse_lxO6>O1KQ zqY*!Yg504S;KLYq^?S4gF6PE1)g`O%%2>UR7C@s-)-XS)5^<i*q`%W=yZ9lD-&$kZ zV5Y9{qgd(wBA4r_&!~@3v#-FcjLUUk4a+kY)$eQoi6rZ;I}VDDPwER1dqjUeWQppo zHAlHGSdQF^et&e0thoehE}n38WvT)8pnAu&``c1(=~8p&JB_(jzegjvBK=L1Sip@4 zs2}iafF>*n+xjPAl)Nh@JmUOxfiCSw8&SI-cUFm4s;J(8*N6(UMxY1aTevVE6G9W2 zB+i9^vLItOwZrQWno`!{DWKrhA7ZP-zPLzfM=g-IsgUc&tMQhJKq}s{<qptJ;q(}$ zUbm&S;s6~sHS%(%O;)g)&>@9z+hHC#2WY1|EHW}pM*D|5eV+%f1j^+O&|&oH-f-an z?Kfc<&8c)UarzpbxjgZSly*sfh}JB&@D)IhiO}o{0*WCudk3sCIAi~UY_=;U)|S5C zbGnH7c3ls~=aFHLtz*l-)>c=nJDuEuP}&ptR=@hO-@Ualk0Upkc`>(NdMuouLs4tz zd%MG$7BBYh{>qXJT!vwr=CTf;TJg$4<qcXEUS-Ss!xG~hN&=V#Pwi1O`@i8!f38qI zf8Y8)B2>OsgV@8FDaWteSeRCu9KF|_&=x_A=tIJ$7g+F(fG}9~XI#}$>Z+#B%RSRD zS}+1tbA0`U54izlbFA#v%WJAC*jTTlXQ|OSh?_pFuF?f9Sa<9^15jdfyjKzUmby-+ zy`}buryIf?bS$X@9*`0&V0w-<nAHwKmzu+!2JO;KNeq#&$9c|0pmaJ@W0o3IW0nFX zxDL20HK=~QCkE<0zbN}n7Z_##o{MAgit;;EC;RS9`gR)KS&~veY5H*iQYhK~6I4$- z=Mt04&0|v9AOnfeT;BvWDcVbft>64~IL|svWCZ<WFh8e-OY98TgoQm)W?b1P(m!z2 z!M#v`i6+B10a8+05Edd96Q2tD5V6QBqrTdAzy=sTopX{$@B0z5*3+K<?y<4q$5BD| zs$TQOX&;2vdgZcIlCU5Yan+$D8$K)va~$$X^0K<p<w!JmBzz1INb{4c=ugC`d$vB} zdKsmkl=LLK^jJ{wbhzuguk8&JL?=j~@*H!QgRXLZM~l_`FFPt}=C}W^>CY50n8w^Z zREBEO$he{=MMK5lRP|D&Z*%aic(P!tpy}f}O3h$!7roC$Odw;m@F*KW6!%8%u_$#; zAb7W|!x4hK#CD25%^smg1>H);L(Azw`!`bu{E=n&a-yi-D!nItj|S2of&kZ_EwDrS z8*x>5&p4t$I6zIet5+Q5Km^DlNek;wT|dAv6G~k0W@V`u=YyZVO^h=|uh#9E)9-kD zV}1_5b)yjB!XFGN&*;A9;M|X&eZ<g9-6|t!A2GzY#&0J&r|5)$>?=z@Q6Tb@hKIuB zAqL>RnnTw8`!x~<K(LVvUd;nB?2nK%OVwtKG)_!@=|=j;jt*63sYLH3UKoG!&6lTw zEfy~%I^(>&)eW)to~apX(Cd}Os@dFB5R6%Dvlr=#WaQQpp`_n5J-6t&(Em%eQ0IF7 z=We1mGddyK1OtW!Z2uH4RLo%wo<ndz2G#nwz%jA|`f#;peVC}Lf6|A;T^<fihuWqi z+&~N-II1#zGUSV~XiN|>`fn}$V<Sy_<^WuV<CxFfk(2uQ06U-xck*W3D?8$)qE8}D zWjV!Chr?u}ex9(0(t;B));-eN{IX{sq_z2F6e@~;BXOuw6m{rgH1QM`o5`|?=uBw6 z_TsvnvLE-<EZi^@P@S%E^P?I{ou<#Q2}k8RN5GP2c#2-KnclkzS<-~K$+D^dps&pI zcbj9|XCU+`9pO+Yuz~_KYZ{rvJ)=?^F*?mjpWJQxMC|F5ow|kD{F3m5$U^MJ*v%q$ zy8zHPt(tpeb|Hd+f-w+4V2wQK?}Z|}eu-&ZYI{5}MOx_bhzARHl*VB8r{<x(5pD?g zWgW^{vAn#Zo4S!sxm7V9?bf#dGGp8Jvkp~>eRvzQ$h(0wR)G;_DD98KCJa_=ROgNg z6tv7GlgDR!RPC@zKIR8<T36^r%M~_c1HK=y(bRU$JE&kh@xS*6Rm`=`$tpO|*I3Zy zQgvl_VL(!uh_#%^r2=!LPXj3VIV;n<nuW^yQX_(~1S>3cW7is&go2mr#tuD`ra#vd zg2F5{eU5?8lzTGcn|vUFQJGOmNo7Ka^iTVwUrKn5@Nv|Sf{6;_4HrT0r%8Jx1eHoG zWLB1;A<YW`HJYcH8F?W!EBU2<MtCn2)O(vz?u|>q9W<lL=Vw$X_-AgmY)McMweW^y zj?_br@`GNwQ6e*1j*y#(z-Bz=e=uyyESR*9jtNuBv<{+U1}@PPOl4u^m};0R(D4d~ zj{f7f=RikTNr{eQ1^KPS#z#Fi79kc4OnCJs|G2`CB+9&~!QH+RTPWGj0cTs8L+}He zoPd%nGXRx7ku^%tGxud40UsBSKPp(2u}AKWl1I*TEbjE?WZ@)izc-93n^4)pm3U{1 zzLlGWVB%*TCPMp)=5!2Sh^*Vxee7s6%1RecmG~?!pW8#InNLcZYJ5l7r4~&TO2Hj^ z0|uMBHOVuaacU+`|H1%TggEHy!`@5t@pJa?b7$?J&f<vJQ0lwKIR>%(uzV|%-YK}> zb(^D%Vp%Ob$ChV%a`7DJh3p!h6CuojP{niH8~TJM&&LGb>R3M29}DYam9hL$<yhjr z%N2&iZj1;yzBTwBgg4c>8^!p*!?iCuGS8P|_`EbT!H!iT)#)V|7^=l;5PPRXa(@<4 zn_7&j>&dA5^bDyn#RoIJZV}2^G`1GSmK4^a?2T5U><um2l2p8e=4(q*vAnw$d8#V3 zAhkSWyU57ORGCq0Q)IN-`~p?m7NJaW!#|u1+zr{?kc};xl5o$pgiq%PWkm+bet{jx z#pe!pVPJ3(-n0CGi<k96x!)wM6d=TcqNvJNyE0a%w6tUJ{T{(4)6XW$rC<>S%R;1L zHVWy%uM57QRe7|0FzSZ@>iCJsQ%z*28qh_urk|GbrKtsgOw@HfVjZMpnETXnG6G26 z4np?#uu1#uOzD1pIRjGR(oegWRS4^j&O)%d`c=p_h1jql?IXg@C#~t0#YLEq#h4#- zDKLJoOF<LYQ4yz#SSQlIG!>VAf-$8lSKeS@y36txF3Sp;h`;+YdZm0Pi_mGplxz%p z3+eprTCC$c%41mM!Rzy#OD_vJ8X$kMzPv#>ra}%+QfMQWoKWo$A*Tu8VA1XcrNqJa za2*#zAzmi^bE82;Ka*IL>T!*;dwkJxp6n(sW3J`5-?>B<U-B{2=2ewfKdeG<O6*QQ z>}{_arpzr5ce|N&jhB`wFRqbi*bGz0+>BXlCPax~BF)nlF1UqBjk^aEx|mzIhaZ+5 z)5HxL2GzCSNF!1_09!;QRUzL>-39<>pjb27jj2Nx*pAA#r?l2)!*Ui(F)Tc>=7a8Q z_N$D@^eC%SBQmYuM40V}5%|626DKSN2QF&<Xcq`+X3CbF^F#CJia8Q-&3aEeki!RJ zoQ(N}I&FScIgFQyQ)+yNoBhqv7H!(LXWMjWPGZOC+O9E*ZW9Xm4VDTDztGyyNSJ(A zrNp)$v#`wBB9T)E1Rf?x=$DT>4O;kBVo@2j*S;_Ae_7*M$pO>4fENVHk+L%QNUbRc zlByg)NLW@a>07$lQ=lshYZQO5B*>WdZo;VL`hm{0i?Ny4u9D{mNDgfch;x({b~v@w z^ge&X;_&V;fUJPb7IEX`BvgX{6t=}>!+OBRv#Yrf6Qh;7VKxI7`>%K!YF*U=k~7IO z-AUye5%0FxBKUC%7tghzShzAyJTCf8zb?v!^B^AF>I(Z-NoRGpiob%@t5UIgZMXYQ zS^X!)W%{7_{|u}DbM+gveRiz=Q!0ISR?qiAO4n;vm6P}sz(j;KeC+UmaX?=<s?(vj zJ4l2{<l&}5d>~t}PW+CoP1S{0rzU-_KPYTgzZ7X_RYs_s<GiKUw?+LAbU}nQ_ZT$_ zw+UqHVs<j~oT!T~kjf;lq8E~4>y_d~Y1N`{EE?BV$mzV+CVLiHVat<EA&2Zve(kKb zmXwtAA0($d%G%~(#N4cFn~VvBX+fZs%wjWkOSqDKve3ErY%4Oo6$wdaQJn}(){l-W z#D~Rnr^v8yjn>*%WS$KBiVR?g9T=>$3_BHXk)CA=jjAMp%nZ|q48~;XNE6cq*f5`f zPpZBP*mtjiX)XPA<tNgKi4&9L0+Y}(I_tD@k-Swq{oJ-N9t$fl7uaF#+~6`PNZLdE zJ^{h{42f}exF9<<$H*ILYF4Fjc?Ejtl@|B>q`$tRwq3|*KYoFu{f+);Pirs7is3#1 zTk^ww%tmfQds0bRE3!^3WoC(`%n-4ZOpj*|f_+2~L{qjKMJMx!V{7AzyVyZDx@R9G zQ!kE>qb)Lz88U&r8|cgd&MQY%262TxfWqjlgid7P#`cA`a3;R67Jeb=f2wSC0s6wK zA+?FY8TaRNqmDy-Mq~#Rgi&h<&=;QFkaAHm;#7@zikcE9HIpp?fiwMhzGjXUl^`>F zt)<?Ovx!<Vo?6hcQCU;wQ<1}4O|2ZONjVU5*i3pmU@j4~+)_A=Gmwfqi-tjzsakpz z=dVVJ=g&vF=V8`b$l1}C_!XGj*eem1`I<M8??p2ES`x~w{Nk6%)tN7D<!UBVp2_ZN z<&!Vde*b*&G{Lwr5v-S(cFhy^E!v`&&PiD9d8BAN@g!)L;{nAeB;JV=;s8dp1z{4J zg4Bf2+i57sveA4Qt}=yR*cTd>QSzIB)U(O&->%R+5~#xA01`c6IR|8+b@fV$xSUIL z6Xz0)gCBfaQ68ipG#&tR8`XXWaXAh+q=yg*8_+2NBa0T;tOdPjFzyVaf$Ge2yS%!N zXPLG|ROBU2CZ7YGti#2#H!<}>`;5H0&EiDhM4=T@ZpS0N%!5u{_>JnqU7@_AWTGxL zKs&1zk%y?1lg}0S?m1uZ!3@p=d}Ve2&cXMV3ikC`l_NbPQ?M=*V>MJ1QD~ARbipmd zNq<fY=i)|F4o7YO5n8?J!IHbs7FdX3<4{lPJ2hIsItnniXwfR1{-yYyJEYx5r+vYu z#qefXbQ&k4;XGUn@~6JCB=!1jC%CbA6mVSv7~Nh7WbOt9z?cA5wdwy*IgguMD}IxI zTW~m3S{odnUIC6T{%e5a^qS!Ko-pK)IS*%+cz`qBN}b+Nq2TsYl_LdohFsxn67%8= z3n2}145|?abH-jY1wtrX2*)1>2!(Lbx;YMnSuF5t*}ua}%5q#E+M#|xAp1$&p$Bk9 z%^kthX3bGKCT5XEZKeI&^qq1vlbaPYSED{SOy<0nzRkT7Yr0u(4sdaEL|B$S{!Qw3 z;u2(|OVa>_E?07Ea39Pbr<5}AErSX7ULQD^bPu(;f66_5H&1uFq&|Cdt;=wd4A=M$ zvEo!X*)$sSvGK{l(#IwpR*bXsv8BjxsuHlw$Hu7oRq#T#hev*RDah~Cia+gR&!FL9 zz5x+7)4GdHi?;Db(7k`TlPoQ)oPP=Xv7S0ArI>Yd)P?DzYRijo`rgTH8%30#rmHJQ zcz}r}*WPl32YU<|6u__H-H!}QRBUp_M#pq&mhm0ltd#i<y%!O+^q_l&s=AkywVVD~ zfBb0g6%<~3{EL<GTNe)&<A1-#kDkl60(Aqm3hXxHLJDeNCv5lh!qC1%S#nFC{eQ`O z7ck4ts?K{~&Z*n3sy@NSFVIo;Io*RzL#KluU~F!^8wkXppx}Jt%sfLX-Ib)Ox~saY zsyoReCKc$2Vn#9;xydCcnkWfG3>wC$#fTb}fsCRPFgg((KTsF~0-{9n{r+pc`&M<T z(~$5u^L*)apL6zpuj{_nyWaQJo3Vjy!HY<V>U$}$*+(LZO}r_c*{$}SSaZwgu{L?# zI20zFkrA=I+wbl!Vx51JWPg#y(`7<#E#4V5{;S%pLi#qyQiS&i;oA5t^kUz+6<#43 z3&W7HtT4>@Jd$fKrsG67t#!N%!;lQm^{LiFSDOT;%ltwu=uTtrk)eb1x2a9#+tf&& zVX83dDcN(}BA^5=%W+WIe7e6)C^9mh&<R8#zfgq5I-Pi<KUEr*B!9_kkoC6B(+H&o zxF1~69W1&kx}%(Zf<N1|?{M6qDgP8tUWeSvBPAl~ryX&Q(gI_!5Dx~=q=$IMs}Ro@ z+aVriO@eq8zb|3ED~t@4iiGoyG%vn!pqxDzOoyPN9kRrA&2%f9i`B$oasHP@Kvb@f zbpcyoO+)U`N!Y+uZYBO6H*a~x>rRy_XN4EMZ3quV9A&uNUAu?}Eo!8U^Aqih$NIus zD({Rwx$2A5F$)&}GFA<EmdLpv0I3s05)eqnkR^ab#l=dOPe?qoM^whcfWJK(U!DSA zJ-@yiTDQ|d2=7^fz3Q^yVG&b1UWDT2P4NCPtHs*d0}(rgR0{qvqZ3y`!8Jz7KBKie zIqO5_MxJidVhw5|)$mdsTQ?T%{BeneY$D2|-utC-{p)+`ufJ4(eRuu!z4leduH>;` z4y|;1ci6+}@UZtj`+0V&44(67?^o@`w{LnOQp_iMx7ur3ZnUj>{yNWkH(P<n4=K?5 z#jRi6Zm+&|L#^Iz_81h=TkpeChuiGTKFzE55&wNwRjKz;e-g_7H~*DV=gHM*!?8pu zEkbS;ftd@Q?huvz0H(!G0FMEOVCs4R#9~$ToU2qyg{aD`HlPgGmr}SHD|#-WyySpL zcFb)^3EF;wr4bww2t;{oNHHwX*n<Tqqe<TslkH`SAsnuIr2MNlc($*f=oTq|oMmUy z8z)|D>g}A+o3PMZ6YwBY)+oZ`=Kz<@lZco^YiwT=%BaZ2TmZeAPbSK1Oey><8xKFr zA^b!;xE4DSy!T166--gF${{4C3q^8Lz~nUws!B5K^cab(X_+F@;u=fh!q23Kgl}v} zk;>Hpx{R+p>9cX%wDF%O&^<2lF_<(__fhC1d#t|uvWB6^z>uX;lwB>MC8Z^Zn>sGF z8BVjF5bab`cyGYch{zlnE$}0Yv{*?wC3?%HN5(5e*?X<yN5+)IuOgOwpnL@ln@{hQ ziezRD)btz&dPZcLp&>ox@B~C4NbST2MgFq1Hi5-TOnSi%umJo`EJQUHLI!QJ0Sids zU<m;6D!lk#8YUY|H{ydtB95BW?b_nLGz1vTQ|WtI$$-N_TEwepDRd|&Hgc#cXarEj zwPP3V;3K|Z$AIld>}BVriNd!CFbp08o|00@e48YVmk(|Rk>ay&U`M)EvO{1eAglxx z?I;(>?afF%hYxEGv`TS6B0%Uz2y036U`2>0Eau}&`%&2S1=^%|P%(UIomC~Apqwli zVP{oQr9P`FEm~()sW958RoJyr^Q<aEGo?sME&ygBjYO`H1Oh`BnaMuuFy8E!<G67c zYUj2*zU}HkSLt|6r%M7Jl{u6jqLX&-NUw<?IrhMa2@10)n9-Vo^u2$Sa4^8Yh@#fs zm-NIHsD6IMe_}By<WF<vC0MH#<&Q+^vX3#kG1d)?MeJcLdXBOi+p80uK<<_;li<G) z3=8fH9$A^_!B+@Dgv>YMBI;eCE&GRXbD7w;RV$EHXu(#yA*EIea8cO;Gua=#6g;sQ z`V^+YY{J2ymK5VrCLZqQ9dx#f59loS3eWeC^Z5u8qGTW}F-#K1UTGg2QuXsm$Znt| zj>VV~{}{4CoN|afYgB7`9mo40wAnEUK$P@CW5GQo68A=tVM%tb&Iv~{$mR`56OMey zB1xgW{v*SxGf1bq=|~bs9k#82%WhVre6UK%wThTUC4##`u$k1co74=|NI?CyIurSs z^IbwF>xqhhC?gTkL_Wih#4Rn~Y0Xd;Q;#*#wMgU5JLN0^a5YOIyr#r0XGz_!Z<a!0 z->3+E(U@g}?3swqQpw6b@XYKr1|)!p1Nmsq`8QIOP*4Kr)kcjY)D1+`U++{k=PpO_ zfms<ewysAO0^fT`S6AX50pQW@ehi5Cf(s!A;)om3D<?N`-JysZAxa39GTIgfLW!sJ z*(PdtK8c^8DI^cmW}7Lc5Vd&DlF4=wQ#b~nX9Smt%4=3c+X?QAJ@oBLg3S6xKXT3J zEXVj}VNpUAqly$Jmulo&rK@g!!O|%Du!Kleyc`D|2_NjjsfZQ?*m#~&vQ<sLMj1ef z$WD`nwb%{~OYA|z+V#(rZNp>N*31<Iw<dcovlwmzW&9GEf=6gr$1CjuA?Wab9&Ih# zbp0_cvjW%$C4!s{NDh~N@^?HH?G8Q3#hk@@k3G!9i9g!A60?ke|EJSjxH^Y(W-v%b zIm@;`X{}G>85s$s%dDd^*bhdrNv3JNP2!WnEJZknNY1EyV&P?4ibZAfDL*{d&Lrcs zihQbhCK-qHN-R&R=oB5W%L=OEumir^sk$7@>^q$6o^U;;<xZo(*(DH8DG6`@JXYDp zPn1TYkB`a=Eum0(p}Y)H#Z!N~uITMgoB{(jbUWOLW1%&jj6pCvInsg|(#c?E(L=0< z8e%NdJbDn@0Im+v0Kf%M1s*^Jj15kl#~t8A$fCjs0A#3kI8>O$h;Qak{MaYJCp8X_ z@j*LG3E1;xl&2YnOnH7ACB*8iYgPL>*=MLjRA|CurX<0LE+_drA)pxi)rK|$${3=& zE+d_?2#sxR$IG!T@Op?bBqcoM#?Xiy@o9FzQwuxA&R(9@Gpe{TWVGnU5Lq(5_npER zL#@JY42?8gb2W*X&7F{wI>{pDfPid4k(CL}$1J1~j2u}=$P(xqR8RY*0pvrD$#vJU zH*lOHM2eUQZ25Sgp&MH?lvlPR**Ow$rT9jDd%cqO-qosiX8ZeyodmlUu}%apIELn7 zl=7=-bN_ro$fU8quXc8s3+8p1$Z=bqVD*g!t8a{*7yzWQ`am!TR!Pu~8;<L|4;^!a zR7M-=?oRSPERLl3q2+yOl~xHEGNV<~|A?N$p+6D?1Cd@r?8`+e79;U@Opr2v1Mnx5 z(Ay+fm%zWYDrL~{)-+<U9uciF8%HuUP`DJpwCrfmBDex=)X0CT;sLCJ2HDHRV)cZf z@J2zMImuhoB#b2osewN^P+)JZ7JGH?8+BwsXhD&0wuzx94KHQRN7wFllKf}p#E$I} zTr(8))@w>vSWP*B-J)7+d|Ny`SqMk*^)@^UK*csZ>?lIQLxyL*lnsw>n}(;{Zc~l2 zz%~s}B<HFuq@sYBdU)?_!?3irlyEF;{j?3y{N2=sC~E*RQ_inJFexF3P7e^|TRlWG z00fzGh%~Tih+b0=w3>ooryim#<m3PYBD4U!MSIBt`EL{l8s;{9RlbSha=rIxz}buR zl8LioVT-~f#d|J1AlR;>SnMrnR<iIp4?q8|H1}t_-N5IW5>~Q3){v%Sgy66=C#P=6 zPL-I<?vCqY;9=N!ml+74`R6nw0nY$N69OQL3gY`jL^!lCI^1QqF<`pFg|WuYEKNXY z0bcmCJOlGrWQxRYT4C}jbLqzoO-_*#d{kHRl3GDD^FgbkXN95pfRQu@aU35)5mhRY zm)+1fMg@~JE@vV;^g3BRnu_B2vO}#a;Q>38?^<PIP}p#8hw^B58B_^&XzX^VP4X$8 zoM3a4B^s<G69iZ|Sf6Hv5^ErmNn2)q>IT`~n{CQG;&MlGu}HcUw`x%k;4;M=BC<FQ z>wz+HFgm#=$)q6dHmyur305ZbvBAo;709iuOkhBWsA*-6xRt3B-LNus;+td|0=4KW zXN{h=3&8-&BNo9iVCW5uNYDu*m6iFWv_A3{+A6tJQjWje_R$sW{QO6|IRHa87ywFv zfmjNVA2i<`Ee)pO@t2Gris{$~L#{2sjF<K#c0xXGW?OZ{gN}pP8&&qC9r2*sOaQ&@ zTcAl)2-jkucxCe!=wcR3{8&q5z$BTt5}X{7NaL6sU<eJu0S{*I3gKiS%ARcYWVkh` zBBxm{k)G<xjPXCrp+iTQ3ZBPnz6&`h<PzAd=&&;&1NbGnCsxIL3buN}V-|T#^Jx|g zZye<|A$y9oXo|X(g*ECp<&AHG7u2EU2Ec=@L5l(6A8J87_pYr9IXKsgkew4v!8Ke* z54r=T;Lf@n%Dy!MI+ie@mXhEtN&3B4ZSh`RR7>tI_6av8wI(`mL2HopA5o-KkC_iE zg%YBo7wwoHiDK@~>_Uu@zjA#@VG=7ONobp6e>z%jvRw&Ro~{6cTRTkw6L)OR&>A}J zvN>bBe<L|C*Ei$R-Tn;ej6a}f0Egu2z0t0I%zzTnnx(31bnG*-p`AK?&ru<<rlDm& z)(&r{F8iS7;IqPL335p`gVd2{!$Kee2bqjVD93YF5P%@YXV(Y{n}W8fhoZwPRb{C> zzvP2l6HJxh@iy{0E$*(E*e@AUU*=jcQO8PFWa*@g*$ECFt@FG;T6gRq*-ckDb;+1< zbGG-FgixX=W4`?VbZa?~>g``%$F+j9%d<!NQSWU4hh7VRr<65x?ReDtuNqjCeJ}Ov zDE5d$Ou%rM02&QKcNc~}*8fg@px%_e!Qe5~SOxoxkLihOO_UO0C*O0EN*13}??3iA zeeZqVi`^EtdhL3zLJhfd1Goawfg*}3^IBG*qm1|(7|JQ5;56m)g!pr!;56bxF=?LU zv7VU~d55M+%h-OrmYkCPtADB>o;?xjgI0FcT6+=Muf~aX2Ng~8y?a}pmgYMo0gY)> z869EBr@@f#$k3_YBnR}zSzlxmAF)8Ko7}RQd6_ak9E95FmLcq+TpgJx^0CoTns4Zx z+UT|iL>--nO$j|-6DP1u^jZ^_xzT&~Dc~XZx?FUM>XkFuW2HR$Ce<A@`z>OfCh7RB zd(L>&yUVF_zl7dvMNhD6Ki>Hh`l^6BKSM=fd&gOrkZTct*P`C<cvWlWH~sr9?YL`P z(VN=Q87IUNP18UE>PTe81J-*gTdh3M`i9S}L&)AYwZ{g)7Y^AQjrZ2QGoYv)InD;w z6beEXU{c)ru$5nz49k9plBwSNw75q^BucT7htm!iM?Wqfb3k72cT{rFHKc1WMqSkE z_C9KF5JaM89=jM4Hw7WY4gnVGCFOL0S=f1RtYLz{rIA~m5T7pdMwwv(*2H7PSzK*| z7=*_CCNyN015s<7@hjh_q&L7_JMBfxWWyzpuNz_YXQyG7gr}CD=<!g1t6(T2+U20B z|E2Z&g%ia>rn%cT5|8b?UQ{CJ`6;Kkd*>5`Ek0UZiut~`txInGeboCy)wTk?_v@=p znKc8Iq>>j+b-XDO+RS#<s+YDqNO0sp1!J*6mJG48KY9TlEL1$-2Mj)fcY1zkN~Gum z;v4r!Hd|28uj>t;-EP=qn+Z0UnYH$(P!3jxFKl1l&QqE(x%EG$&v`rYx?Aj?*Ewk# zLR?rrMEPQuy*Qs?aG+ZgE^m0_x=3H}svyIG1P&RQ@l{90v1NPbeBB+Ldedn-G6$W! z<*x_Aq%UedjQjV6#;k<4aXZRG`KXY15xsb-<ny~sm6kXCN-l4rlk4B1VHSTjgib&e z<Lmu_dZRl&#E!+^Ib}hw#Z8}8<=*STO|!(Wy;F~i|ElipZRyNXu6=YAWz@You*Cfw z@*b9vO1d`<lN<;}p`1FiHnsa6s%Nffsw$QCUYjP2Iq1B!n)@ez#s-0^(P1KE-@l_z zSl`x<0y{Tq9gKbeLmnlkOM=4dvi{EhsCm<P-YMHqKsPOjh(nr_65XN7-&mLSIK<qm z$}Ei1N12oQKsjJv)jxR6_fM6<J%5G%?;MO@sT2TBi@TiZ9k#F?ENFQFBlE^>)4fLI zo9S^(F*?>d5288}thDqdNvJifzqfXrwEJ-$J>8sqB^YXt@0&0io{q-AU@{R{v)G_} zYB2g;>D{!!5Sl!8N{14z^L*z|3O>e<2||OrRta?6sS3z5EG`}}vfDL^hp^jj(Hp`= zw;<s;XCrJjWK0^&o3v%&UKPDZL51Q5lem2ObLZNmxE<~Pz^my{HTFjLfKewIH%V;y zoPG%52YOA4Prk(n;WjE1Yt~?C!GT1Cs_Y8AqsyYR?6!rZzYX&q8|E!qV$h$wbF-=- zhAgu|H%nP%4h1JSvbdHgu}04*rCf0jfNu`GG1LeXoW;P`BL1#{o4wKP9d)gF-%;8$ ziku|HEsc@DgH$H8A(d_|jrFOWS6Fc@<Q32ZtOdZ5+?vqK;|-&@$3`K+@v(9g8pHja z`CT?rjQsbx1SpUOP*NcFPdO;V_E5jZE7D-0&Tx&_J8<1FIRlqB_9>!PK%6l*#O#*P zAUKIo)awKxQIzN(16?xyY>MfZ2+B^4aAIz%gG6uGL|EMea^spX&t!-iRYX&ianU@^ zUeh|Pv>srX;#Z7K2PwuU!%WTX*m*-aI8O<;ATlDrC4orZ$4f4=%>ATzLg{mS?hRoy zoMGTP*|Pdg^YngCAa?Udbk@61zeQ@(X#^C75frkuc1i;%Oe(?Zy}MP?UI!&+Bs8=z z`wKS~7M)iu^OBZ7=YsQXaWzT?Y@<{_NXC+L#~Dod5B)|S^-whik%h*R3KeqY{#--j z%H*Vdhyh3m9&msWvWg%Cp+$mCww*|@*Ws@mjbba2JO7iW;kJ<$t3VtH*-`I<>Oey$ z7{|h}EH1s_pL6E@WngmHoa$p>y#&6MLq=#JyM(x&)evu%D8bw)QE8P2Ok>L4mbk^Q zlI>~xS-pioc@++<&nCy#jVh{T)zcb|KiT#XB4^>y=s!9+6R0rfz{oj=boNJfVwE|T zUbLUU>rt_7e&_22OGxCW-@Xnj6(7|1+g<DbvLmdlLHW}7rHuELUEFe!J3bt7=>CWt zl$}4N)=hPA=AJRO1q;G>KXXcbTI?=QnlNa@r{0v-mUSH{hH(^Jl1HOOXnuqy?=%p| zg3#&N&SlD^0Jr-U?=-}_qazB^&ra_zG*RAJL_X<-raop(36}P*$e^fPr2vCyOW$;+ z1Tkg($(1zkRf#Hi<$!_W4o8f6Z2~af09{+^v+0jwUnxVr=%D}IK+4{p+%uJnB<DV^ zSA*1L&s+bOAlS3wdFs@HoVV`0b99e)E?10((%d=+9<sWzzin%Y94Yh^MHsu=nLtqP zxPtET$UmWOB_FDQ@nVhoTChsiqaJQoX*ppHS<k#XrEF&+{H!GByWgN`@T4I|n5s+8 z;3<C~uKR1mrzJzhVkjjAfn0dViYnFEEpB2k^GZc@gw%&ID>@Ccf<(71%?g724U>Xy z&3yz?4H`b)8hPSO5~v1+ClMCs(f5iTj5aViCATnV_4lG3L>fNfCQ$*JuokdD`*z)B z)cYfU;Zi=ZUo3GOz-H;If1*NJbD6M)U>HZ@;{Z$=M`35hh)!7;*I2-v{xX)-$1;Th z%Sd#5X`JBbGXwr!xFc)}h6++Y%p>Yq@44=iVuC#H-D_jU7pq~EceC@8#)(>tFAlRY zZ2$*|a4kR~?;N<y_kKfE7G<xE>!36BP6uD+g;w!-4TmxF5`^WAXkNAx2Bnxs*xQi5 z#m`{GxcA>}5<zi?p{ALmviHY6vVFr44~qoN%7kP=Lg~Nm<$pZEo=O__RM3nit)R39 z;6WeVmz17^Hogx=e+ct^^!s<vzk<T*qxUg2DuCN^s1_;llh_Y05iF1CV0;~nBBYES zyym)tMCyJ7{^?#gNOt5Suk4Q|Si{BIjemDvij2;A$oc<?KTskKmGEj9|JrA6{JB>u zfpip)#+RHBF6s{(md@m)0K2p(4YO^fugT3k?rT1dw|86Esq{5}HnC{tP87&mR3Kmi zIS?AT(NvZ!#oil*1Pd<(Ng3_@X}vXBxAP79D!&I_mtZRHwJum!4kF%v_uhJna#9W{ zCMlL^CB=W^t?S68;>nPkiQfl8WURQs#q>|9A_49LvUGb*;FR7;RQ$Eokq$hVn3)?D zTQ<AaYE}p)Qw}%+PD7gHT3mrKTT44)nFiZ5E;pzEl@LVP45+vURMZBPx;Q<c5E3mw z#r2HbA1VT%aPV3)5*oM_U=YYkR1_39zSSl`ou=6hfPw{f%iVwz$|Y&oSTShZaTwNd zq`u9I#?=5O#(Dh1La%cQt(+%tyO2F!L)~qWDx+bEH*?REVm%73Q}(%*15^ti(L=)l zGHZ~zN@)F=aV7gRE&W)X$g#BZV7mt&ieG&&C5%8(ZS*j@bmj+irAGS`kLcJvNgOzF z7NyoA{;sj0!svCDKvni{yzISje4`w)jpa~W9wb|}+GxKBEfFHSnHN>eQ(AeKHBW-@ z14%7wZ%QlEy&5AifHHqfHig1D@Kt#-s(vMy>QdNgaU*iY3M4Fs_SQ66W{I@AF*Vzx zxLWp#LbM!45;*pLQ^clYm3Qdn-;{<>a*Qo_ja4F?tn@663rW}vC)b(i0J#h&6B0!) zXv9FF8m{<`^HD`dlYEE^&ApI-rBv^?43!L6$+~ou6jv)Vj=9;=BDRR?XonXsQ`Yf) zg(H=j<G2M~A@E(O!4OSm(l@2^4)bie26$5`%7l?fGvkN4Lu`Bza$MW^QNzY(q0JdH z*!YyIkQ`<I4{D1MONwF++J(Ad`GrzEY*2@am8oX3rA5lv#G)lLAK3|1WkF=GZ*0h} z=i;$_;tut4F+VEQC|!frg7RnKNV`%L?&>5USAgYL?`={9U|+4I2ZA!_)H1H?GERCy zsfC2OA4bNF6d&TS4w)UG5VHf;6}?z0(61QYVQ~&YF-{*OJ^+TQMJr4kr#RNP6h7o6 zEl#<ZqB1Ne`y=p{V&zT%IX<FPL|Axemm-#Il{ojvmf<+KlBW0-$b~Thfs%H3Vel8# za*igj!<-S+_+~5V(sUKJvv;+3Ohis~McR*1Ak1?uDn4Wm1Ox|a(Qy`0ljzn;T`2Mz z_a3Kv_X;$I7NdsL1Y42V77slxUvbCI{B$Qu5GNJ@!>&;W9F#)jtskof%Z!OZ$gG~- zy#1TM{pG(>nhkFSOO>!piI|_}+h~c0e%aHHZ3$tNGORTba0V~pLggT8hlwJQ7(->_ zFdt-lTJC)XBlg2!Prp?Z7G+<M#IH;=s5qEtpeU9wXYIYQ>_I>p5(<GNY_90weV@7c zq2KwlwXc5Qn(N#M`~0ha`>%fCH}AcF7dN|U7&Oyt!3q&cQ00EOZNpsO(Sz^*`!C&d z_s8%0+C$NGoJIK2_kH|*58U_%w|zFc?$p8P`)LZx%%nfiVV9R8kD_9(p_RDOXf5Q3 zMif_=X%4ANXknfT9;HOcKGCL;;N=@CjXUKb=yF6o@jnH2Azbh#!AvVyAT99ZXw8#j zZJuP8*2D0mg+nYV^2~_SZ$#*FPf#a16YR}WARzxN(^dA5j+a4L+Y%z*<SEKURK0RI z;!8Td#~f*;Pr-?v14EWof^^A8%uG>MH&f!^9b5pwr^|{;rSvIZ0x1vDgOa&7k}0#0 z8N@s*GYE#@t2X_f?5M>18^vdXSgguIFWL|UK+&)uNQ_v1hT6+D2w(DnfJ=}fa{XCu zdNnRQ1`?VJjlnUXJUa)jH36;<`EO?_D<v{uWzTDQE3r$0Jl^>x6|o?Ww25M{rTzRi z58%W&sGoQCxTw`e652>iV+Jm~^_}Z7s<Ym;LKqXoj(<qC`!PRCqGPbXoYbM8o5m`~ zjzS9e6>DR-WKM3mYVvSxtoISMWH95=^%iC@_Z4%>WODC|Dj-h1eoP*oK#aw~&K`rZ zow>_{8;~Jk(HT)wXTHFmCM=+$jkj2%nkwc=ikrrQ0<WB%r1;I7);05g#mK(mftvs| zm<5CI215LA3YYbP)XGa>*+d3Maj(_k%cs0@@IHzd3nxhbQSe*rxFD0`S?n<aGN~OE ze-II72pITt!$591I8GO>1696aUeAyNRa&J!wN<G@^c~QGfg<5;Bxm2!kW_2>47j=` z)hl<b6-^+NArSKr5#F#KB^!0(KigZf55^tOUMJth+&g*b>dMmZ(C&CngOP&|@!vLN zqkX=7$IZHGntXNJ3aT>~65<thsvy-Yli)V?7>(rP6jjIJ9%cjvS1`X4F9Kqr5@|mH zkS;Zl%<2o%{h=7SOCj0&E&&?ZEIB=pQw1xd_S0;x#VM5*5vQ0c>HQB^rBYVp=_0%= zA9~BS<<Zl;{OpQ{RO^M=uXTrIb~=gzqD+j-JyF<Q8>JLLFVd4pkAB!;lwvgjC8VV# z@N-MGULtrYIT%sy>!>c@=5jwjzErzM^lO34e>O$fMY;%W=BI8ZQaCR;fSzCvK*6n? zMeo8Ly*G$A{p8<LUyRlIVx-m=U4X2cOHc2oZqyf+njG{+cc{KV_cA~tRiseV`a<=B zz7Pp?nb=sVF{~&DZU_{kf?Qfd&hm1T1A<&mngpU<hd+#|2f+>SS@Oz)0UanxOXQi9 z5SbenPIZCJh{KIuc_h;ZBS7b-mGOmc@qRbGK8m&}*4?NtrOLPra<a%SL=RG3n9vcJ zT|4(+Jm)o)q-&(;YOOU<YOT?x)~Z(H!Owl(=kKY{d)j^Cs#SXNet%W!MV%8--WRsv z6%D!4ZRy768!s_R_b_6Y3F7nKk4bkZ(@=BZA0#i@Ofcrtg@=cGvmN{j?D{6t)3un+ zCV3~T*aB4l4oz%L$z-FEep)6!Ds3;Q0BH$Gq|9#2W4Q<N89k$ItB$UVTihP>qBua~ zX3!EC<}jI02TpJ~ys<%4++y<DBCzN(!dRZ`d!1n5`@Ku!;9a4l!>129H8g5uhi3>p zaqm{u&Ax|G*#P<xBVp1KG-&%?I6+0<bG{2oy>|qV>-#$!-*2|2SRan9DgD#&{<ELb zw2jL#o#1l*R$QJot-`eC@}%bS4uev~({p)LM9+-qTMiFo;L!!dug4iIH`kX45J!~A zIzT#8SlsNvDEo=#AcM*JFE%*hD_zr`_%=+%Hje^<l;Z*Tr7U`Y0$AjxfR}`&O}IKO z9z?}O+#IU{Du~Rnv&lx0M#A`LaV&gZZl9fb{=*v`Epjy=al}CrzK22LMHyr%=iivH z9xYo#%Bc$SOS``C1`&nWwbB@U3l|&XI3aaS)uSyCq%xsQ)kBZA0l<pbed^d+=91g2 zq=Cc^wpR?9P&F}HHxa5@*xClFoyB@c=vtRIjry{KI_CBQM7M|7Nwgh9e%4laAQ&fy zzjUzM-Vg`3plBd}IB;8`5<pvld&I!a;7V}&xgHvj&PoQ{-67z{<xmv^H|I}qqG=o4 zwq3>Mj^Ptvg|V^`Rye~3bv!iLx?6&~ZekO-ZT}pAW}r2=iHJ174Ph|A2Do)ORSoVY z$1RvtaKH+4o2HO%nu5jMtbdOO3ElLMbO@_V#@i)N=jd^4M+TD}{wDP0f}_#N$ySUm zNOuM&>#r%w)8aKQpmkB;4gOUgXIk81s);W@Qx>r0skFG$eajz}kR>?X5a((3_Jr+V zlD9bY22BD%x&gYt!~|f72_E$HAh8DpQbUilp%o)kV#Gyuz1OV)d5g=i?o)1TRO6C4 z+NN8qadefkbw*BFJT3GC)Vup50e_@ekC%z9c+_F?Bm!fktfv4RiB%8Tp<q@+S!p9) zakRL<s%Y<U;q^{MMnel;GA<r-vN&d)K$c95?hKffm+&8I+=C0znP4?|w44z6%bLpR z)#hu3IcXu(<}WtBzd_%#_r@ERxdQQcyBIz&k$RXk+!F+4=gL93>+Hs4>k0w&U~+uL zm$|jduM_{EDXC#gih4Yh2;Xx_i?4^^YHn9<o;LdNNFTaa+lHvfPiZdQU71S>Vr!Rr zjk2HGxGwi_17@+M9I#TRdS^c1vo&Dtz+u3gnQ4n(?Ewq)0LG%jOans>{p9Bt*(Iuo z2W1dJJ7knq*4*R<Yi_b>&DmP1K*DN}wXqo*AVgql0OcFaP56`<a*Xd^E;r!=Nh_5< zV3Wxy;9d!p7T*it@O?)#>heW1*dI*^(9ZW9t4V@_MI7OMnE*j%c%{JmR%=?dymHrh zpJfuSZ0|WnQSVi(Tk*fTs`wv#dNu^l_Or+m6VuSxoZE--J@!axkys%Yh8EeOxP=`| z{8k|zrYvn@^y`d35TfB(1VlnL!RvS@TiV)7vyH{U|I>ksX@m?fN^uIr$m3M}VK5p( zF5%NcE+}dXxxkr{(q+iSgcN3^LN55F7IHD5hp3BO)+*{ky<t%ox;cZW%cw_PaH|{I zbEJvBG7Y&x-)*Pi3P{)~zpV|o;tDcGORnIG<1^(7%hcW<sdC^7vUnR@p_mF>p<*>x zsC2~@BqlYu0vlYBz64{ELb9__M`eVGRAu6yST(1QFd-2=!qfnxB}g|xd5i0zniIz5 zX(3Fa>^}^*k~Eg6_t%PcK_MtAt*Y@cSvs*mo1ZK#%Fq{>%VnJ=K3nV<lwm!H3nC#+ zXj2pHe+jW&Itq@xB99VSHU{Leue2ptyk+7l%Rt|c#@}grP=`atE(sVAKs9Dyv9$M} zUd=?*lO_@(7c{E*R7-SKgpPXzTR`cACbg?vbqI#(L%CP6b~AFG-71gxV%rJzoSZCe zBJ921`=w{aKm{tr_xtdilw^6Ulc`HvmCjB1>sULVwTDHtUJbdH5yb3Q+&(E-!-AgR zdbRJH3P*J&2>l=pg0(rSO@;DAN4MTl2qz!G3Jn0Zl^L3pvrF?JphQfmqZJJ!x+qhc z5y}l9jLJ89<*X%+9<T@1jzauB+i<!QOi{$tO?~89`>?a2DeZ^cs7q`KfsrW|ood!v zJ2T@-pf!X-Ngggx97<kV3_7$#8vGt0Q@;0@M!{(iP(=1Oaf0Porg_zhp`=g-?t9@d z#VpwgBT&zJ|5-RL4Ibsfr3WoPYpt@5*i~XruKtndSh4~Gk9NLUmYWR8q<5EEP+N@H zm(+`geu@k)<2%_rSdRyzm$C1~tV0v$ik?5jWE($Tu1pw^(VEw$aMPV}ZCli7-cX#- z67}ApvDjJD`e|B3Jfmc3e?XsNt&zqeRS|t3u%HF;s=Wp1y_*a!q7Et&rGAm`nKsog zq%h+GvN`KV1qkC`3#^3E?-$L`SZU*u=JMp>j4ny`LNYA?-nRMPZR%BKb8;XU;a_}U zeJv7|nBCs{85PK~e@^1bhS-Rl$Gu8FrcD;WZdK9bfc^*+<W+($tgC-egJEq%B0y=0 zJnwk=;Q&!a17dJ!!6H9$@<ogcg5ZHf2(A}Q7t%PuYM|qBPEp`IB~Kt7WWa!~$zK!% zMdD~1HAMp<fzuX`75%T{KK_P9dMa@TN|Y;dJ?<VkM>FW4g=gcH@z(EdVg?G^mjx!~ zdEoInWh5{M$vsSX6LbV`6hUzKywgD00<&!Uh_|JzvPd7Gw4%lDM!h#Xeo}BWFMOh5 z)}rDoC60p6tfZ-=_XjG=8Rc=HiSxv%xNQtQ=Cv&yGFTZQmQ`4MNGeSwa#jC*RTuqp zTALrNRIY)AUQZb!iSkx!NB+d}cY436>VAW$Xcj>5LF#}w%tj5*D-Cwgk<2AN-MLw7 zdkl^YN^99pc^`l^XaT5&Uc)<E;7=({NDbPOB%nQ1NkLY~4&wQfM?5E2^i*cjhq_cz z1cMp|ZIK$&RCVrHL{ZZWmN5d|@YwDb>`>kc(2-_T?lP=KtpQWa8>56bWl2pq{z`{2 zxELfH@WPB`FHrF;1CB8?GQq&lCTa)$nE)O^7zg09%3cB6K?`<m<2M}RuwX8%-;^?s zGa(HB74w&c%IPSkUy(~$n(08{Xt6kZRX3FusIGZCugWDUrGNtFxpL%4;JjTVVv<pB zO(S7(=h_e{Y{E0QVHjX(zkG+3L9S=d(GD0nt2*`u_^UngSgpNZR(q<tH`=Y0hm@>F z6fm(u#*MY8tBgVwdViml2Fsyz;-!u8Rlb@hs#)TBWO78TLj&BM3!=0@h-grQy<bsV zfta?7+NI%p@6uB@FjP^_giWo}`*nMxlfP(O>3AtDFSmZQti%)S7R{9s>=g1zA`0HC zs+9XV`-IfEE<h4fT%x`ZaO{VMgmrA4BH$KpMZQtWPGJwsu36D6E&jY(vI(-*InsNN ze#$7RUjjgfbPIEKiXp`~7PI%W*2cFnZ*N|s_BN%{45>G>0L!rhCI<+IYs{>jp`Fq1 z(#E5&W;t3N8y*0dBgU3oPuqn?dft4nGDor>#`+YvrF={GHyR}U$Oufd3V0;gDG~Gl z^Ru%-RLzx&y?2%43V|a0v{HOSuuGKzRg7<3kWj_~!adW-{`EBy&xGTVp=U>7P#e1T zg0_3r3u+ms_v3X%`)&KWy{MhUV5g7lbSbgHZ0}vKx;f*sqii(JQk~e5<$I&3_a9Xx z^7ekxx_WNQIw(UX0+B%yg9A!=owbbe2rbu6Q+7qtNZDpYX}||G*0bG*VsZTuTBB(+ zvnULfP+psYbtxq43x8PtPfsS5Tu$Yu;>LAex+CwLlE-WMNW4<Ep8fmQxa(e}<lW66 zKs?{G?RDO{1cA_oMrJ5uN|xgre1x19DOHWf8xGE|fC4aR(sr5AB%Y=<Daiw9LX&(e zo2;P(85lNg3z-fcGD}!rEW=-4Od*iSyp>bdv%IR@=EjKmq(dXdYTV-e)3so^Pmibt zGrk|b1*Y>BBQBft&)C=J4WC)>twvg4*flfgt4?q7I}jUGOuP3<-em7<WK&^`M@|(^ zXN1GH2a6J9H%uUjHozunTiO&4n*Ahk3$@$5!ad|+Y>eJ}yNNdG(&Rv+(WKodVlMG} zf*_~Uw~gH(X4<0N&4VR#)KtsEjalr$m_x(d-Hs?_7Lr_*8xY&>#^ivQdeX%sC+!(9 z{b4w>p#3li(zy2vjgo{{B+BC6E%uO(eM{EmVA$J|lnsEFKT=#{wx<bZphtx}RCB=W zw4B$wS-+I1z<{+0k;?%s$if4Pa9EW#-N@L-l)_ieUKLk8vBobe-~X6kM>bC<NkpjQ zSG`%Bs|{o6z2D!69OwY$m-K$IF$QVmhZ_T@O)<#ghM2rPhV(6w(hJsba!4|;aqP&< zdfTbTcKt@&BvK97ij|vo<<s<jO@q*ZCU5~4X6RQDBOA%FvK>Nq`g@!vxTN>)>OC|l zho<)df6vg+fsOZ-o%c*(!(?gARmi5Fk#F03i?s(SX=uf>d%vgOgkvIUV#&$~={}DT zXM?J-x11YZDc{Tw>9TJMG6dfz*mkO$_U=)0yy81y1)3?IeZdqXS0#A?Ou!oiVa8Lz zw7ihSNyeNqDm86}PDJ$qj-WnptN;jxc|T^@e9%RRTv$t(k)*jI;Iq$2YE;CcXy0*d z`D1eABmj2JTP+g6$>j_wJVzO|@4ivwzSF#uGkpzGYEn9N1(vF|mcY$Pr7EVF0W&${ zlECKS+ho81OPNl^+N(~PJHn_J@^raayY^M5u4GA59~LDk_%?Q=pQNEI(UFy*WuaYb zi>58_Xh<Ol0o1_Hc=0I;-5{VjL$tuC1eXe>**_&_j~S)&l0)+B<XGV>WYAOIpHWk- zu<s)hmIL?=nL@jeJ4h->=ZVLHnMqkolmUI}NM)}8z|W`x$0~aT(d3!*Ah<IiJ`l17 zFnEojFXYK4-~%FP(L}i6E0h<2x2O~VtQ{~036WobhEX_kY(U=UZ;8^02%ooxpfp@8 z)Vo_O_jnlmykHtvzW*Q&u@a+yljqbf(}2UB8J<zr`$M&o%~}(|5q*>S=`1hGo@zZC zz>8?Xa#JMx?|*iks1Gc;{vLfPU@Z{!;pn0r`0VrhV%`Ca?Y$3MFGo$XgL6*H(}O{| z`vgF;ZdZIL0{o0QrigR7_!lRr+~*W4%|{@iXw9ZR%c_)%z$p7@j6eXSw5Bed8ubmj z64;S=7Kl--Dv+uO)rcKh8}bk`+FVlj7Hy%CV~+cmrYn9J0!l^Nq9i8_8J9X4Z5S(y zFs!(VIng~)lz?=^6$}?M*z7g2BN`U+fKQl=M4Du%nLr{HfG93T<k6}fEaNQ5b-M^J zO)x2t`}$BZ#%Uc3Fbdyz6Y5y4!tEkd`(n*3WZxI5=c(wqsJ_*J2fxgW$~rY&uZ!Nv z1IP}~ovQ;PG8gocdgVqHvm^i5DCDhs<E#;Yj5lgkp|bfWQ0E;L#R8k8jB#H7Rneq@ zyrg6HbNW+4N!X;@^X|cGIIDw?wY5{|a@{4wDq!Ias>ofO<^h1n1wMAXxc8U-<d?b# zShzmhX_jxlyVGr4Qdi^h5nxNo#|y>BBWOFk>iFaSNNeDPLpW54>x7<>d`R69TUrmF zJ$~}|4*lfwc74+QE(JH)C0eXqt#g}Nt&wfS?Cwf*ve@m?O_EX6D{kS39C*Io!Q1fl zLssz4@*TTHyvT>``Fh<zD1SH4tw;y^)AGE0PH46l&ll%Y*+&DY9f}I}1gH~kkU8FU zXi(xxrp;B@11<tUl<K^3f<X%;02eQ^6s<`N3f!<|*^<ILl=B%Kmr>!k#5|z~vNogQ zTjq-g(&K!|w85Xe@ImJ!&@fG;2HKEfk?MHfSuWxg2vr%;bV>+IBeu=I7-E)b*se8N zT%AW(9A~EDIKvBBi57iSQZUOa3UAT5hOKY3ss_VECOO2v+>cWZe{3+uVFt`k0qr5< z%sZ<EkYGiv0DTL~$1fj@YruwT22Af7F#oWUS4UO7d9snN&d0AfKG3mx8uEw^D8R4~ z2~27R<yaZFHqf(M){(8DQ_2y*=oA}o!Gej66;~dg2WUtR=E!-@h9~m!c$1!79gL&n zT6Gk72Qsp&>;`lTap{V4(fy<dK!alx`qjzD&4%|({#HBmZ7fXE6-9t*>#gEMoa|qw z8wW^CT+XT2h=kjdfsPks=u8&Fbfgc|Q|LocHZJc(Kk#L{&ZNbN1+1YcfTnQXxo|MD zgKAyDa0l|1<E(WRqKy8tC3p2@5;Ah@+Q`VG(@rgue$}Q*Z4V1T<VQI;c-GT1#ghq0 zjY~6(_dy!CR~e0lN{mnkBl>SDAgwt#8h}KTTuO3{$?@O1239eqfUJ)p!iPa9LRxi; zC-k*n0PZ4Ur%Gw@M3M5z%ShK`Zv|X}LSHDJ!+rpIF`x<0n@M%72C!)*p;$f8VpKd1 znut#>@b42Y*s<DI*W)GBO9-<D#M(tZYFHuR!delA*kWKeVvFMh0&S3il8bLUS#*wr z)7~JvfRi8cQ4Rp-aZLL;TNJcXWXEc=NnzQC1S^eItdvh27n<9!$-5Py*n#R0lJL+V z1crH`h(^y0{Dv1rh_#3nIdzQdi;Y_=!_bE<J(auTE9M8Bnlif_&h8`T^hLzO6(#l% z8Pp;k&BjXw;u#lV7-v&65YM=X!^{dTs|DhLW?IB!zXS2Gc}B!DLE{_9gd;p`VS2;k zlGS;MydYnm(VDP>CtPCiQ;E*vbzZnUl>FgwB^<kwwgy*|@=%0Q$E(#BhY>}@<Kj0d z?XdGjx5;c6)8qi!)NKgnhl)KJTadf0q7Krt#mf>i6b~axOn$>d4brX*iCX-Xj|g-! zBX63HzA@v1{eYEm0kD+zO8it@1DXZ7-;3r4W5oIk0J$*8j+2O_l+<SUYX~szOwDoM z1f<(jX0Jt6ZTF5i)xD#GblQ!zfVDTei*W1lDTZT|Cxg3HpjgM%!VY1$Xen7-<yKVF zd-H7Tu}}a(U$Ft%2LH|M|50t1VJxEh&|nEz4m*^ePujLQmIwmO0ur|k1jo6_s9bOC z<fzl{rIoDsn${aG;%jMoAm~#mq}E_}CjiFIIisrd8`EdOC+AvY_EKeGXyou>El(0Y z;@v_H%&YdhRUufN$@&~GKw=}bn282d+#P8NmXY}7AnaSn7B)-~zSeX`7O;PO>_Uf* zs~%G;BR&$}QN;9gdy~JUQnui4AJ5)jJyl09oYJb8S?yh#5dE;Yl>=ms2WV?YH%Nq4 zH1S;^XS@Vn%)JDLoIcbRzA2xXY2Z8f2fgW(e;z}O%=P?vBOqV34jgM159TTmhIMb3 z9}Zr2W63hf7Cyw<cW8ai|Fqu82^FeZ4@+v3q!q0>0=P|^h?AZel@V66CR;Tn9WCL= zXV8X;P95%rM<Aq}hs5HCF-me053tUev{~nXLZ`H#m@3<rg1cmpv!$igD;u&QW*Oui zGsq=pp|?&UgB<flUuBSouQJFxg$#0gfDZ8@GRR3<<#}izl$SwnPex>rn<2pF4savG z8Us<B@gk%11&l_MvOYX0x|}3MO2#t_-OMgoU*Lx2%N*Ap%idC1U_uc#n%4wAs;U@M z7=zuFvfJsC-4*3oIb&rHvWG+q6QUW6!(bYZ^Grr^gh|jE02187?Si)M5+DJ?1`vxg z%B^`mdXUP1+7LHXGH#fr{l;lWl$IWF>H)q++9lm6KH1R;C}Rkov`r#qZIg6H&QPDg z05sGZ(B`LyJ*63>@>{q_92c0n3j<6cg97HTPfo2A--WaRi1S5ZUISq5&Ev|97dwGd zz}rL#)lNRq1{>zMTg?VNx_MG^clupK+3o8(Uu9XB6b#iEWsjrto<TugIgyWZl%C`{ z^hR0HCo=;}OjUNeT?1^pGCRf9_(4#Mo`P5vhCqB~rYlAX9!OQSgahVX@s-%!2E;Qc zg+2gfp#u(vI}KPu<}{F*VI~CV!b$+_P-r03F%FMlDt=$q6D)O$-h)9qT0D`qHyE_b z#dB7)QfXGeIc&ei{Vxwy46Fg*WYLOo`;kj1*=HTGk{q(kLU<ZQdyW7kGxUN~C|P4h zpke`IjbH(H+yVxsOk75AKCZZ|8Ns6_$FOTL9c6Z72{U^cxr0m+Y+P8#pbP6&R6_^K zFdPQ}*m`|{VrGdNhYTG^<#7@|*)g;OS7v(^0f39eT!=u8vWYfWV6sTDL^jH9d7MaS z_Y-+v8C#fYW>{f@iH|7}>?_@TfnMUk>^df-B@m-ZD9zX?NCh~9rG<~lRG>;C*3VD| zMlj{gb&e*X<Q(V+Ov$mkJyCRgL`ak6jG4R15p*AZFyT_oniO#Jan;1-Ll+2V_H8@= z$^&3h@P|@VN;{jDg@hgxq{i7WN9EDki|yqL>!4F?lWzr93Fa#*VFZFl*S3MxFB3PB z9b|-dVR>ml<oIC}+V{PQx8UN6x4PCX4mMFok*o0*hTH~HrHb)Z*Qo}a+WI|&Bi=F; zDq0ZItK^<DL|jIi*BAnab&4nQOB~j9mr*Ie#$lK!R-1*`%^Zd=Q1X|IgbBvkWmmu0 zidf_(w!&yNsPR^W6{&~@r=bdPEFr>VbPbc?*2>MqK@pRUn9Lus{4+WJ9f|#}*bL-} z%_s-Wh|Nd@s=OlDOrxNSy2HuPuR2ut0{=w)`+ymZYzB?ZLKrr6>_|yj$k;TPENH{c z228-P=O9_WE;1&v(;#9(%*4S&#H1z$BDQZ0B9?MU4oEtfv$%<fc@aFzt%+Ey+8|=u z*i6I#K;}pAzXXp`2sDUzb4*DGHdHYsh;h50N>o%4C47R7B#eJxB)lo6WJF8!)7?iy z2%a^++M3-xOya>7eNu#4$WlnZO2Fu5pB;-wSy8~yaUD%kVG?E(^Ds8WEiuV51x(=j zqB`KNv{rO$TU#9(ezf^Z$tBoYGVqM=oco<*HbxD$Z)yf=wxw+?xj=*#QkBn~1R=sV zmTI8o(4e%KmM(ldKQrJmQ;FY6MX;a`t&bvJ_!i7+Jfck_*OCUZly**fNEGXbJ-h-p zJ3mUMRAhqylQ*TXhMDQH6`(kaN)jn5KoQ<;_bh}?L14q~x#yYXFi40I*ci>wn4{>& zn1wx+5$LESvbXp8O$D=8Jg{M5C<Z5b^KM#5P_O4;nTqv*pKFlVG>AaPx@9|*$kI7o zVE^Q!E&k*5j9$({3l^<rXG+Ar!GCZ)0)ya_^Ith98W#jeg-{KYNDTmcLAFA8fvU#} zf%D{1(7Vz=lril_7(yt>D!!E_7~h5{iI~Tw0Tz%gCBZ@vFJyq}D#kW^cw-!vxB<rG zg8^n7W+ZIH7D(4IijuHsXr&|~lmxtml1N-hK%A~5Kn(<H%LAL01i`+fQWG&Wf`snK zkH~e2p>%~2|Arys<(aNv0Y~zfIFrcIKEkD?(iMyVUBMa8(iH$)x?<FH#i%I3Ofqv3 z66p#J1YJQn>^bQQ!{Vq2O_ZrFBv0!O#mMRx`ZmVY-Wb8i2Fz$&S6B@d1p?>_C23~1 zF7~IkF81na4#r{HtWHM2j}urx2dj-jS_0z^lzQ*@GP{>C3Mjw~CnlyZ%?go3wK_IJ z#EwB2Nr@r!CvOuuJSAqOvdIJnYqTVKmDYpI%N*=rEgMrv9$?%Ac_1Mw_DB7yUTBZX zn(8Ev%$^9#JCtO@uO>;JmupMaHm^P98LZzG>vt9LaMlN8R>`KT_s;qXG|A$YxWl|e zORGG>j^oZq%mp;gG^uI#HmOrddrATiv0mwegw-@;3{i|{$kn%hYyz4Fd=A!~Bniaf zlEgfJRX?31>9!<^RwC?1cx*^2nk2Dr4M`&RTmuLok|Y6~2q4u!lGO0D7lcmB7kMf# z-oS!>R*bEr0oudmfqeMVj*5fEAsWC)eS_|*fS1RH9zs(s5F=?E$oklBOUN6$!U7rI zneIW9^pS>ZV~Wm#1O#W!w-T33(#bd2%gl>-uLxM%HrnH=2w1kVD>MkWCEb*HPHj_Y zYDg4qt+gpSHFc&pfuhrjqIqT%t*G-iM$u58?0hHTYSx;6Fd5Q7qm{O6gIB|PG-%Yc zogE<fu-dMi^d=guA+(7`OMO<&iy3aB3Ni3lnT4>~AHs{-pz2^s61mVFTd6v#3|DoK zx+E4Kk`EFFe0cX?pQf|QT+A>;(?La*ro+MVKQajv5vLxj)DrEcFxtcmbDNJyB!I(g zeIq5V$YvFXO^rh>F=|$XoFNn(q%Q@B{B#AURT?QcLtIzlRMe`;qrkm~ykj;pA%@t~ zPZ*}0A%hbSffJ&VT@Dnlj`DOBi6RQ%^K^nX8^)4cxfTFO$G2Oo@+>=&C>dP<QSymo zuyks4TOM<9WU9?ZAcH(D4h`Y;HR4TeLSiGFj!6~Q_bkiAHu8b|SI+N^AOTxnkVD2J zh|^BbkFs}+lZoOzkqm3~jK<>P0IfHI7fQt;^&nR2!Lj3fl6k;T)Vv*U0oE|A118~F z!(LLX*$Ha^o6xz`{z3pi83iRexca?z1usqM+&UaAnI$UA0IUkYL5DL<rYa%6TqH)f zXbFsehT<Uu7D{Etc(jqny^qVLu&hVDkYT<lq`FDa%QG<-&j!4q$BoFd0f67L!HDLj z<PHVtpe!@UJb7zl7<ZFLBO&j7VyPJ3tudQ5GIy(4dmjRrlcInH$p>*Mnvv{eO}kqI zjIu^^0Zbw;{+26yWxSQ{EosOlfYL@;?iM%7VO4+QjvPJ~`h>crczobvl`n<eI9&Bu z|FWHwH&OXgrI~0vrLj}nR*69xO-cE9d(L!ToN_^K@XoRrC?)k{Rw&O%agbd|&J53F zP>uCLFZ84`ZZ*L1ZgAOe%ZbSHNjIp>mtrhcG_H+Gjp!WW+p>TFyjwt)jHE%*>_%TI zfS#$hEFcTW$>}5vN5v43;~N%`wwLm68IUXIMB|b#N}Hk_7ElcUdm#_sUI4xc561;! z7_W(ewb3RqgmmE{JQmZ2^O!BDn(<IF8t-aG6S9KvNhG=?N{Uw!GeEfN7|HZFnG4X6 zQLKhY$bVzrX4ErZAjbhi>Sb@8vS0+qeFS%%sEuE{3cS)_`18zCsYye0f?y!{k8G-F zu`EO)LZbAfWy}Uc2|nSGnMoV$d@N{MhE`i<5{2E)r(S4JcD@B&fHK+u+sWm3Wfeu0 zRYdJz=Ud`S!_IfhDVS0=JG1jmF>SE(<&zHxGX*<e7M2@HrnTF%79l8fh}SEr1UI+h zL~iHfJ5+Z5hUyG>3mb|VgM*D!Za`DSV2GV>5j&#fm7UKws9d|IHLPIgmmrex3wA!` zpmJW6R;FIa&bJpO(m}gHrZVi?_#9@~gF^{$8#HDPEFl?QL5pv|ACbMmu!pz}s<mOi zv8!Q*y>+IW(`XZUW1Tfnqi)^uC=Gk4KGu|4^vWzz3d5!AaEfgsT>Di@!=8pj!q!@w zgo9zvn*ef)K-rZ+JPU@sMWMa{62`EXoN=11W?h3FUlzRtQC5b18@w9uqd}Br*xM=! z$D{_Lv~LZf3}LKt(wm5~hLBNfMU*u*>v{2Qvtcj(3`W<G7?uKGWekgzgTw-dZrCdt zxjIk?q$TFy3UN9E4SNhA6@}1Zk$N4ycYy`)t$Pf6txFIL4eJ~+AuJMt31Ouk!b-~! z7HFYu+i*6-uxELpGVD9FD3b)yALrz8nh}B9!Qk2>#^P{`No>7gxdUFnjmErZ07S4- zk6;07Gw&^eRU7!WaX57YUjrsVur%-ufo9-~YHbkAqwY^!FQyjniMax}Y`8F~I9(&7 zg2@L;X4#dNs%}`pT$2h9#<xMfIEU^Pdd3D}9~?=#vPL>{1@2o6Xp#be>zK&mJe#D< z>mE0HCA~kh#Xy-iSP}l(5XC4gLp{4KEsrHdi!eB<DO8IqAv+D-iEUKmzR!XMLjwfQ zF{za=LO%xx?r*m6d8PJRR{nq^A=uaDK85ArGguv}Tq<Nb&E4Ghm}x4XK-dB8$~+}n zK@pseXyLeQ{8V2h!dOVSXouGSFrCu{3!TYEU;?(@4dlh=6lK}omcw~09U!aL%@`FS zNX%`$_S9$yF=n(;6`~bip=lK2iJL8ghMF{OD({Gyv7<_uAl*kJfpV*+)E{D7HhD~x zF<3=(AV-9;mR1pH56uHlVw|iq=hc3{$Za0=N;l=%N!dGMO6;t*+NHbfE5oBNS!z1w z96pGuQ;SGzf{X=c{4i?_0*K)<hMd@OL^>aBOy7F<i=b!=#lsjysHTGWr#=NqPx?^3 ztbd!hQXc9p#oSdu10I@XdVi@FqpbH={&_>pTXo<E!o+L{;k*oyYh|VLHW+&fz?TOO z>DoNiV{%l&8mSH(3a9*==<>G!6KNF=9nv@2htAFC77`?t!pNhHImT?k`6E&?80Y_t z>!WYl2mEMQ0wOpQ+uNdsoyP@s1XbV##vOtI$68Va#uR4^V3uO|VB8_BO*;+-uckN+ zQQUA4j#xP+iXahL-Z+C?wx2*W+$CeV*o<A!H*v$vAVwz#SWn&Rp9*mRv$}8KUx|h+ z2?ehI#CzA*;JuxI?Y__JYk4Weueb00sYp#jFaOY~#Y^?c*@9rnw#AqX1MC>@mclxk z9$+;YJB7eEknF76sVVw~E65%`qczr7_eJO|g{@h8o7WyUoh_vwGGfQB#Et5%+V<1V zaXiCO7E8YEq!Pj6li2wBlGqn5V!wbzP+u-nb$U_5Jf~sBX-4*{D+C>*B;5jsqjw=P zg<tv(e?T=angVUy+QUwBKf{^nIo<Jqm^<kC(zVcyvhrk^O8Dy_bqrJ!FCw!t)c6?# zZ5|`G?Q~CS1PpYFzjF-yDWy4}h;~nXL3rTHjrOG%u8d%q^@TwcHp-G^m~zMrX9rNd zq=4-5hRzYe%F~Kn(~A(xQ__XE@{Dw;9Si-ycI75BV{<b;ozTSegm^TKa4g#6Ai|)g zKX|)QQa2nU{+4gTIk&yPkRUM|p9l(hSw>H6D{G(X2i^Veea7v-4ZHtf0Tau}BVj4E z<Dfw#@U@xRV-F{W1yhU7N#bmE$;Rzc`a4}iG>-3r%mpiTC{e{y8^`2S)GYI-Bm{Ty zv^iDWApXG6Ry}pi;Z%V(tR;@t5!taL3^HKQZ#)HhM43vyGF1^OYd4__HZ>~CT{4cL zASG#u*Dm+$RYK*Yz~_kdf__=7vPY~h*drZygyv8w%Uq!pR#Q@FDU5$ZDQ#HEX-iY! zF`0TYewe}DXk^gYLD|j=m>d#@>6i3eR$#`AE2zPW4UDxB)~-QV*|)V*&?Q6HT~j`I zZT=hXeD9MY*c_N}3`{r}iOP}|<%tEHo5FiVE=`m0&sgur1;^$AjT_}h_TLkW00%^` zvtxbCg}<-0ll&;<pp|#CMa^Fs%gcLelH$%!u1m&fMf^CXIQARrP2-k1aO)TCWq8&D z5WrGxG5>S;V1j4U*4v;mzhY>N!+Uha&~BbGbYx<;_^3YTbpsKhKwY1q0_RhkU;28U z8Y=FoN>Qd~W%3|TKKW`J#Tby{G@#E5kO1ysI#8>j0|s;Y4&FVigU1RR*dD7yLZIl- z?f;u}IdjJ@?W~S|g1Zujd9fWEp3{#|DV_*+@Kd8Nhurm;Cul!E7aTJdvo}odjz8A) z6wKE4or5=gBw8DE^>Ou&+>EBvCeq@sqeW7nw0$_@x&%g6Tur8<rpT)!3tp&xEu)$) zIbikHPj>!wFyp%i$Nvq;3n!CbjhWIOLEgQ2pq8r+x&3rV+tF?4O=k)nUe$f6bY5tb zwTaD*YS^$f#*Tb5{V4}AU%(y<y;q6I8EyiC1MQ~Sh8rnfqcYhSJ6qp{11I_VJ`#q_ zYV>0Hk<L<B2I8v8#x0tbc4iMN(*!X@s8@6IKY7;;6sSjr!#L_iB4xZ-*O6QDoTUvh zyu@IggEv9K+z}+zue)R{&dYOiY8!_jN<eWSXE<ZaXj83Y>@-GaT*m4~LUhnd3+9>& zH)BvV*%y)Jv#|!xRl|g;(n%Qx6XGCVTNNs7eEkX9LCE_GPeB*552Lt0Aroj^6X&&j zZ+^mMzMw?hnwIl1$d6qnp^)X6BpyFUOj}DTFkTk09QYwEjA|}eS~i9>&z$6d*uJ=9 z&GRx3FUMOi*%C=&^)usXKC)-VT0bFMRcX8pRM7atVL(Pu7eECFLBLKmW(Q&x)@P{Z z-Ry<6t5qvfiD|{u;dhIZ6!9}-iGDPI1qj2rE(+83?!m@}`aj(g)il*{Y7fK)a^(QT z`iOVh0aOy2SiwAG*^5b<VQF*K%&}qVMI!X<@XKXunXUcKwRHrG%>@Ull$%v6jFeXD zj^vb_w!K?kHM~9GAPjun?fvDMwD+*H9NNl9y7lFA^$}eT=+ph1$J4t<;_zQ5jpJ_l zqxm_eAEPGD?o$?&<l4pa#s6S0z$9Pu3#X>|xRx+R-@mS{zmn_izBBw-d;UB-i&FoU zUtopT)($itUWXY=qrI<4*G)}9(3rNqeM~}*WT?2f6?buLlFby#AC9pei~H_`2BS%M zq<(atJ)%PW=u`HHZC&-FzqUs@jJIm*{zz>p&!bpV!W6NT>fJ}|og%|9=R>17Zfk6Q zN>n=T{iBW9zW$Sag<gAK)i(s6gi_tkhty71B)I>P$t_BL*-HXbs2;gmTpROxEp|DC zZ>38h`FLi`%0SSdpZFsY=75fh?I+PSlp#4Bk|eBfQX5C?(NuUeRzFgFQ>7;BM_6R` zs8>IlpuZ79?HB@=DKJO;rdxbf-aB#%B!9`iS{X8^2=2@m_t_&^d-2G3Q7$e1nJ3#q z+vSL+nPa*yo_=ODR!k^z!IX8DK9Ycw@TpX}aU8zHJIs(T2|U?<>84#fpFTNY=D2Cp zs}yQN09~YgwnUrei&wJr&h=yfH%*c2{)c8#0c0?;KENc_A(_M!7Ci9C+(Be|zzVV9 z%T5qfyi7R+cF>Lz#jxS%N*M)+(CpwE|J{%`6=&I3z|Vsp6Ml%j(JcV76QH2KR1OHs z;w;x6j8G%cpFOe#V}Ar0ZF~d;k?=DA6q@=iPX0<oQs`toaP<XDMv9y0W;)`wi|W4^ z`+mH*_;_&>+xD#1c<~NvAdxy6EuJuMHt?ie8BPZ{UMxz<j~54C<Tu&cH@_C4-JNxK z108nbD2im%kK(_(L46TBAVU+3bG&$f5Q{AB^H{x?{uwV`#~5q`M8X3&;1IxJ59_IN zgos0W-CwE<z_Dy{qJ41%6^++R-mj8z7D{^S%5ZBo>i2EjphiW1G>JmNs)-al(`vrO zYwXfgVZwb-sEl?3nFte*Q7qR*7**<%=t8szEG40&8|vtR;v-RZ1g%=cz6MKgUor6( zAsDwQ1rxqsJKxqzi~ApX;e2sFvQ@_E+SmB;fO+}+s3fVqDn+O`DQ4tR?;pf1oMe&x zxo(ImqJ9~oxcgp{81e(Tag%l*{GTH_9D^B5iMBNrR_dYqB3tEkMurigQN{Pz$%n4v zOZ~ol1unLF1@O0e1;~cJsyo2aOE1uvE<~GCT5ps@5EYL-*-wi1XIhO(<fT6CBC#`J z0lk4mEg;p!8&F|nHJ6OVqIPCjT(dH7K?KPNNhprJQHT^#Q3=-f|E4i+BE}V1xP_D@ z3F<OtErfwvz8}xlV!})UZ^`&{p^WB1Qq0d!?}*a!zfSoje2OWfVr}mQLRYbNf21!& z9QYxdI4Z7-FW~!4@de8Pt2?hn+JyHePZDzVPi{i+O`m8WSZf~dejC>&8G^@_Pmkcv z@^E|z<y*hn3X9a*>eU$|_@Qov;3qt~2>#pOKQjdX!Uq1i=e`DbF%62f2h#vvnBcVh zvlTQR4M6Rz;56~*!s&XmFmSIPh7mTxY3<J&aOwi5-*{(9hX0aqdU6G)@kbX<?>$2} zUD^y<ZFH(+m6@lLHk=bJF1dRn_e#Iq^(md52i)0_%~VHg?&*-QXv<v)Ln5mR04l)h zcLO4@6cbcBCVnqsj|dB^uO>?Ibx}B`;n?(KZgDBw5X4+(JyF=1FnremV)BBqXvW>@ z8nB;HfdW1dtu(J}GHvUIdWwk)xEhx1Es2FpGL#<^TIrm;2(u)S7rvo5K5nzP`ob<! zoj$o|WIm+_{5&<bZQFL(XfRd0_QqT8iB=Z!DGWEAa+tX-H^{0t*>X5FF?`smk239| ze3HdXTl$>{+veyprJF?4h<q9hJ!hy2n^H=%QN9k+o3cEkM@6|jpu!YoFw`)hq@bRU zkr6n?LNJyPkfew-iZb{TOau0>@j79GhLSGOlfo)DeZ9wctAG^RfgSl{Sa1vdDS?t9 z^Fx*d_|Vd_<A5*&4@T$vvVJp}@)L>!Qx-R&-JW!)VlbGeBiWbRQT6UF8NW=^)}tfB z&w|R{hyb)YhT;Zed!vbBO`$23a*J3q=N<I&S!bB>;&1&65gQH**ijJY;m{tHVr{$` zwt}pzDl|9rZRo&W0`=l^)^0JpFr0+vt`4r#G;A)cwfRaH6VPbNd=bTwH0IIb^WG$N zhm6i3g<pgtvyDT|US;5U*B92;BG%=VX*d=Zsq7m!64@Rk>}6Sqwkyc;6yf22mhlES z%SOtXlrI<`A$IZmh8aL40yWbJz#z0r5)zi|7fWF9o5Smya20x~rc0Yx-Qd_=#h~VQ z8A$Rdd#5ZMVV3QIN@Xx4dq?_(ZwR3DSSTl$6i^-p0I~$SxOlJ}0KYdN)*Rf8<^R#k zTm4PtMdmn)_!V%=@$8pU7Oll8^&eiilzIU&hvg1#$bctOd0N3i#MH5vCLf&nrI#l2 z%tMsZFF^}y%cfW8jeS*Eo<UHN->#nVrqnj8^%zL@h!q=q#EK0ow)Th>8+*ix4F@OK zBUWte5k_i<6&tn1ij6&Dm7!CN*dtbJ?9qt(Y#M+`?@lH4ut*u8;6AdzMrapzX%Khm z0{Q=&<(y7SV(ML!b8r%4iA=nyF~JEj1Ds4OxdA5t7=_pZXmE}|xWMCSaoEu2<;cBh z_)p+yv-9(Gh(Q#AO|LH-QH!!tphKj{&3uwda>lYZk0fKb{l$|><d+)5L5Ds~VBiam zV)GX<8v{tXc8vwa^Il<E5UY&=q6|iC$5PRC@71SZF<Twib1P;$OK~fY_a~?%Z6rws zIOTt#bdQ_PvFA_<%WRVKQ^6ab&!+%K<44l`kybyDC6lI+f(F!~bQ1g=O)_PzRt-{8 zP4=hIcdRZ<7#4Io2vKGhiuN+y{-jL{#k4P;9O$MwX8@nQ#h{4~ESR2`zK^TdoRXFv zpY9}497$vOviO){bMh(r3~3}dO*9Uc`VatE>rZHm7MF&Nv|K{Wq=*?K@>22k2`M@x zPD839Dp1<u*oYei2GchSGY?&LyttiDrL(cOfd))`Oju_FGRs9Dy;DLGH++DJTobV# z`$;_pRBOBY;=EzqcT9@`$h_Th!V?9P8F5n}(x!T3`7rbtRXr+&=t4%z$4lVO)6ben zfK6?9CovG2X3fNQj8qU9bqGwfAb{$zaZa?y*(XvumHpj_Z3aya0HP>ZVGNH{G0`m{ zFP*|c?9SFNl}6%=K28pr2m*i~vIU_J21S<Hc=5`M>Zi<}rgP**QV}>{uV@<a)u48* z)0D;fsBFa1V9Z4bO&Ip#ylRLNYKj#WOi*@Ago3H@vE^DrQ60aWjePjB9_S%DBijZ( zd`c86ASU`7(VBU9n1qJ}rN|8Hk1>sL1gouycUj0n&8;i5o)s%#BGv$(+CW@9FnU~@ z#es<JDMC<TE$LUD_G3W<3tUonoJc}3dXk}!oLsiUx3%NP?y%b8onOT^wBA+?CUyn4 zXW9+GgNtJhHYC0&e*j1ULPEO_UHjnZalYE;JW-_15l-C|%vrUD3l)|cEgrPRZXl`t z*iK~=P_9UrfDu7}tyJ~!iOhqy0BZ`7ToW2f>V^a~<OuN4(9RKLhK$Tab8f&U{()*f z-cNw^IDf}L2w`laVgkCR`$VPVU_c=??aL?$je-q&B#E>lLA6=^01_PcAd1w%F34Sz zzG}+w3dffXQc_JCc_MuY%pk(XvI`;x#biZf(qU+@4YIBv{ze&7DGKcfMJm=5Fxju2 z)|M7#B=F!(Apl!*axmUnAvI#fBx{)0)t;>{mxfcTiqtKdUE`<?US=<crQELb52cCL zstFkSn32J()oBG1nRd2QX`eQYTnYK1rtNTxbt=Rq0km1F@#!p8R3)IbAXzo{U^hw) zj0j#}rTA(I0>Q5hdYY(AV=FyU{L|K@Wg0h(;gRh6q+StabuHV44qJm1)NhHfc83TJ zOa`rOKyF&wfF@)Wk&a0orit~D-q&RCBc_zNG2y@P|FXT&6bgqAl>2huNzW1mE2A5Y z5DjWVD1lJ|qB6QS0|M8x0)-Kp09P5^!^-<9Q{{|hcfY;MrX#0C34v6aeSPEZaeIoX zh6IwGGEL5*OmQx0He|X<2JC`Krqz2#GKFj@AB9rutgha<P$2W-xEbzR=23nnhCY7n zV65a}bWcMlXd%DxrXdtq{Y5Y$6tuYv%LfxYxX98Ocxz=f1g@HtJe5f{f@OngA;hD5 zG^ae~9ruAsLb0eUZ{EPurG>$m9fmGWq6L?GW5vY^4v=-=kNAd$rjsQSzO^iYyjrl- zhN?%MmnpL3)DIMZvSQ8nQv|_Eo9GloI_eo{Da#)Snz5I_?c<)Lsv{oV*kiJs>248n zw>|<RiQqJUH}}XOHFHnmKuJYRg07)i9Zb-WDL5;LN-N|n!TYA{^Yj!c^@cg;ec+ZP z7Ycw1nXt)z>pqh5c;zj*PO!_q{j{8!-8q?#YYVY9_FD8p{>!tN6yD7FeBix2jSn1< z^V}<8)v~xYQSyboL<zAloLV|)Bd!K~5*jq~1UQWI*BuHHFNC|)@uD+42c#jG)s7?> zxA=@@+wf~rzXn>7qpk6L5`{xMs!z}f1aUCde18Hrtq1`%ow?V&U@*CZ0%*^CatEg* zQUZ)4N#-f86JC8yG=usuFw1b#U0+xyM$vmvLJ0a-aq`uqc!kI2_@#c~v_M%pP%+G4 ztMU5=j%xg>ukqt33>*L0@bRB^q&_i>A#1xmdIU-NNBkmU%||a9WIF~^2H5f?AcnJZ zo=@Z~g3*YK4tNYBn3<(OGN0=GuHb`g0JR7tjCy21!MPc1+wV^^vGD-w3a+1VxPBqP zwId^DvMih)IEZa7BPPQ@d1@5>D8I=FsK3V5SG~=rPw`)W8oV_O7<V7lj6$bsA(W*c z$kQRQ4ezm&Pw9wOsiG;}$pL+EHaI_!Uf1|}<C+w|P^Ll^pNg|W&j<pH5Fvb<NS0s$ zht4t*meQOAbpAFxH@~1{nwLzOJq=s~CEJ>oqT5}gYpzFtLZMe0g1$VOtKM|_uLV)J z39pm+7~5Sm5D2y;^hqj?TrlX0&>39b-J#iYa6L?6w(*9+Z)*;ovrvW&Uit4?zO5WQ zN=ARS8oa1ttHEy*5%hfU+rELp1J`YkB7ZO1v7i71!@%_qR5KyQWs~vo5apOCBtF7R zOT?WJk#TaRQRHMGvdOQT$VLDy3Q<?CB2LUo7AChOG6*3!Z^9NTP^mb|yWL8h>d>{_ z6yXHLU3?&FbIDlSEvLjbZ6U+P+|I|8lFj5Rb1zsF!cX9Rs^YT;+k94wix0O28-(cw zQgPoIzSyd%zerlyn8_|P1B(gVODv}<2e$asD1>g0%N$s;X;}Um*^~pzo{It6sv!Aw zksKR7j_6F-DkBvxsZwA^lmctUVlgaDHKf3@j(qDUCBd|ukw}mGidWyZh$YJFVK+4K z!;QJHLF3c*BFh*<dhqrLUNT@0UwLDYz{bYF*RT{=gF-=eQ8NX$O!>)Eu2=)%F%A$0 za_{<6rk5U?NIGcioet{rkTVW-L^t|cJ_$sGIFP<ZV733qRl7(A8!hr>6uCp=7bzWH z5}mg%;R8A>O~k1VAmg{_daQb=2Lc%%KQhnj%81+{V~sY&`1C;2O0K6_T?j#7S)-wA z3r^I%ycqurQ;rIOF0cRl^`<7a=4b>W!b`(sCMw%)tPG;@@gkH4nKN&GSw>`N>-KBa z7A3ejw^QXnp0+wH-)!NtXVik%88U$1UmH3AKa<$7rL=l-a3xQLAG3AvyRmij!srW) z;@0<zb&xWpcnRQ3JOM!&$-p%+5Mu*-kZ3?w2YExXY;{|a<R)tqaq(QzKzR6s<9+p! z7Ozm}Q8BX0USKaqEM!$>6Uvf$VWp1Ax{fI~xw@fLs#3gojCN%`5K%)o5=FA&v-9op z4wcFZ8n%~+su36vM!{1mSp1RxwfzE=_eI{_0h@2QaTqW)C}t0Se}Wbws6_ihL{KuI z8}upg==Vl)HVHrlC+8BX-xJ6=6*-HDm03s9A>X)8gQn6025l-jI!ZneLEFl~76fe# z(u@%_s~iFq0R@z)Al73p@Fe&VF$nTo69ZK#wXt_dgeHuBY`*_z5ktv-XF?C38cGkH z-s=J#IAutDmXz^`Isfi>*w)a3wJ>0<H60?S-=QyT^28?`#7(bI94^&}|J3_?8N^ON zig2XGwU*U}7^XnptcG?``?9Gdq~Xd*&Nc+|(SpK7O}`RpVMSqd8%CpZ(OFVul;&yn zqwO5&puROC8x>Os!HgaYNH7O%nQL=8gX_Ik0QQXx+Sp~2s+JEE3+l|1DHqt*^K%(d znfU5Fh@HS%ARFD?#UemhlpOP6U)xuHo&rSk{EM+uD#{SC6C}6p^yAjVqt|I2Gb=F- zOl~d5DTEJ0g>OHGrbEV!LA4`T8dU9@K@~oBsQOMct!HjMw=)3f83WS;A*b)}3}E?n z^w*-pPWJdHkjdBnNYcG{GKrg?6>~({^ICDnco$$>gZy^BL!YKmVloG$t;;q(b4qRG zP-&^`8yiz^`lMbKXZKzs^3DEDtMxRr4kBbo!Cf+>(x8;?QID>alpvdi4Rl55FBO?q zo5Ze->y}Aw>V}rhD3gh1Et{~H%(vy?p!`^isJF{ic8yXb;^q!HGjYnyr%(BmJrkmw z^*QKsNJxMPlvxM7*_&^Y%A}MRQz74woV_k4(_tsmMHSb3->q0bEuwyu9&oM%k{dak zeaj))p6=yC13+;&>i}BT7|z|aW858;W-wB+mQzt?Idzp-(!v_vouXv8Wi(&r1IV~h zB$<UfVUILB1^;9UM2wo<o@N8dsaHIcy;JjhBfKkJQA1QjI$npZ5Xe-@gJ(I=qSXpF z$`Z2*_f{(7fJ3xM6j>RJE#zY%=|JJr@N{&6ediC8cc9T(=0O-6gV5M`j4flM(})yF zm57zFWRN;>z@nd++VpR!2C^R59GB?uEAIB;Z}8oND3_&fP!n)0&syOeE+%1xkC>6b zg^PxjPAIjYL&8YZ0DYV#ZBWBU@o+9slWE1p7QW6%8G=ayq@oTC)4*5d58vvRd>Q*` z0u!emG<3A>QK$WJQ;O1$Emcjp!Rx!yU^%)n7$?LMAUx)&%sEdD?rjKT@-P5QG;?V* z0qGQ(z7EzABL!_O7RooyfJ8n)mM#4a$ZCa$jqX6Yn$%lclggr<cB4Ib-;{*>;GeD| zG_W0$LPyd>#RoptJ;N+>ARt}Onst?h{x6(m{<%8K{Hbymusj~6Ec4&B^8)Z@pd&es zTV<JVOyYmnpEbbySipPFEoE{o5juz-My|ovO5*2O!fyb)V!thrvem140FXg?nk4?8 zBX!E`u1@i!M;E-G{;0t8@ZcrU&nVf{Qx>j5e*394H04*uYNHp0w!u_PaH>GvbSH|F z6hWUzSrGLKI<g<MO;o&ZYikKx5GL6larC%So^<d(YM5h<@fgAE9bOaYOVM(hvh$#= z81`cyx0;agjPC<8GJ>;AiP=9&Mnn}Bch>E2!UkLBTJ5Ml?aK#K;k&#r5ey|6at~y) zFhkiJJ#G-|<Lp7o!X%=`wgsok1q7FNuFxRfxJFw+qgs~SY&#O2;xd?5Ne9=^tCl5) zmM?E*Qt%BIA6XigRny|P%V}?}8bSt}hRH1I`qgUskoqB-NWZ+4LcY@v4Up*KtQCLu zp-&nO`p?ac$e^z#Ts2g!#BZ)ZI+TW&Xg$6sTCt?k?G7@0sj|On3@p>tBh#RqT0pFV ztwv>p<wI(zXmNY#yp%bk<-1LIsaDQ64--6=m&^$-&}0qM-*cQ`+3~pTI-s_ak{LQ1 znSp7P*wkrJOsh%6{H(}U$awHfXqH%mXjZWXMnXc&H%PP9Tq_zsJ<VcY2_|?xKQA#u zqBrxdlWw)ReplSu$QvY2p`=pDoB>V}qb9nn5Hn74ULF1S$3}4x?-auAgb*%yKV=Ac zIx~6eIpraOJ0*AyiQvLb!y>pU&gxg?n5zHUbWO9%ENNJ1H<<|0X|QsvHF$;g$}Ox_ zGCKlnlS6MAG%-5|l&21RNXj)=o3qz%VYAaoKP>=kicMKeBgC`>8r-em)NNt2W67V! zZ1<+v=n8zdFxx-9<?4XLfItc6%@a-xq7~X8+Fvab*kJ#Ey+i<DNJ)DF>AV2g7b)gX zvrxcU84#8IBW8M+>FgUu<82K;F5P8{5FQc3D8>ZH2l|2yu0YjjBUWT~vMnEYQkrD| zGL(3Mh46?~-?$I!dg3kEnq)=Pc3UK7)K6k-45r1M+iVj&26*vKf?ILEtr%#ra~u1k z^(l78aI&3*w&3o;{b?SCq~2+zjBN8D_BONRx5>|&q;UnYS*=tMyO&QZcVv=@ZSz=m zPvPrqxo@R2{RmZ-sWXi;6bf5NTT7J+X>Tsfi#nvejf%u^SYu7;hQz`~2rpF#Z#w5= za)8r(mWFDn4<=&9m>eL<WY^(R^1(>Aj_MNUW4SSG&ScTO3`kp;znd$%PlNbB345v$ z7lW*k7AIAzfE-eSwfw;9*PBW%gP=C1=@4&3FN$<p*tdlOUgVfPXXVY-ZKtw<d5z$@ zB<?i$@CUkak_`ZQP~i0gBXZC2!nxXgTDb#YXv{?}Z$fQ>yj5EdTD6gV+o;WgJM~FS z$R}C6*GU4tDaWFZ_AXIFt#uR!ajeH1S5crLTSbX;_^Vpj;;F-}R3dL}!7UH4XD`iy z0lootIYh8i6O6h<vEIU;cd`#8!*p3N^D3+7Bd*;ESZrw*DDxV-06w#2KDfy2fQDTF z;x^d@A1e)xN>+p%J2Pdc2B;N_EVFn6$jq2Uq{uK3xtd`BeQ(^@V${>v*uw9P){Mw0 z%o0mO)E3t{cEid`F4XIwG6SBZfSP$HL6cf5CdT;IkX+xGDh%<|Z<Z=NF*Kqk{9-Wh z5%qz+iExE;k`QwIn9P3<Ig;yVQBGt=WikBDPp_L>2kt#`r$%b9#ot&U@AO8&X)FS{ zWe*d`drvY94w6Qqx6>^*{H&XYS^*L9_R}H?HjQ`tO^QQ8w-3|bqYJJU-A5UbYt43G zACqU9J7`Kgd!{&2CM05~461%Qu(t=dl|~+f|Njc^LWp{ElnAtjneZAj6TU~B<(?+e zgzg}s;K8W_IZ%-~aY^Y^`A<=A+f>auQ4v-D>}j4_=c&;UZ#eZb%l_d6t6=2GM%e?0 z-tmWz+50!Y_2ysP_2S?D>F0juUyjGmT94M_{raEJbJwG9T{$v$Xf~f)JUn}4zOp*A zyt<Oltmf-e`6VY-^V!A2{xf>)C0DJ^=F2mSm(8B8SZHX8x=WYlmn@y2f&KaF<+J&b z*~NTrC0~DxHLNGzr=CS4FI>u3m-up)4I#JA{QdfOs9LMR{L-am2C+Q1c$s>k<;zwt zr}8$2`wdooOk-wYVC#djSAS=n8nBvu_WY%K4&yzuY9|&K7MHGA%nvOcJvu|>^{6+q zvNF58I=8f#UwmZtQgHBp<;!!IUA}s8e(B8Ik=er&Ia9rKb$`A-KH+cpI;2{!UCql6 ztNNltGmDE$tNF~KL$fR3Ie+=(%d<0w^A|52nmLlcY;|dQ25g_4IdWn)KYYT$y`0Zn zx;nd@AD#sPCk|=28pV2|fA{?$Gg#TTdim1PnU#I#E}vLgJ#lQ`>eAAYLzmCYEw1bX zyO$i9-8(b0eCYCLJZ<mHv19x?JhObo+~U3?bC>K>4SlXGAF}UyxID9b)xMb{N0tuR z<3meFj?7xWEBh{4U7nrYcXa01zS+gq<*UwJUWv}NLaVb^uIiUHa%kVv&Y$_t@BHpV zvoo`o9)8+)pa1kjPe1Rwzx%vPp8B+>KJ^)wJmc{BPk+YDrRVW{NOzYWeMAkdtRA)j ztz5<AuUb5`@8}7}wr_Fi6{`m8l}9qlO^q*K0hAUVrPdEEEzkPcMfCfQ&K^B<`LYf7 zn}+4O%hlz5$7WU`w8cjPpe@_`rp=&4iDP#k=$JY3NM=yBC;FLN+BdtrytKUUikX$U zN2AeWGmCSF7LcOfT&$jscNb?)&RsUMI{Ro$M=&}zbHu>2@7#0uomiZ^a^LFQ(MQyH zMM70W-!$4eEu5APKRVNZh~}1FVM_mzz`Ozr@^RnMrNfU(TWFL=+E&RgO!E<rs>a6h z%oVRVIg99iWYa8%7DU6`qXV-BKKCup9$S9J+!A9II$RT?K=mWtvuEFW_uToJlQYr2 zVOG`?Wo4N$kfW=Y-E>3qJ~N+N)ho33+~L`LeJtNC;|!xMlkMeJ*poJ!UXQRn*`~?v zCt+7Sdl1Eg^>`3H77ettd{u6UnK3(w0j$5!EWVY}Mp{{zJ9bQl;UzPN7FL&Mux*cF zxvc6D7Us;!rMbiTv87cEhB=I!<=I0gmRB%hCZeZbI<u-LCwOyoHaAhbKQybVbBl9S z!3Z|7`4lez7@m^PT(X4Sws_gZR%4uQk1?8b$vb=4OzmX<#QOg|;bh^BFYxAy%dz<w z`;u%kjo#{<TXlBsL^NJj`i}MVJM!J@(@(0a%Z69O@kI2va(ozZvzm#uRCP^>+VXTu zbALX2__En^FQ1k9nLqp4`O55(OV3@D3<}To=1<*R39Ic~jPc!1QkBCitNvqYk!c(` zaoOBrzIxR$zGFRN^&aE-;_5N5BqQ^{OJ|m6j_w|SsoCXAg*7b9OJ`0TSykV%_ZDRw zpE!0H3>@ak>g?R%#hIhCE3_~Raql_z%A-e~g(*n$n@2Wx_{#iQ`SXgG7SFrz<v+Cf z{jllQGo3s9?7_|-o;!-MdXzEG9<IKEml=5@N3m6m!j@;mE@erl(&gFJ6U&Q2_F^EK zD=x<wk_&`;G`oD{DpiG(W@nD(hapec@KPaP^8-|itJuz7{lwzzmB)YvzrODWUu;x$ zl$Xysv8b|#pF_>r{UYJ~iaA`Oi+|t;U-t4>94wx9(aV3Jc-{-1{nU%`=uXZpFD+^w zSoX_vvd2Xehv!y~9hrf<YQ~wscERnSmDv>#v48)G#VeL)j_rQZ#W_9-lezc>%gYzL zfAH_W{OMQRmwfpHpa0;oBVU;L;I8*y^=+^H@E0Du<|RM$uSag){qK$<l$I|&vUCMp zlt2H(vH@oQ{>51gfBXyPES|$VIC^X-7rJ4a%erMi^2DxKh8q_zYcO9ZQ}#qTb7oX$ zr45JR_5|KPIeUa@A3A<wZh1C0^L*y;$(hANLdx7yZmM!6zhw5RCEPTSHn$u+&bruz zOD{9WFcg}@hjynlI=FP@vkqUim=}vzJtuJZp_vma3~q60aW4SHX;ep>nA;gqn9)pU z5eG{k>XH+eUW$jc3fI(l+CSlFf7ue`a(EVMIf66CWR{N3VL~5%%GLyN>4_spa4&)9 zOb&)FSz4iwQgEI-A)1uM`HE$egC~|@NqM3g)@8Gca2g}S%sMW);=bdB&)=V40szF8 z`RtXKGg*f4gR7UG53wvQ8?s+<;@B%zm%L@*^nFMIjlD28)QSaMUOwYG>5^IarGb1Q z7N`y4vGF}qd-m)(XV0FIB>nb1dmcZzXGdqxp6}SXeb1h4kJ+8?+0&oc)7`yiPeiFF z?%8wpuF+|}?c(pAIMcspJ#NppeCxM8A&&gNmz}UdL>EH&m!S<T5V11L1Q!p@M$gBu zg@A43k#mLVOex;k6<A8#6n&pu>U?$es_3P&%SY#s-muHz*~Pip!_iA5o=kUvGl@b+ z5q18*_P#tiuB*)R-uu>iwQAo>rNx`9l4{dZUJ`kY-FD)T#YqTBRF=xNVp&pDk`t3d zlZ5VMh6xaQm}y83j07R4yVHb*ft=8t%p?Ka1H*wZLx(d=_p}wlIo$_3I1NKXLX!D? z_q|v3s#NmE^x+@MzN&Zk?|%1N?!Di4U&RWG6USqc?=c;dAjS!7z)u%*98JLXl4Qu= zGgVk9u)}g|j{S9PIyVm~N)rhIiOwNtWlcE^@-#uha0)v)cE~=QvDGOJV|H;KlXYaV zaGLEW`8&TjM-m3>>4p5s7)gOrDWuHdTws0*OtXm9JLo|UdcVWRg?#=5+ciI1D8wi8 z)Lm?PYUTv$=O*K`GpHm-XgAGvyQ7p?M9aY0uGyIxdw&o9E#wz41;A5&-h(=l6_a#0 zQ(pKe@fF)QTaXKu#TifumNGOWJ0>U57A<08D8qKn&z=B5%}oJG`v(s>^XX2312*x5 zU9C6;b`GL6zoi-4Pe&)E=nXh~utzB|y62w!grg#yj$Bk>$`(luvmMR@PzBiKbk5Ek zW7nTPd1MxHMPd--KRYKUUuuP7p&KyyI6yibo1Mdu2)S~Y4uUZziEo_NOw3QsfuI0u zan5$)tfwNI#yWwO2i8dT%7alM7ZH1j_KM`jiha`*7pSTfww@k}#5IZer_9*hvx{ha zb^^;TcKrg$4`KumJIQT1S=Y{kZe|y8=65$F3=mt!Hjv#DQ2&!`@8XO+1(NlP8W$M= z1FEDzs|u_SF>(+YBo=YResJN!)X^z4JCDWWDU?14&|@TLq>L%#0R>X8ou6Hp#YvMh zV}t{zO=tECq=^>TZs@G38JnRUB%x)gN+#LW4iqJX6`Q>qDj4fgS`HD<I;|D62_>az zDoZXb&Vm2Ho)VB!%q8)oxs$}2)Iu>+vXC>PA}^HpaZP^V?%DYhw9=mh-kBTsa#1zT zoU!Y98Bwk_?Rl`(Rm@ffr2$<pkw<-DaYM|G(oPydRZ1*L%iJ`{{L{q2bMv#uh?_kE z!J7)A0+C9xK8}M`aR0j&XCV^v_e|uma>TS?Ei-j|b`A?*5@yu-uH0Pi$ka4e6q0)i z(6Un#Q^cB#EsAT{%lsAfD{3?Uu=Y=)QT=E3#|l?Qwa@bh_#tIbjk51)pJug6RC$sA zAH`&Ut}#9-Ugvo&BOVst5pm_9`a|tMiC?PinyGz39MBrXJ?uKMO?h2;m3>utpZYEK zXUb`<SIqE}O1Ju?x|_YppJE?S#@Ua!p?*xgUER!X=2`wn>_zP{zDw-oztAQC^TgBw z7~ff1{p&15r8@pIZgG`sO&oes4TLm<bAFh2)N&zI&UN?^A)%gM&;OnI1YeKeq}sG0 zgcR7MI5*o=wZpHCawBw<o2scQmLhm05zIH^jxkzo7Ik$khF@#uhQZA~ymG+M5Aq0C z)Nrtln|R51BS(jds?amF$W^>DHy{8Lem+gL^QkYPYKWmt15nlIeueu08P!I4g`S1N z!)k+ydTzUvBijWmcn<Oiz*q3PKLCjlC<1R&4yYPZ@L%T}gJ`9RM-&s^s7P%JwH?2m zBGWBu&Cb!#23}P`<?+HAO2aQ6sD{?4<Ed6@25ejyCXmO?S`pl$89Gl>pBit*-#V@c zjc^-++sTzC?^Wy62=CoBf?8T+%u>aADk?T}26WJbsA3aBS1}d_!Kh-_x=7@(&=n?X zc&m0m@f-F2hCsc9O9V2aE`*ugb&IiC+iP{gQ_$BG6`H?aZ5?h`uN3tu-X#1=GQ-2% zO!o=Z>gWE@NTXu)3ct2lD4SG(2&`()*LC}u(8YQL6VTA7Qw+(OQ@Ek1LRW*piLroR z<BkOL=oJ&l6^#&p58ft=>C;qRvx#Nck96QaV2^Or;E^VuftI}gVuPG1Ej~jNntE7Q z_`A8PZRWSv#1aw4#@Ma_!IX$4>IXGbxod+@923{oH}J0JOkg+X{iwhdt4r$%b|`5c z2=hq9()frkRNJKNA#j=%5y3AC>KKhK``*k$`n?S-(4^x12jb54MnJ<)@CGpjHl0s* zvDz^)yfLt#@rQa_^;Z8aimWY^o&ayfd<q^Gq3(P446zO+IT#B@war>o^r|M;`aV_L ztM;lJ%}l@#OcT92-%2LHe2tdbtn)tZ+oOX(`U&5n$!hX-?m(+nqlMnqkLqF!DAkD4 zI{G@4jvkY@w+$n=E?7IWe<U4D2RC-NW2Tzk=p(w0=qW|%u(;XSS3A(q*5uo}S+lY} z9&Bk!*QjO>m}iWuKAI%nQ_H)-7U~s}&H$lhWSc<q4sRBDh&V(u+EE2?u7}W|Z$L%j zp?@bJQ|p!JPU7KbhK13hcV9`OW9zWsSG9>ygYlK@v9V4t6(~sXC^!qeb(<*$1QCg7 zOAD)JpT=wVA2V<6>h$qAQCBhw`4HMqIO|70#F3~7d<nh|0g|AppEdbv_$Eb}<%ihO z*_yAhT$t79ElguL3}Z%T&7qoTZ6cy}HfVJ${y)cJEuy9WfSNUG#sF);@Ucy?T!BHb zphXtu9S{y7)nx317F2wOrE+#7zY5(F$SQtFlcVGgaG#0qcEA8CQ4G#R3vYo8Ee|H^ z)b+nHcn!E1nAEQ5;&XA!h;SCI?Pu+;Mg{*)CvP;xS%Yv1?0<dhZW1SerwwuXPvRLL z-Toq^+Ub-?ABqw5sw`mf8gQb<YZxd;Z)Gro3;YYqEf8=*cy~Vbu4s+FLdXpJ?;2&Q zBwt*4cSlyGf%9ZlDSa79kX1zK^^mRWMGE9Q0xhQd8Q&)Ip{*h*!n_?Q^=sYQKNYzE z8cO(pHZU}4648QsW!ecfNIyZ3kbW|1tTO!sM#H3HdQ}mCA}rHTvR#FQDB7*H*3wYO zhQ4Tk5|cVgkj7IDM@I?EXq4&DQEm?DDC$>ggEVq=H6mK^UDQt~K)h$_mQVs))Q)u+ z1Sp3zR1@hZ&=lU95Zed+w1M}6HiPTVNWF9ct%REhtvd7)r1ULDqp#WLYNXqRrCXrN zl12)sT<NoQ5>)yXaBJ)kev}D8E(GeY=1-X0l(2FIw2|N3tX0!NVIG16sl$Buc%BDD z8xJbop|0@5Dr@vNfbzAtgn85uq2^VZ=J&f>`7ME$Td(DZ#WB(`{ID+p<ul@A_0O1p zSHDfM^igv!eq&|9A62g5ezl7<iLk`6=60*3LAI&CkFiM&qPiNSwWvQ>tWQrvOQ<UJ z2ax<<T<rn5VOSb`@;o|3Q`ER}Ge*G&2OfYn3Ad}gKB-UmIyKbyDfHv$6TH{vBJ~O1 zXZjM*CwN<Mo7T2|RIL{&SDy$)wG5vz7Tcf(e8WnIXwzB^HDFvT^@=fITKamW!xS%d zLQ5$Ey2bQ$4VZn6t?S$Cn|#**y4@BRErz8J@mh0(Z=BcrYx!VNpZN8ynx&U%6wnoT zg(zAxgrR8uO09whP0{flu0{8m))T{GefJ)}r3dS_fHvye+eBDx`pUM+>jPeW67X4j zNuT)J_L<iQ_Z!w>^XBf5rIRiRSsidOFV`hLjLg*}I-u`YtzWblq)R$(<l$5h+JtNV zud#_Bvq+o3iQMSqmijJk=~1Cudo2qbLRw|ZnOn7zR?++y)G5MbZz)V~3fF`OM3Zk} z+b^sQ;>Cu&@2$t`?IvH#Ll>uUNUm!<TusL)pi#V_x3gx8##!IXn^pdNg`N?SO$Svf zv-{xo7k^`IlM$?6oeYE_J;QvR_0(ubuwV6o=k!OJQlkWwAL!uqa6lae);F3_{Gycz z`8F^!B!YNDeiloK2+}O$-}7UubQWWC%wXVS=sK{^K7XPi!t(|s4AuZWJlb}ve~Zt@ zn^n0d&^9YueSOV5hUGNJo&?(25*>UCRfqicf%saX%V_~^OYA6who9nDa;v;vU=>fL z(Kb}PVu>hz<qInX)Z7U1iB=Votp*%tsa0C>K<vwSyT~Hu_R_)kK_#PcY#`o+{XoDx z!iCRo?1zw|&qxRo0>Y2?`uI_~))r7KsuICF4h4i-7=nnY?O@5Mz*@z?!pRR}Mth5* zo1Y?;gze1#1^|GTbQ+ijvYaPKcB%<WRo3IJVlk}FN0EhU+9+0aA6Kxlg_6QnMVuL9 z{Ht9|`4AtEF?~v4)d7#|YX!qNRo((6@W-OHHpthTEq<)>lmx~G&V@4A1>6e=E|U-P zG!P(!hLQJSQS=%i4$759A`uWMe}(@CjbY8AsT&oQe-?f0fFQqG-|J7R_wwQJCXCfm zl4#f9+m&98n^!=LXRw1Zq4Vxg1eBEEpTvf&L1NdQi4dg5E>3GlLO=F=Py;ubG{`2x zwVe)X`7t2I!dkRZE(+f%v6QF3Sh+DD7ylP5t@20Ezsh6QMqSPxPMilY2~c-v@f!w^ z#jk6WYdG;<+FxKcK!?;F7Jhz_(KMwAW7fmC%d0SLtHJn{5EOn|dV#~4rs$?AjHvoC z{EDrbGI@wM66Y3IhLd~=b+)TaeOvsDng{NganfMpcrVNs5y1^X>p|;`#jI17s!!wZ zDNE=x!GdKP$E@RqQFqcBN7~+c2Fb+gnetO8+eVbX&;MCGJ#8IEeci&;>bi9%Ei|_6 znno7IMDZ3Mep*cZpcOSae8x9~nf0v2^hd4r=&up=Apa~D4`gcuYwJ}zFzF9!-?W(7 zVr=joz@~QzT0snq`7$D^9MF!N&E_V9=?Uts^HWxzI%hl>z0cZaJz^R4Z^{uc>nH^I z!2qkf4e`SrKLpIfcskm^W~n!Vl^b|!0t;X?f%@qdmZBVH{R@nv_gVNMJJ9ICIFLy4 z`Ex{Zm<uLu)R@+)B_l+EKjlmOkdLVh%XuoIH}dCs62-2i!c<Iyg_QqFVZ>|^S&&D^ zSt6NAXNHDHMzgsi6R=gr?v3@ww!~uhTK(3>jn?}%^~c`l8qf~SNk6#?16pjHEFX>m zZTt4F?XV(F9M2aLi9|AyN~9B+#86^5F_IWfWRr<xGMP%I;j%WA98QiTN0Zr9B9%;~ zQt4DCHIy1ojig3X*>obEOsCT6bS6EN9!`&>N7LC%B9qLdGU-evGn5(5jATYL*`dTx zaws*F9?A?24Gj;C42=$DhZDoe;nZ+?I5RvnJUl!yJUW~mNsJ^%QX}b+%*fEl@W{x> z=ty=nF`67rjiyI4qeG*^qa&lEqwuE70-!89%c7wy3S}X$f4iT{Sol{|A^ElTr{edv z6qP=n7GA_gXMgQ|{AA<v>Bi3cMxX9wdszS3tF=Vz8kRYG-SbzUz41ejv%|qB#tuLJ z{pSy}UybEv-Z-1P;w^UMKJmijh_NztHu}Qsqdi?8e`N6fPb4y3kG{F<{ztFc%O7L8 z9gnf&xyQ11E<ZMQ@`cA<et7wl=RUt<spktTOTYf&3(M?T{u%bYuFpL2a_({V_xw*E zd1d9vmtI|YioKTmeB$+$FXHOYU!8s8mA{HUBc6Tg+dH3K`SAnK-u~K)&$3^N=b~?P zKgZav=ML0OJonzr2N1sZ+&v0o2`$D##yH<!8&f{nxU{jcb1B}}yEL+}d3^iN`_FFw z(BrWy`kz?Z{rLA|duHBPI`Gn4;|E8K<!c`8S-JI*!Q3An&g^*b&0Wh6UbT1SL6+-! zh#lYYQ1;II9~wKk{Lsq}-~YjLpI>>n=L`3L=+|G&eV9GF{1Nu87e4Z(Z}X303?Kd0 z_j8|k_y^0MV1K{ziAP?!|IwFTec_XL{p9{nv2!~<{l9*(<H?)f$UVi_D_=PK!~<W7 zKJ)1>J@xHZzO?e=?l0f|+L14_Uw-<_(KlZFGGjmd@_{<#>Gx)Oo__nC351V54bj0q zpvKrowQ+V^-4d&9iggBJ<DEb4AK%=UIJ<fFiKTw=C$V_+&Da2Ydwlp)?<@_!#k)qh zl^f{|c8xw6TFwqOcWwDuwCliFX8HPPZsmq^cjj(9G`n)+C!ft7X5Y!(l>hGXP3#9< zH?y<Lw><Sq*R4N!b>%%T{X_1y7<=vZbNu~xjO&aY^_^v7ktIG=cUF6?aa`ZqvZT+& zmW=V<CEuwHXRWvT$NffP$^S>mv;No<OM%3n#6stuTna_riA4;ai>!#{$jxT1=8eEg z?RaRp@!I-a)3uE&EoYl^t>@ZuZP#|Lw6Wf<jzn&`WBksQj=7Uv(fek)I)6T!>pb_Z z<<95-$4ck7zuy%bTUm*nd!?)E+^e~+zy62iuDgEH)y-aC?mqYCO3xc_rg{$nU+wxh zyQ5}_eW~dz^T%R*GP%To8?p54vM79a86{p*SY$wDkvmj|u{~mrvrzLm9RAPpcixVP z=OSH7BD1V4J=>)gzMI2B;3@S!_KrF*5X0wCVFG?8@JgI?EG`G|C{Lji_w0f(8HQpq zB$veRj-9)9@7a4aHw|Oq{9^u^*@eC2c3!f*?v|$4lJ(-cytEX06Ht}xhBuaMhK>Qm zlTs?bgZzE+ljzAw%TJQ|W&aFZ1L*-7J6t21)6Q-fRcy24wXizwu&o(;q!srjvWV>@ z;~3Q|nnd>(O3%eal-NU7QZlC=f;E*4a<-|j^mGu0LTLnXx~80c(h}kr9{118EiO3E zyJ0Q9fy}8e3g1MI2Jg<#&pMvpd*K{_4zGtrmke{#jIzhEqEMqS{~aW=vuotr2?H`& zI~S!HoJvs2Zh%UIlQ!XeVYf627f|<VSS`u=R&YR}3S`@Kt)T~D45cn$Df3vRZ=5Nb zwQejr!|a(o3iuBly;^#R?Vo|gOqz(CUSLL=q1FqB;Jz_`<IDuij>l%_r{0^N1TE~! zPfx?C%(gFKX6I+`&QH=A6$Jx<76v#}W;F<OMn<95i3jlQL!f*=f`t(Dd{aKvqw*ny zMg&TuveYisrDqCMkMbx`dCI3c6sJDx5WHpSnfjx?vG<hq*^YBRd>-$sscF<A>@Fl? zMZ;R~eVq|u@5A>wgsVMq1saQhaP#ZXQZM3tdW7AN@0)>z1io)j?e>0#{CZFPFc{n$ z-{Ztd5xQ;O04x%KZ~7u~F!una9#8xz;$5Ejam4!&r#duvY75P>w@{x3JcEGyGsXBI zp4&Weg16ZdPvMzhbnB<_ObDJ45%vJSzl;6`Jo$(49QDL+z%x!si}i2D^CnOH2%ZUk zxBa6P`BR9~c-;J9Ja>5FSv<!Oz8Z+gael-3{*LqgH_o?<&zFD@!k-&|8gt~G!@qRm z?i`*$*$oKp9AeAC@XN_Xcz%ZGPEY(-c=qD+*N8WG^54O;7e683ugQ~-qiZ=Hx4-Wo zPW`#@6M(<h6aTMHoS5xjGrW%ZcA1Rm!;KS@dE-Q5G=4Xq=AOpq#%b<|r--rX1Mp=a znj$83X_jb?@J3Akj2U73@qI6FPxZXzsBP+>+VrMVzXV4+0)5<msQ+hz5lK_j2Yt{i zdub@b{tkS95YLUkIngxDlM4^caSWjgfj%T)sLgH!qNiSjbqMPbNT^UA(alB#qIaTU zALPhBe18Lh#=9AzAA$IBITVp&qcKz8Zu`Wae-3%_9(<ofz$sZVege<C5k9L#*lqZx zy42o2#Hl>tgJ}CD^h<o%hjGyyE<qN&8{af0io5w6ksm`GU&RkSlT1h=kSs_eWDtfB zXzqz0T^tdejv^3G5}aERwjpds_)0h;=Yr;o@V$G4uv8l76?pzPz(2_gf|c5J<5%OE z;JhAiAH+A|nBw$t@pPpfEza{;MSKV1-uc{#xQjRHoABh)*}HUmK6fGQw-xY{5AW1l z5jnqm0LNYg`dnpy75@@F(PzK?b;*APaC$9j5ucFG_&o%7J`doT@J;x4KSU4LAiM*4 zKz)A%lSy<yAGh9NJl}+HGr}3@KAO+_fm5PSH~#sG_+M1SPXU;Xp7InYo|%R$Ah>=6 zK!|@8v`_FaA<!JU@jt*b&G#(`MC-RAJe##?j(C#-@d$lrOcaQR=;P+!ZbyqWb_e1y zr~g0n+jQv0Ka6-Q^8a*GgguGyIfQ>6h_E*hticFtL+C@uA{;<?55jSTyAbY2co^Y7 zB76qna|nNla0cP~2>*!iF9<={=UWhZ5&98^5XKM=Al!^Fi7<!o0Kx|mK92A>!hc3M zgYaF1zeV^*gr6h4jS#{~P8|Y!j}Oj4lZz8@k>vqAlIwhaGJfQ=5`;f=yy$gqBnDEM zp@C$CQjcJN0cX}Y-96M314APNNm<%WpP<C_K$8DJz)}9BM0BS%F5drsHm<%OA2zPh z7rr@yLQhI4$Z#B|VoKyG0gjF5cra+}3h@NyDaklbF~QR~^vvKub%<$vnCW~3hkc{W z<XPqu$po`RGKn8(O{Q4D?1hUPb{z%ItYsebvlzoof0DNTuzbeFV`T@g;mluCjK|a+ z0_SmlA5wPUgq`_=PTmf+M#T&HWNe34(TYy3IC%!;$@(VMmruUplU0237oP(3#0J-z z3BGzJhEf9w{&jxQ$Q3t)7@<+(kr5&pVUiIl8E}b=VIUsZ#M3BcWbua(N`I5`Zwh}) zIHkGq)p5FGZO@yg8>Y{~i`p)A95^6G*ZQEUK&;Qr7T{7gJA-FUUK7THkFKW1afM)k zN?>Cood=D-6~TE`#g)h71#=pgtQX<FSTNw(Mb5)_t5m6QG9thAlX+aQoivX+4v_wd z*%>%$<1X&(yawOK=}FUd?AIoyao53Dyi}p%3e)0r-UMsF^?2d5aUy>jZhw<TVFtHm z@(Y^0>ubseb2AI-QEZxgaQcQ%B)l$96i#%Xh|U`tJbv@j1>JTv?@?*5-s`t7Xv8I? z1{|WN;Q+qQR{+h*w4UJP?1Xf8l>_dX7u9Rbb<PVL4;mq<uyJnFys9lsEnxd5d+GsY z5kzp)e7{|!??eo^gAZ!{v^oXYdyEC#I9`~VE9kh-ek{MxuP13f4CE1bpaN@Z=9Gha zg((`mK4&VDO&H^oq7x^WEw{qkP`XIRCxN%yN>#Bd32=~?0es#GH%6+H(pw;UK?KC- zM4|K-iw^uvi@RZ8X{>0@smpl%t@gy;=A;bF&(1EuWn@({Hq>#N8K$&W<{iaU$UHc! z&@2;F1*Yk+@iRk{2PXt%8rChiH&j3(vze~LeSm7?o;7XzseDF<HHxW?#qONEfg`lD zH{k~WR@33&Lc`36?J$WnRW$B4$BZ>vVWvuDm=EFyycw9HQ;N8KD~4bg(rYO_2)FO@ zjA4D)#yB`1-DA;(zayNPoi4a#RR#)+N5~oaNM6E?gZ}>xT<C2T@OryvR(nNmS&Piy zRlO%1=1-i6%ljKF&{n;Sog3_}o-6T-n-1}#(~E`UEOenEv9J!uK^6+Zi~l4Xlc(cy z9E86S(SvbzR1BoN9E<4ns2&a%lkI_0{ZY}2`q8!O+XdlO<xs(>Xh88WxG(Oxa>Qz3 zYUeDd;sV2h#6PStFzj=g!0L3kjY7*%EnJtJnvq4ouVEA^rLuY*3Ap~|ltbULAlxb< zDCkLM4YH)=CV|9dNmv)XC6UY;UC6V7yjyCi?IIc^Fsw;8C5c0oz>^C;nw4b$2x~4D zN>(pKowcA4j7th$0U_bhVd$rX+9Gkr@xlcuDP_LFU}46B0_9n2l|rno*lSAs99|P# z7R7?NMqUiS)y>=%`x4$@Nq&tb2NDB`THZ-_2fRhd6<j)PJ0(ONT{++4UO*qi@(J~n zS~ry#*GW_x+(eso3r@P$A=4+Orv{5ym9**P>(SNuHcdGNjMicS4?#JZrVSvqPL$=O z`s#~S<ODj&mJK2mMlw2a(*0%Wm?kGZPz7{vd%-H8mo*%!UZB)NxXS?)(zJkk#xuBm zfUEiO2@<ak6p?msnY(c*r!2ch-wE>PwOAd{Em<ruJ?opblndjvfeCoN-+{wVdclSX zR%9lu(`pCPL2xkpx^k(glw9p2%le$M(36axM;pn)mQy%Lg^SurX8D~g5)N1fft*Zw z48WqTcIfNzXkJ*xny|MsQeJwDDx6Q?%Az4Ex`~Qjl=PK#!c?aobpn;5O^zetjDwgt zQbmNwHb}qekTI;LngEeSNY%+AwX2GNYGe_T^HPfFQi{;nNp_0^a7`_(Y-t@5#VVu| zYmgXL$z50^cVms*13lS`74kYPkk?~<yn&X-8)<dCiS`?PASG5?XS9g9^<#>HrCXQC zEp!-o=gzTX`S~7}B~K8LPhX|z0G*tID48$VqN9iQT)3@^RV)?HNNU5nc;SwVuV@A# zbvI)L2ddQiw-^y|V#Amy0>!rKcS~537iw~9h8Br(8(0Eq>&1h#s=kCQ?m#xM&1e#H zn;pn}&b8&)nJC+?pOgUhUkE_5e8qF}T2q10HGJ`~d8<mWXbVCJOI!hj&BeS3yYgZu z4{i<e*83$-Ua6vlg_hq0OKQvrin;h0my^d{U>A!RId3O(^Cv)9mxvf|RS8Vm0VcCk z{b-mro!Ig*qn=~mL1j<-==}$ng#7$zs})RTZ@lAlSu5_@(HUXX&<ga%EUw`7Puzlc zPmQ;*v*i<T<wi+6X7A*zHqIKnMLnBBRTg))Kz5~7V7S{xNB{#r+<T#oqXo~qvSGMe zF|4P{;c&;7pk2)exH$1!-*j4CoV9sTRT3z1+IT7~U?}`V&}xRj@|KgR!#EV<_r!^C z<*wB)_mZKid(cQ}*Xl3dwffHo4UDHJ6;?wsv(}cGKFCbVm6?84N7LTuz(~7yk0j#t zMhmE<Iy_bnXC2<LIfAIL2w{v1h;+ixKw`1c=5pCDYqGYBVl~;_D*45mi||PDE|MYe zrWv(juBCi4-nxo#X&>sGX0#c-Vy?Y>v+iHDS?{nqIBW6_#v{2D79|<l=?tSJLvhAs zjR@9Uj=Ykv<Xz=->@o<(VUYOi_9CWd)jX8M74J2=@k1VE=L!DG^MrM)#^4b$3d7wX zF}J}Maz3_^3YP)yDO}*2{AswJxqOF99qv(4`B@Q8j*Evyioq_1>-{jxnMF<?V8;EN z|F(~i{0+E1Q^~;zW?ZA<a4C6Wb0wRmB%7wIvPGuErm1o^O;vUyS#yZ3&0-n96yEmw zz{6HJkOdd?mvceZAI9dhMEP47QU2C)%1^DL{B3?ZSM^f+cFdzV5icXQR3))pDXV#C zMj2y9tC$<MWy~OCOrI-bQtU+rzj|hsP>iKE-(}Q@6Qec@$nxxKcmpPBtI7otHnn_@ z(Jbb!a7ve0a5Fo@{J0Sl@Qk1UFI0XNT8B=T3o^U)|7HW>;VL-6?l(eW?geJE#UT5p z-I!-w1-JzB1KJ00g39XG5|a(C99%=7HI{K&8K_NVZF+!fE~`@pQA?THEOKc_6+Ita zL!oz;HQ@>-C03SPWYey)R@@E~O2>J1V0Sgm7hS80<c9SwBBkJ8txz_&*s4?von;V~ zxM0(oLVa^t4Hp9mC016$gLftFU&q<1Q;CYL1uj(>VR0wp1<=&Q!aW1*t4z<!h4#kE zh4%E+(fq{eiRrwvE1@;^DR=?VS~9ivB73cBaveG3tRsha8yX+O8dArtplVy9%2sd| z<8+VPzvLOQNBx*IEy;!GRxwwYSAF(jO?+yy5ON+JV?e>5o12Qe5p3T}SJ#=960ELV zw>l@@73)~%Bpm8dfGtZn#4_E2U~p+uGMNi3?%~+utfD@PWpqC_YsG_h!OB`UnCXXa zh*>NR*@PRs5_wW=l`uns$kK9<UTSq<T3z~-FnTJsRYkyn+lnOT;_wHm5?aNCX${P1 zSB?+f$$LO+iol!@y`5nStP4HtubF;}q`K}(s)H1!ce}b<VZ4@!=%asadRtvVQrf6P zY1w`17p0ku2!+-|E)E<XE}AqdR#>GIKTJ5rq8{fb3U|Uc-ZL6OgQ4k%h!1<0S%4Z> zoG@)=HWS&3POX^URt##zWIn$ihEJ-A8Md_jIAm8=HQK%w^n+z`!3+GLzZbV+&yQxU zi5bnxT;llPn9!wRr|=C%^YzX1u&68-x8D~r@5l%{W@034i+!wfKwpmoDJ4ksi?=t~ zMj<3boCMg^@HP)PQ>t))bvKr2eo{AKXFD>Ro1awizL!O*;g96zp-Clo#wk%R<msij z+AO&!;wC<8@$zv+kIYz)t=YV)+vx?zKBn6cakFq7W;gg@3agv5_9_h(A*KCMj^CyG zr64EF<|lapmn>*qU&!GNDTsy05;r>^KMD&T?CD(KrNcG~itTWilTS?);@CUm4bfD| zSAhA0tc{cKQrw4)krxcd;HI62g3DBzEDz$<vDce3S1rH2G=c?xy*O?aC*MbNk4dEM zx!b0c8TS10fWH+NKy85Fzu~2Ftg{=Hx#00ov9T~U240}GweB<k60gzz0>^KPQLX)s z^GxKy^UuxIRdAMAlWW}0r7&<1mN*Y9ZQ`t_l9gn&az4=DmG*DbAzD$&H}=KJN0+}2 zzpSk+=vqbx$TG^yqhdR@=kf(l@yc#0><jT#hs>~iDmyQ~x_d^g?z-$YN|sbDdC`ry z7FWF-6GnN|T*CtD!`p2fgvq0Rm|IGQ6&yWHPg~^;`OBk$u~qfUY%oPM+jbSlwmRUk zQU<qNRtwUG87kr0h5llCFSl@pFJ$11ln=S4JX)JBhhN%*N-sRLEh%xb+IdV!b<*}& zUotN?xYk18snLDLv3pZ?b?Zf2md79@J1Ea+-g^G3MI+Rzy2xp{K;@zxX;o$9w32n8 zt%}L8eN!b!()d(qSc;>12mCpteJJ{yv2^GE2bPYN$m~+3N{OYrE}f;jFUXudzcKsu z{s!!~u7sD%XTSBA!#^7?pDi|CfU?ia7Mm_m`Mhk=ck$G}`4Vhg{g-9nieI|rYT!39 zTMb^4Au4g1R;c6!@m4y6NnHZoO107#9Eaynp^&+Rad_(uUG!<f@MRn&jI6>^#qP7G zw6`6-h#hA3lD3yyR`p(G1=!;(bi-KbGOKWW>SRpb@Zxyk7nZ@c!vSo_aQsy1{iwX9 z%-J?hHR87CAsk7(ez<T|yJv9@7wcJV18XbKlC|JexIiy_<QMc}5vz`iDi)baH6IqW ziVKTY-GfE1;sxSyU@@xrctCu?Rn1;?nCEd{@m2F)v8p<+l>4X@<zt|_>q@W+%$|kH z8h$CoR>M`sgz81X8o99BO3nGbRwTXDVYdk~h~wy>P!A7QB8ea9S{rMt)bO;maZXb% z=(Hp*NHR)$W&4u&oervyMpnDvfV2Af2Anmj5MkD=N^n_U*_^tQk}daMa_1uJuSPD} zz}i%o4OXGLY^Vywl&CygjUqf$o{dzgULv?`O?84Qj;*$OesPd>t9FtVXTr9uaivE6 zDw$AGykS*)6~ec?y~b7TRTOVprI5-`^vauSzQ9RV%c^BT72#aoWb4K3==s|UkFqs3 zq5MKSFd?@~N@=^Kx?3bnqvk$i%A{YCQDx_0R`qoDt?g=NoyW7xUvwl3NWZb*h5f`r z=kp8;Uz9Icq|*DVri!!IYTqqjsIHnrS3Pag8a!6J#?_{|rZU4@vsGFKwFJD&mDb|% zdK=2nIUN}~+UYd5!#%S4n4t`?=)&n$<nI-*w!66Ku2QU0wf0mgQ#zBg&r{XTDuuiP zS8G54ckfa>_{i!-PjsW#Eg=0Ot2;_&P3lKB<CLn{(Pn*(Zh<m?)~sa@7zBlr7V>-# zG<ynq&oZ*Xaxe5yv0#NyX*OJ5Fj85tDA{TPQp!4KGPO-^&rX9=GjbJ?t?Te+cn&e_ z?E2!+<jIHY37c)etEOF?RQ12jHg;7l;g#)86;9>N)f@GQ`<6;S^499Tph{5%v{lw= zU$agTc^yUniKy*n)mge_6Dzt+47g^gLC+a)!an&;dXiG^b&j-65Sb!D43$mNaQWPh zRG??Hav%rG?FLt`K12UH-u}l1Di<XcI?ERL#p77Ms(gh`swgj3@q9HERcu~KRf4mo m4l369-QsQFcZ)as_K9b=kp11_4VTQyZ7Y|tc)R2qiT@9IqG6E$ literal 0 HcmV?d00001 diff --git a/plugins/official/mybatis/plugin.toml b/plugins/official/mybatis/plugin.toml new file mode 100644 index 000000000..fac119161 --- /dev/null +++ b/plugins/official/mybatis/plugin.toml @@ -0,0 +1,17 @@ +id = "openidea.mybatis" +name = "MyBatis Navigator" +version = "0.1.0" +description = "Jumps between MyBatis mapper interfaces and their XML statements (gutter arrows and Go to Definition), completes statement ids and #{parameters}." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: navigation both ways, line markers, id and parameter completion." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["project.read"] + +[[contributes.providers]] +kinds = ["definition", "line-markers", "completion"] +languages = ["java", "xml"] diff --git a/plugins/official/mybatis/src/lib.rs b/plugins/official/mybatis/src/lib.rs new file mode 100644 index 000000000..eca8ba1ba --- /dev/null +++ b/plugins/official/mybatis/src/lib.rs @@ -0,0 +1,376 @@ +//! MyBatis Navigator for OpenIDEA: a mapper interface (`com.acme.UserMapper`) +//! and the XML with `<mapper namespace="com.acme.UserMapper">` point at each +//! other: gutter arrows and Go to Definition on methods and statement ids, +//! completion of statement ids and `#{parameters}`. + +use openidea_plugin::providers::{CompletionItem, CompletionKind, Document, LineMarker, Providers}; +use openidea_plugin::text::{offset, position}; +use openidea_plugin::{Invocation, Location, Plugin, Position, Project, Range, host, register_plugin}; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +/// How long the namespace → XML file index is trusted. +const INDEX_TTL: Duration = Duration::from_secs(30); +const MAX_XML: u32 = 5000; +const STATEMENTS: [&str; 4] = ["select", "insert", "update", "delete"]; + +/// A named thing and where its name is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Named { + pub name: String, + pub range: Range, +} + +/// A mapper XML file. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MapperXml { + pub namespace: Option<String>, + /// `<select|insert|update|delete id="…">` (the id value's range). + pub statements: Vec<Named>, +} + +/// A Java interface. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MapperJava { + /// Fully qualified name. + pub name: Option<String>, + pub methods: Vec<(Named, Vec<String>)>, +} + +fn range_of(text: &str, start: usize, end: usize) -> Range { + Range::new(position(text, start), position(text, end)) +} + +pub fn parse_xml(text: &str) -> MapperXml { + let mut out = MapperXml::default(); + let attr = |tag_start: usize, name: &str| -> Option<(String, usize, usize)> { + let tag_end = text[tag_start..].find('>').map_or(text.len(), |i| tag_start + i); + let tag = &text[tag_start..tag_end]; + let i = tag.find(&format!("{name}=\""))? + name.len() + 2; + let j = tag[i..].find('"')? + i; + Some((tag[i..j].to_owned(), tag_start + i, tag_start + j)) + }; + let mut at = 0; + while let Some(i) = text[at..].find('<') { + let start = at + i + 1; + at = start; + let name: String = text[start..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + if name == "mapper" { + out.namespace = attr(start, "namespace").map(|(v, ..)| v); + } else if STATEMENTS.contains(&name.as_str()) + && let Some((id, s, e)) = attr(start, "id") + { + out.statements.push(Named { name: id, range: range_of(text, s, e) }); + } + } + out +} + +fn ident(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '$' +} + +/// The interface's name and methods (with their parameter names). +pub fn parse_java(text: &str) -> MapperJava { + let package = + text.lines().find_map(|l| l.trim().strip_prefix("package ").map(|p| p.trim_end_matches(';').trim().to_owned())); + let Some(i) = text.find("interface ") else { return MapperJava::default() }; + let simple: String = text[i + 10..].trim_start().chars().take_while(|c| ident(*c)).collect(); + let name = Some(match package { + Some(p) => format!("{p}.{simple}"), + None => simple, + }); + let body_start = text[i..].find('{').map_or(text.len(), |b| i + b + 1); + let mut methods = Vec::new(); + let mut depth = 0; + let mut stmt_start = body_start; + let bytes: Vec<(usize, char)> = text[body_start..].char_indices().map(|(k, c)| (body_start + k, c)).collect(); + for (k, c) in bytes { + match c { + '{' => depth += 1, + '}' if depth == 0 => break, + '}' => { + depth -= 1; + stmt_start = k + 1; + } + ';' if depth == 0 => { + let stmt = &text[stmt_start..k]; + if let Some(paren) = stmt.find('(') { + let before = stmt[..paren].trim_end(); + let name_start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1); + let method = &before[name_start..]; + if !method.is_empty() && !before.contains(" default ") { + let s = stmt_start + name_start; + let params = stmt[paren + 1..stmt.rfind(')').unwrap_or(stmt.len())] + .split(',') + .filter_map(|p| { + // `@Param("id") long id` → the @Param name wins. + if let Some(q) = p.find("@Param(\"") { + return p[q + 8..].split('"').next().map(str::to_owned); + } + p.split_whitespace().last().map(|n| n.trim_matches(|c: char| !ident(c)).to_owned()) + }) + .filter(|n| !n.is_empty()) + .collect(); + methods.push(( + Named { name: method.to_owned(), range: range_of(text, s, s + method.len()) }, + params, + )); + } + } + stmt_start = k + 1; + } + _ => {} + } + } + MapperJava { name, methods } +} + +fn project_of(path: &str) -> Option<Project> { + host::projects() + .ok()? + .into_iter() + .filter(|p| p.root().is_ok_and(|r| path.starts_with(&r))) + .max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0)) +} + +#[derive(Default)] +pub struct Navigator { + /// project root → (namespace → XML path), with when it was built. + xml_index: BTreeMap<String, (Instant, BTreeMap<String, String>)>, +} + +impl Navigator { + /// The XML mapping `namespace`, (re)indexing the project when needed. + fn xml_for(&mut self, project: &Project, namespace: &str) -> Option<(String, MapperXml)> { + let root = project.root().ok()?; + for attempt in 0..2 { + let fresh = self.xml_index.get(&root).is_some_and(|(at, _)| at.elapsed() < INDEX_TTL); + if !fresh || attempt == 1 { + let mut map = BTreeMap::new(); + for path in project.find_files("**/*.xml", MAX_XML).ok()? { + let Ok(text) = project.read_file(&path) else { continue }; + if !text.contains("<mapper") { + continue; + } + if let Some(ns) = parse_xml(&text).namespace { + map.insert(ns, path); + } + } + self.xml_index.insert(root.clone(), (Instant::now(), map)); + } + let path = self.xml_index.get(&root).and_then(|(_, m)| m.get(namespace)).cloned(); + if let Some(path) = path + && let Ok(text) = project.read_file(&path) + { + let xml = parse_xml(&text); + if xml.namespace.as_deref() == Some(namespace) { + return Some((path, xml)); + } + } + if !fresh { + break; // just rebuilt + } + } + None + } + + /// The interface `namespace` names (`com.acme.UserMapper` → `**/com/acme/UserMapper.java`). + fn java_for(project: &Project, namespace: &str) -> Option<(String, MapperJava)> { + let rel = format!("**/{}.java", namespace.replace('.', "/")); + let path = project.find_files(&rel, 1).ok()?.into_iter().next()?; + let java = parse_java(&project.read_file(&path).ok()?); + (java.name.as_deref() == Some(namespace)).then_some((path, java)) + } + + /// Pairs of (name in this document, target location) for `doc`. + fn links(&mut self, doc: &Document) -> Vec<(Named, Location, String)> { + let Some(project) = project_of(&doc.path) else { return Vec::new() }; + if doc.language == "xml" { + let xml = parse_xml(&doc.text); + let Some(ns) = xml.namespace else { return Vec::new() }; + let Some((path, java)) = Self::java_for(&project, &ns) else { return Vec::new() }; + let simple = ns.rsplit('.').next().unwrap_or(&ns).to_owned(); + xml.statements + .into_iter() + .filter_map(|s| { + let (m, _) = java.methods.iter().find(|(m, _)| m.name == s.name)?; + let tip = format!("{simple}.{}()", m.name); + Some((s, Location { path: path.clone(), range: m.range }, tip)) + }) + .collect() + } else { + let java = parse_java(&doc.text); + let Some(name) = java.name else { return Vec::new() }; + let Some((path, xml)) = self.xml_for(&project, &name) else { return Vec::new() }; + let file = path.rsplit(['/', '\\']).next().unwrap_or(&path).to_owned(); + java.methods + .into_iter() + .filter_map(|(m, _)| { + let s = xml.statements.iter().find(|s| s.name == m.name)?; + Some((m, Location { path: path.clone(), range: s.range }, format!("{file}: {}", s.name))) + }) + .collect() + } + } +} + +fn contains(r: Range, p: Position) -> bool { + r.start <= p && p <= r.end +} + +impl Providers for Navigator { + fn line_markers(&mut self, doc: &Document) -> Vec<LineMarker> { + let icon = if doc.language == "xml" { "implementing" } else { "implemented" }; + self.links(doc) + .into_iter() + .map(|(n, target, tip)| LineMarker { + line: n.range.start.line, + icon: Some(icon.into()), + tooltip: format!("Go to {tip}"), + target: Some(target), + }) + .collect() + } + + fn definition(&mut self, doc: &Document, at: Position) -> Vec<Location> { + self.links(doc).into_iter().filter(|(n, ..)| contains(n.range, at)).map(|(_, l, _)| l).collect() + } + + fn completion(&mut self, doc: &Document, at: Position, _trigger: Option<&str>) -> Vec<CompletionItem> { + if doc.language != "xml" { + return Vec::new(); + } + let Some(cursor) = offset(&doc.text, at) else { return Vec::new() }; + let before = &doc.text[..cursor]; + let xml = parse_xml(&doc.text); + let Some(ns) = xml.namespace.clone() else { return Vec::new() }; + let Some(project) = project_of(&doc.path) else { return Vec::new() }; + let Some((_, java)) = Self::java_for(&project, &ns) else { return Vec::new() }; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + let line = &before[line_start..]; + // Inside `id="…` of a statement tag: the interface's unmapped methods. + if let Some(i) = line.rfind("id=\"") + && !line[i + 4..].contains('"') + && STATEMENTS.iter().any(|t| line.contains(&format!("<{t}"))) + { + let typed = &line[i + 4..]; + let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32); + return java + .methods + .iter() + .filter(|(m, _)| m.name.starts_with(typed) && !xml.statements.iter().any(|s| s.name == m.name)) + .map(|(m, _)| CompletionItem { + replace: Some(Range::new(start, at)), + detail: Some(ns.rsplit('.').next().unwrap_or(&ns).into()), + ..CompletionItem::new(&m.name, CompletionKind::Method) + }) + .collect(); + } + // After `#{`: the enclosing statement's parameters. + if let Some(i) = before.rfind("#{") + && !before[i..].contains('}') + { + let typed = &before[i + 2..]; + let statement = + xml.statements.iter().rev().find(|s| offset(&doc.text, s.range.start).is_some_and(|o| o < cursor)); + let Some((_, params)) = statement.and_then(|s| java.methods.iter().find(|(m, _)| m.name == s.name)) else { + return Vec::new(); + }; + let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32); + return params + .iter() + .filter(|p| p.starts_with(typed)) + .map(|p| CompletionItem { + replace: Some(Range::new(start, at)), + ..CompletionItem::new(p, CompletionKind::Variable) + }) + .collect(); + } + Vec::new() + } +} + +impl Plugin for Navigator { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + Err(format!("unknown command {id}")) + } +} + +register_plugin!(Navigator, providers); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + const JAVA: &str = "package com.acme;\n\nimport org.apache.ibatis.annotations.Param;\n\npublic interface UserMapper {\n User findById(@Param(\"id\") long id);\n\n int insert(User user);\n\n default void helper() {}\n\n List<User> search(String name, int limit);\n}\n"; + const XML: &str = "<?xml version=\"1.0\"?>\n<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\" resultType=\"User\">\n select * from users where id = #{id}\n </select>\n <insert id=\"insert\">\n insert into users values (#{user})\n </insert>\n <select id=\"\">\n </select>\n</mapper>\n"; + + #[test] + fn parses_both_sides() { + let java = parse_java(JAVA); + assert_eq!(java.name.as_deref(), Some("com.acme.UserMapper")); + let names: Vec<_> = java.methods.iter().map(|(m, p)| (m.name.as_str(), p.clone())).collect(); + assert_eq!( + names, + [ + ("findById", vec!["id".to_owned()]), + ("insert", vec!["user".into()]), + ("search", vec!["name".into(), "limit".into()]) + ] + ); + assert_eq!(java.methods[0].0.range, Range::new(Position::new(5, 9), Position::new(5, 17))); + let xml = parse_xml(XML); + assert_eq!(xml.namespace.as_deref(), Some("com.acme.UserMapper")); + assert_eq!(xml.statements.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["findById", "insert", ""]); + assert_eq!(xml.statements[0].range, Range::new(Position::new(2, 14), Position::new(2, 22))); + } + + fn setup() -> MockHost { + let host = MockHost::install(); + host.add_file("src/main/java/com/acme/UserMapper.java", JAVA); + host.add_file("src/main/resources/mapper/UserMapper.xml", XML); + host.add_file("pom.xml", "<project/>"); + host + } + + fn doc(path: &str, language: &str, text: &str) -> Document { + Document { path: path.into(), language: language.into(), version: 1, text: text.into() } + } + + #[test] + fn navigates_both_ways() { + let _host = setup(); + let mut n = Navigator::default(); + let java = doc("/project/src/main/java/com/acme/UserMapper.java", "java", JAVA); + let markers = n.line_markers(&java); + assert_eq!(markers.iter().map(|m| m.line).collect::<Vec<_>>(), [5, 7], "search has no statement"); + assert_eq!(markers[0].tooltip, "Go to UserMapper.xml: findById"); + let target = markers[0].target.as_ref().unwrap(); + assert_eq!( + (target.path.as_str(), target.range.start), + ("/project/src/main/resources/mapper/UserMapper.xml", Position::new(2, 14)) + ); + + let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", XML); + let to_java = n.definition(&xml, Position::new(5, 15)); + assert_eq!(to_java.len(), 1); + assert_eq!((to_java[0].path.ends_with("UserMapper.java"), to_java[0].range.start), (true, Position::new(7, 8))); + assert!(n.definition(&xml, Position::new(3, 5)).is_empty(), "not on an id"); + assert_eq!(n.line_markers(&xml)[0].tooltip, "Go to UserMapper.findById()"); + } + + #[test] + fn completes_ids_and_parameters() { + let _host = setup(); + let mut n = Navigator::default(); + let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", XML); + let ids = n.completion(&xml, Position::new(8, 14), None); + assert_eq!(ids.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["search"], "unmapped methods only"); + let text = XML.replace("#{id}", "#{"); + let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", &text); + let params = n.completion(&xml, Position::new(3, 38), None); + assert_eq!(params.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["id"]); + } +} diff --git a/plugins/official/solidity/.gitignore b/plugins/official/solidity/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/solidity/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/solidity/Cargo.lock b/plugins/official/solidity/Cargo.lock new file mode 100644 index 000000000..d2be61fe3 --- /dev/null +++ b/plugins/official/solidity/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-solidity" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/solidity/Cargo.toml b/plugins/official/solidity/Cargo.toml new file mode 100644 index 000000000..697f1b60f --- /dev/null +++ b/plugins/official/solidity/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-solidity" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..e6d0c908d7707ecc300369c16f2fd67e43be7ed4 GIT binary patch literal 105969 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 literal 0 HcmV?d00001 diff --git a/plugins/official/solidity/languages/solidity.toml b/plugins/official/solidity/languages/solidity.toml new file mode 100644 index 000000000..27c519ddb --- /dev/null +++ b/plugins/official/solidity/languages/solidity.toml @@ -0,0 +1,21 @@ +id = "solidity" +name = "Solidity" +extensions = ["sol"] +line-comment = "//" +block-comment = ["/*", "*/"] +indent = { tab-size = 4, insert-spaces = true } + +[[language-servers]] +name = "solidity-language-server" +command = "nomicfoundation-solidity-language-server" +args = ["--stdio"] +root-markers = ["hardhat.config.ts", "hardhat.config.js", "foundry.toml", "truffle-config.js", "remappings.txt", "package.json"] +install-hint = "npm install -g @nomicfoundation/solidity-language-server" + +[[language-servers]] +name = "solc" +command = "solc" +args = ["--lsp"] +root-markers = ["foundry.toml", "remappings.txt"] +fallback = true +install-hint = "Install solc (https://docs.soliditylang.org/en/latest/installing-solidity.html)" diff --git a/plugins/official/solidity/plugin.toml b/plugins/official/solidity/plugin.toml new file mode 100644 index 000000000..cc03892bf --- /dev/null +++ b/plugins/official/solidity/plugin.toml @@ -0,0 +1,41 @@ +id = "openidea.solidity" +name = "Solidity" +version = "0.1.0" +description = "Solidity support: file detection, highlighting, structure view and folding, plus the Solidity language server when it is installed." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: .sol detection, highlighting, structure view, folding; language server via @nomicfoundation/solidity-language-server or solc." +engines.openidea = "^1.0" +# Highlighting, structure and folding come from this component; it is loaded +# when the first Solidity file is opened. +wasm = "dist/plugin.wasm" +activation = ["on-language:solidity"] + +# The language servers in the definition need `process:` capabilities; they +# are asked for on install and can be refused (the rest keeps working). +[[contributes.languages]] +path = "languages/solidity.toml" + +# Offered in Foundry / Hardhat projects once the program is allowed. +[[contributes.runDetectors]] +id = "forge-test" +name = "forge test" +files = ["foundry.toml"] +command = "forge" +args = ["test"] +test = true + +[[contributes.runDetectors]] +id = "forge-build" +name = "forge build" +files = ["foundry.toml"] +command = "forge" +args = ["build"] + +[[contributes.runDetectors]] +id = "hardhat-test" +name = "hardhat test" +files = ["hardhat.config.ts", "hardhat.config.js"] +command = "npx" +args = ["hardhat", "test"] +test = true diff --git a/plugins/official/solidity/src/lib.rs b/plugins/official/solidity/src/lib.rs new file mode 100644 index 000000000..5b7abcd47 --- /dev/null +++ b/plugins/official/solidity/src/lib.rs @@ -0,0 +1,689 @@ +//! Solidity for OpenIDEA: highlighting, structure view and folding from a +//! small hand-written lexer (the IDE has no Solidity grammar), exported as the +//! plugin's `syntax-provider`. Detection and the language server come from +//! `languages/solidity.toml`. + +use openidea_plugin::syntax::{Fold, FoldKind, Highlight, Symbol, SymbolKind, SyntaxProvider, Token, bracket_folds}; +use openidea_plugin::{Invocation, Plugin, register_plugin}; +use std::ops::Range; + +#[derive(Default)] +pub struct Solidity; + +impl Plugin for Solidity { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + Err(format!("unknown command {id}")) + } +} + +impl SyntaxProvider for Solidity { + fn highlights(&mut self, _language: &str, text: &str) -> Vec<Token> { + highlights(text) + } + + fn outline(&mut self, _language: &str, text: &str) -> Vec<Symbol> { + outline(text) + } + + fn folding(&mut self, _language: &str, text: &str) -> Vec<Fold> { + folding(text) + } +} + +register_plugin!(Solidity, syntax); + +// --- lexer ------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Ident, + Number, + Str, + Comment, + DocComment, + Op, + Punct, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Lexeme { + kind: Kind, + start: usize, + end: usize, +} + +impl Lexeme { + fn text<'a>(&self, src: &'a str) -> &'a str { + &src[self.start..self.end] + } + + fn is_trivia(&self) -> bool { + matches!(self.kind, Kind::Comment | Kind::DocComment) + } +} + +fn is_ident_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' || b == b'$' +} + +fn is_ident(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' +} + +fn lex(text: &str) -> Vec<Lexeme> { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + let start = i; + let kind = if b.is_ascii_whitespace() { + i += 1; + continue; + } else if bytes[i..].starts_with(b"//") { + let end = text[i..].find('\n').map_or(bytes.len(), |n| i + n); + let doc = bytes[i..].starts_with(b"///") && !bytes[i..].starts_with(b"////"); + i = end; + if doc { Kind::DocComment } else { Kind::Comment } + } else if bytes[i..].starts_with(b"/*") { + let end = text[i + 2..].find("*/").map_or(bytes.len(), |n| i + 2 + n + 2); + let doc = bytes[i..].starts_with(b"/**") && !bytes[i..].starts_with(b"/**/"); + i = end; + if doc { Kind::DocComment } else { Kind::Comment } + } else if b == b'"' || b == b'\'' { + i = string_end(bytes, i); + Kind::Str + } else if b.is_ascii_digit() { + i = number_end(bytes, i); + Kind::Number + } else if is_ident_start(b) { + while i < bytes.len() && is_ident(bytes[i]) { + i += 1; + } + // hex"…", unicode"…" + let word = &text[start..i]; + if (word == "hex" || word == "unicode") && matches!(bytes.get(i), Some(b'"' | b'\'')) { + i = string_end(bytes, i); + Kind::Str + } else { + Kind::Ident + } + } else if b"(){}[];,.".contains(&b) { + i += 1; + Kind::Punct + } else if b"+-*/%=!<>&|^~?:".contains(&b) { + while i < bytes.len() && b"+-*/%=!<>&|^~?:".contains(&bytes[i]) && !bytes[i..].starts_with(b"//") { + i += 1; + } + Kind::Op + } else { + // Anything else (non-ASCII outside strings): skip the whole character. + i += text[i..].chars().next().map_or(1, char::len_utf8); + continue; + }; + out.push(Lexeme { kind, start, end: i }); + } + out +} + +fn string_end(bytes: &[u8], open: usize) -> usize { + let quote = bytes[open]; + let mut i = open + 1; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'\n' => return i, + c if c == quote => return i + 1, + _ => i += 1, + } + } + bytes.len() +} + +fn number_end(bytes: &[u8], mut i: usize) -> usize { + if bytes[i..].starts_with(b"0x") || bytes[i..].starts_with(b"0X") { + i += 2; + while i < bytes.len() && (bytes[i].is_ascii_hexdigit() || bytes[i] == b'_') { + i += 1; + } + return i; + } + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'_') { + i += 1; + } + if i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1].is_ascii_digit() { + i += 1; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'_') { + i += 1; + } + } + if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') { + let mut j = i + 1; + if j < bytes.len() && (bytes[j] == b'-' || bytes[j] == b'+') { + j += 1; + } + if j < bytes.len() && bytes[j].is_ascii_digit() { + i = j; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + } + i +} + +// --- highlighting -------------------------------------------------------------- + +const KEYWORDS: &[&str] = &[ + "abstract", + "anonymous", + "as", + "assembly", + "break", + "calldata", + "catch", + "constant", + "constructor", + "continue", + "contract", + "default", + "delete", + "do", + "else", + "emit", + "enum", + "error", + "event", + "external", + "fallback", + "for", + "from", + "function", + "global", + "if", + "immutable", + "import", + "indexed", + "interface", + "internal", + "is", + "let", + "library", + "memory", + "modifier", + "new", + "override", + "payable", + "pragma", + "private", + "public", + "pure", + "receive", + "return", + "returns", + "revert", + "solidity", + "storage", + "struct", + "switch", + "case", + "transient", + "try", + "type", + "unchecked", + "using", + "view", + "virtual", + "while", + "leave", + "wei", + "gwei", + "ether", + "seconds", + "minutes", + "hours", + "days", + "weeks", + "years", +]; + +const BUILTINS: &[&str] = &[ + "msg", + "block", + "tx", + "abi", + "now", + "gasleft", + "blockhash", + "blobhash", + "keccak256", + "sha256", + "ripemd160", + "ecrecover", + "addmod", + "mulmod", + "selfdestruct", + "require", + "assert", + "this", + "super", +]; + +const ATOMS: &[&str] = &["true", "false"]; + +fn is_elementary_type(word: &str) -> bool { + if matches!( + word, + "address" | "bool" | "string" | "byte" | "bytes" | "int" | "uint" | "fixed" | "ufixed" | "mapping" | "var" + ) { + return true; + } + let sized = |prefix: &str| { + word.strip_prefix(prefix).is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit() || b == b'x')) + }; + sized("uint") || sized("int") || sized("bytes") || sized("ufixed") || sized("fixed") +} + +/// The next lexeme that is not a comment. +fn next_code(lexemes: &[Lexeme], i: usize) -> Option<&Lexeme> { + lexemes[i + 1..].iter().find(|l| !l.is_trivia()) +} + +fn prev_code(lexemes: &[Lexeme], i: usize) -> Option<&Lexeme> { + lexemes[..i].iter().rev().find(|l| !l.is_trivia()) +} + +fn highlights(text: &str) -> Vec<Token> { + let lexemes = lex(text); + let mut out = Vec::with_capacity(lexemes.len()); + for (i, l) in lexemes.iter().enumerate() { + let kind = match l.kind { + Kind::Comment => Highlight::Comment, + Kind::DocComment => Highlight::DocComment, + Kind::Str => Highlight::String, + Kind::Number => Highlight::Number, + Kind::Op => Highlight::Operator, + Kind::Punct => Highlight::Punctuation, + Kind::Ident => { + let word = l.text(text); + let prev = prev_code(&lexemes, i).map(|p| p.text(text)); + let next = next_code(&lexemes, i).map(|n| n.text(text)); + if prev == Some(".") { + if next == Some("(") { Highlight::FunctionCall } else { Highlight::Property } + } else if ATOMS.contains(&word) { + Highlight::Atom + } else if KEYWORDS.contains(&word) { + Highlight::Keyword + } else if is_elementary_type(word) { + Highlight::Type + } else if matches!(prev, Some("function" | "modifier" | "event" | "error")) { + Highlight::Function + } else if matches!(prev, Some("contract" | "interface" | "library" | "struct" | "enum" | "is" | "new")) + { + Highlight::Type + } else if BUILTINS.contains(&word) { + Highlight::Builtin + } else if next == Some("(") { + Highlight::FunctionCall + } else if word.starts_with(|c: char| c.is_ascii_uppercase()) { + if word.len() > 1 && word.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + { + Highlight::Constant + } else { + Highlight::Type + } + } else { + continue; + } + } + }; + out.push(Token { range: l.start..l.end, kind }); + } + out +} + +// --- structure ------------------------------------------------------------------ + +/// An open `{`: the symbol whose body it is, and what may be declared inside. +struct Frame { + symbol: Option<usize>, + body: Body, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Body { + File, + Contract, + Struct, + Enum, + Code, +} + +const MEMBER_MODIFIERS: &[&str] = + &["public", "private", "internal", "external", "constant", "immutable", "override", "transient", "payable"]; + +fn outline(text: &str) -> Vec<Symbol> { + let code: Vec<Lexeme> = lex(text).into_iter().filter(|l| !l.is_trivia()).collect(); + let word = |i: usize| code.get(i).map_or("", |l| l.text(text)); + let mut symbols: Vec<Symbol> = Vec::new(); + let mut frames = vec![Frame { symbol: None, body: Body::File }]; + // A declaration waiting for its body (`{`) or its end (`;`). + let mut pending: Option<(usize, Body)> = None; + let mut stmt_start = 0usize; + let mut i = 0; + while i < code.len() { + let l = code[i]; + let w = l.text(text); + let body = frames.last().map_or(Body::File, |f| f.body); + let parent = frames.iter().rev().find_map(|f| f.symbol); + let add = |symbols: &mut Vec<Symbol>, name_at: usize, kind: SymbolKind, detail: Option<&str>| { + let name = code[name_at]; + symbols.push(Symbol { + name: name.text(text).to_owned(), + detail: detail.map(str::to_owned), + kind, + range: l.start..name.end, + name_range: name.start..name.end, + parent, + }); + symbols.len() - 1 + }; + let declares = matches!(body, Body::File | Body::Contract) && pending.is_none(); + match (l.kind, w) { + (Kind::Punct, "{") => { + let (symbol, inner) = match pending.take() { + Some((s, b)) => (Some(s), b), + None => (None, if body == Body::Code || body == Body::Contract { Body::Code } else { body }), + }; + frames.push(Frame { symbol, body: inner }); + stmt_start = i + 1; + } + (Kind::Punct, "}") => { + if frames.len() > 1 + && let Some(frame) = frames.pop() + && let Some(s) = frame.symbol + { + symbols[s].range.end = l.end; + } + stmt_start = i + 1; + } + (Kind::Punct, ";") => { + if let Some((s, _)) = pending.take() { + symbols[s].range.end = l.end; + } else if matches!(body, Body::Contract | Body::Struct | Body::File) && stmt_start < i { + // `Type [modifiers] name [= value];`: a state variable, struct member or file constant. + let stmt = &code[stmt_start..i]; + let head = stmt[0].text(text); + let skip = matches!(head, "using" | "import" | "pragma" | "return" | "emit" | "type"); + let until = stmt.iter().position(|s| s.text(text) == "=").unwrap_or(stmt.len()); + let name = stmt[..until].iter().rposition(|s| { + s.kind == Kind::Ident + && !MEMBER_MODIFIERS.contains(&s.text(text)) + && !is_elementary_type(s.text(text)) + }); + if let (false, Some(n)) = (skip, name) + && n > 0 + { + let constant = stmt[..until].iter().any(|s| matches!(s.text(text), "constant" | "immutable")); + let kind = if constant { SymbolKind::Constant } else { SymbolKind::Field }; + if body != Body::File || constant { + let at = stmt_start + n; + let name = code[at]; + symbols.push(Symbol { + name: name.text(text).to_owned(), + detail: None, + kind, + range: code[stmt_start].start..l.end, + name_range: name.start..name.end, + parent, + }); + } + } + } + stmt_start = i + 1; + } + (Kind::Ident, "contract" | "interface" | "library") if declares && word(i + 1) != "" => { + if code.get(i + 1).is_some_and(|n| n.kind == Kind::Ident) { + let kind = match w { + "interface" => SymbolKind::Interface, + "library" => SymbolKind::Module, + _ => SymbolKind::Class, + }; + let abstract_ = i > 0 && word(i - 1) == "abstract"; + let s = add(&mut symbols, i + 1, kind, abstract_.then_some("abstract")); + if abstract_ { + symbols[s].range.start = code[i - 1].start; + } + pending = Some((s, Body::Contract)); + i += 1; + } + } + (Kind::Ident, "function" | "modifier" | "event" | "error") if declares => { + if code.get(i + 1).is_some_and(|n| n.kind == Kind::Ident) { + let (kind, detail) = match w { + "function" if body == Body::Contract => (SymbolKind::Method, None), + "function" => (SymbolKind::Function, None), + "modifier" => (SymbolKind::Method, Some("modifier")), + "event" => (SymbolKind::Event, None), + _ => (SymbolKind::Event, Some("error")), + }; + let s = add(&mut symbols, i + 1, kind, detail); + pending = Some((s, Body::Code)); + i += 1; + } + } + (Kind::Ident, "constructor" | "fallback" | "receive") if declares && word(i + 1) == "(" => { + let kind = if w == "constructor" { SymbolKind::Constructor } else { SymbolKind::Method }; + let s = add(&mut symbols, i, kind, None); + pending = Some((s, Body::Code)); + } + (Kind::Ident, "struct" | "enum") if declares => { + if code.get(i + 1).is_some_and(|n| n.kind == Kind::Ident) { + let (kind, inner) = + if w == "struct" { (SymbolKind::Struct, Body::Struct) } else { (SymbolKind::Enum, Body::Enum) }; + let s = add(&mut symbols, i + 1, kind, None); + pending = Some((s, inner)); + i += 1; + } + } + (Kind::Ident, "type") if declares && word(i + 2) == "is" => { + let s = add(&mut symbols, i + 1, SymbolKind::TypeAlias, None); + pending = Some((s, Body::Code)); + i += 1; + } + (Kind::Ident, _) if body == Body::Enum && matches!(word(i + 1), "," | "}") => { + symbols.push(Symbol { + name: w.to_owned(), + detail: None, + kind: SymbolKind::EnumMember, + range: l.start..l.end, + name_range: l.start..l.end, + parent, + }); + } + _ => {} + } + i += 1; + } + symbols +} + +// --- folding ----------------------------------------------------------------------- + +fn folding(text: &str) -> Vec<Fold> { + let lexemes = lex(text); + let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32; + let skip: Vec<Range<usize>> = lexemes + .iter() + .filter(|l| matches!(l.kind, Kind::Str | Kind::Comment | Kind::DocComment)) + .map(|l| l.start..l.end) + .collect(); + let mut folds = bracket_folds(text, b'{', b'}', &skip); + for l in lexemes.iter().filter(|l| l.is_trivia()) { + let (start, end) = (line_of(l.start), line_of(l.end)); + if end > start { + folds.push(Fold { start_line: start, end_line: end, kind: FoldKind::Comment }); + } + } + // Consecutive `import` lines. + let mut run: Option<(u32, u32)> = None; + let flush = |run: &mut Option<(u32, u32)>, folds: &mut Vec<Fold>| { + if let Some((s, e)) = run.take() + && e > s + { + folds.push(Fold { start_line: s, end_line: e, kind: FoldKind::Imports }); + } + }; + for (n, line) in text.lines().enumerate() { + let n = n as u32; + let t = line.trim_start(); + if t.starts_with("import ") || t.starts_with("import{") { + run = Some(run.map_or((n, n), |(s, _)| (s, n))); + } else if !t.is_empty() { + flush(&mut run, &mut folds); + } + } + flush(&mut run, &mut folds); + folds +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./IERC20.sol"; +import {Ownable} from "./Ownable.sol"; + +/// @title A token +abstract contract Token is IERC20, Ownable { + uint256 public constant MAX_SUPPLY = 1_000_000e18; + mapping(address => uint256) private balances; + string name = "tök"; + + enum State { Idle, Running } + struct Holder { + address who; + uint96 amount; + } + + event Transfer(address indexed from, address indexed to, uint256 value); + error Insufficient(uint256 needed); + + modifier onlyHolder() { + require(balances[msg.sender] > 0, "no"); + _; + } + + constructor(string memory n) { + name = n; + } + + function transfer(address to, uint256 value) external returns (bool) { + /* multi + line */ + if (balances[msg.sender] < value) revert Insufficient(value); + emit Transfer(msg.sender, to, value); + return true; + } + + function total() public view virtual returns (uint256); +} + +interface IThing { + function thing() external; +} + +function free(uint x) pure returns (uint) { return x; } +"#; + + fn names(symbols: &[Symbol], parent: Option<usize>) -> Vec<(&str, SymbolKind)> { + symbols.iter().filter(|s| s.parent == parent).map(|s| (s.name.as_str(), s.kind)).collect() + } + + #[test] + fn outline_lists_declarations_with_their_members() { + let symbols = outline(SAMPLE); + let top = names(&symbols, None); + assert_eq!( + top, + [("Token", SymbolKind::Class), ("IThing", SymbolKind::Interface), ("free", SymbolKind::Function)] + ); + let token = symbols.iter().position(|s| s.name == "Token").unwrap(); + assert_eq!(symbols[token].detail.as_deref(), Some("abstract")); + assert!(SAMPLE[symbols[token].range.clone()].starts_with("abstract contract Token")); + assert!(SAMPLE[symbols[token].range.clone()].ends_with('}')); + assert_eq!( + names(&symbols, Some(token)), + [ + ("MAX_SUPPLY", SymbolKind::Constant), + ("balances", SymbolKind::Field), + ("name", SymbolKind::Field), + ("State", SymbolKind::Enum), + ("Holder", SymbolKind::Struct), + ("Transfer", SymbolKind::Event), + ("Insufficient", SymbolKind::Event), + ("onlyHolder", SymbolKind::Method), + ("constructor", SymbolKind::Constructor), + ("transfer", SymbolKind::Method), + ("total", SymbolKind::Method), + ] + ); + let state = symbols.iter().position(|s| s.name == "State").unwrap(); + assert_eq!( + names(&symbols, Some(state)), + [("Idle", SymbolKind::EnumMember), ("Running", SymbolKind::EnumMember)] + ); + let holder = symbols.iter().position(|s| s.name == "Holder").unwrap(); + assert_eq!(names(&symbols, Some(holder)), [("who", SymbolKind::Field), ("amount", SymbolKind::Field)]); + let total = symbols.iter().find(|s| s.name == "total").unwrap(); + assert!(SAMPLE[total.range.clone()].ends_with("returns (uint256);")); + // Parents come first. + assert!(symbols.iter().enumerate().all(|(i, s)| s.parent.is_none_or(|p| p < i))); + } + + #[test] + fn highlights_classify_tokens() { + let tokens = highlights(SAMPLE); + let kind_of = |needle: &str| { + let at = SAMPLE.find(needle).unwrap(); + tokens.iter().find(|t| t.range.start == at).map(|t| t.kind) + }; + assert_eq!(kind_of("// SPDX"), Some(Highlight::Comment)); + assert_eq!(kind_of("/// @title"), Some(Highlight::DocComment)); + assert_eq!(kind_of("pragma"), Some(Highlight::Keyword)); + assert_eq!(kind_of("uint256 public"), Some(Highlight::Type)); + assert_eq!(kind_of("MAX_SUPPLY"), Some(Highlight::Constant)); + assert_eq!(kind_of("1_000_000e18"), Some(Highlight::Number)); + assert_eq!(kind_of("\"tök\""), Some(Highlight::String)); + assert_eq!(kind_of("Transfer(address"), Some(Highlight::Function)); + assert_eq!(kind_of("require("), Some(Highlight::Builtin)); + assert_eq!(kind_of("sender]"), Some(Highlight::Property)); + assert_eq!(kind_of("true;"), Some(Highlight::Atom)); + assert_eq!(kind_of("IERC20, Ownable"), Some(Highlight::Type)); + // Sorted and non-overlapping, on char boundaries. + assert!(tokens.windows(2).all(|w| w[0].range.end <= w[1].range.start)); + assert!(tokens.iter().all(|t| SAMPLE.is_char_boundary(t.range.start) && SAMPLE.is_char_boundary(t.range.end))); + } + + #[test] + fn folds_bodies_comments_and_imports() { + let folds = folding(SAMPLE); + let line = |needle: &str| SAMPLE[..SAMPLE.find(needle).unwrap()].matches('\n').count() as u32; + assert!(folds.contains(&Fold { start_line: 3, end_line: 4, kind: FoldKind::Imports })); + assert!(folds.iter().any(|f| f.kind == FoldKind::Comment && f.start_line == line("/* multi"))); + let contract = line("abstract contract"); + assert!(folds.iter().any(|f| f.kind == FoldKind::Region && f.start_line == contract)); + } + + #[test] + fn lexer_survives_unterminated_input() { + for text in ["\"abc", "/* open", "0x", "a.", "uint256 x = 1e", "é∑ unknown"] { + let _ = highlights(text); + let _ = outline(text); + let _ = folding(text); + } + } +} diff --git a/plugins/official/string-tools/.gitignore b/plugins/official/string-tools/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/plugins/official/string-tools/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/official/string-tools/Cargo.lock b/plugins/official/string-tools/Cargo.lock new file mode 100644 index 000000000..8f0807fe9 --- /dev/null +++ b/plugins/official/string-tools/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-string-tools" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/string-tools/Cargo.toml b/plugins/official/string-tools/Cargo.toml new file mode 100644 index 000000000..559b812e9 --- /dev/null +++ b/plugins/official/string-tools/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-string-tools" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..1280d055ba32b6a313ab5383974f2237f5a4cf3c GIT binary patch literal 165373 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�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 literal 0 HcmV?d00001 diff --git a/plugins/official/string-tools/plugin.toml b/plugins/official/string-tools/plugin.toml new file mode 100644 index 000000000..18ff2bb52 --- /dev/null +++ b/plugins/official/string-tools/plugin.toml @@ -0,0 +1,171 @@ +id = "openidea.string-tools" +name = "String Tools" +version = "0.1.0" +description = "Case conversion (camelCase, snake_case, kebab-case…), encoding (Base64, URL, HTML, JSON), line sorting and more, on every selection at once." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: 26 commands in four groups." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +# The commands are declared below, so Find Action and the keymap list them +# before the code is ever loaded; it loads on the first one run. +activation = ["on-command:*"] + +[capabilities] +required = ["editor.read", "editor.write"] + +[[contributes.commands]] +id = "camel-case" +title = "String Tools: To camelCase" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "pascal-case" +title = "String Tools: To PascalCase" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "snake-case" +title = "String Tools: To snake_case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "screaming-snake-case" +title = "String Tools: To SCREAMING_SNAKE_CASE" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "kebab-case" +title = "String Tools: To kebab-case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "dot-case" +title = "String Tools: To dot.case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "title-case" +title = "String Tools: To Title Case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "upper-case" +title = "String Tools: To UPPER CASE" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "lower-case" +title = "String Tools: To lower case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "toggle-case" +title = "String Tools: Toggle Case" +category = "String Tools › Case" +when = "editorFocus" + +[[contributes.commands]] +id = "base64-encode" +title = "String Tools: Encode Base64" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "base64-decode" +title = "String Tools: Decode Base64" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "url-encode" +title = "String Tools: URL-Encode" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "url-decode" +title = "String Tools: URL-Decode" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "html-escape" +title = "String Tools: Escape HTML" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "html-unescape" +title = "String Tools: Unescape HTML" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "json-escape" +title = "String Tools: Escape as JSON String" +category = "String Tools › Encode" +when = "editorFocus" + +[[contributes.commands]] +id = "sort-lines" +title = "String Tools: Sort Lines" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "sort-lines-desc" +title = "String Tools: Sort Lines Descending" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "reverse-lines" +title = "String Tools: Reverse Lines" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "unique-lines" +title = "String Tools: Remove Duplicate Lines" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "trim-lines" +title = "String Tools: Trim Lines" +category = "String Tools › Lines" +when = "editorFocus" + +[[contributes.commands]] +id = "reverse" +title = "String Tools: Reverse Text" +category = "String Tools › Other" +when = "editorFocus" + +[[contributes.commands]] +id = "swap-quotes" +title = "String Tools: Swap Quotes (' ↔ \")" +category = "String Tools › Other" +when = "editorFocus" + +[[contributes.commands]] +id = "increment" +title = "String Tools: Increment Numbers" +category = "String Tools › Other" +when = "editorFocus" + +[[contributes.commands]] +id = "decrement" +title = "String Tools: Decrement Numbers" +category = "String Tools › Other" +when = "editorFocus" diff --git a/plugins/official/string-tools/src/lib.rs b/plugins/official/string-tools/src/lib.rs new file mode 100644 index 000000000..c69ec6fc5 --- /dev/null +++ b/plugins/official/string-tools/src/lib.rs @@ -0,0 +1,460 @@ +//! String Tools for OpenIDEA: text transformations on every selection of the +//! focused editor, applied as one undoable change. An empty selection +//! (a cursor) works on the word under it; line commands on whole lines. + +use openidea_plugin::text::{offset, position}; +use openidea_plugin::{Error, Invocation, Plugin, Range, TextEdit, TextEditor, register_plugin}; + +#[derive(Default)] +pub struct StringTools; + +impl Plugin for StringTools { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { + let transform = transform(id).ok_or_else(|| format!("unknown command {id}"))?; + let editor = ctx.editor().ok_or("String Tools works on the focused editor")?; + apply(&editor, transform, lines_command(id)).map_err(|e| e.to_string())?; + Ok(None) + } +} + +register_plugin!(StringTools); + +type Transform = fn(&str) -> String; + +fn lines_command(id: &str) -> bool { + matches!(id, "sort-lines" | "sort-lines-desc" | "reverse-lines" | "unique-lines" | "trim-lines") +} + +fn transform(id: &str) -> Option<Transform> { + Some(match id { + "camel-case" => |s| join_words(s, Style::Camel), + "pascal-case" => |s| join_words(s, Style::Pascal), + "snake-case" => |s| join_words(s, Style::Snake), + "screaming-snake-case" => |s| join_words(s, Style::ScreamingSnake), + "kebab-case" => |s| join_words(s, Style::Kebab), + "dot-case" => |s| join_words(s, Style::Dot), + "title-case" => title_case, + "upper-case" => |s| s.to_uppercase(), + "lower-case" => |s| s.to_lowercase(), + "toggle-case" => toggle_case, + "base64-encode" => |s| base64_encode(s.as_bytes()), + "base64-decode" => { + |s| base64_decode(s).map_or_else(|| s.to_owned(), |b| String::from_utf8_lossy(&b).into_owned()) + } + "url-encode" => url_encode, + "url-decode" => url_decode, + "html-escape" => html_escape, + "html-unescape" => html_unescape, + "json-escape" => json_escape, + "sort-lines" => |s| map_lines(s, |l| l.sort()), + "sort-lines-desc" => |s| map_lines(s, |l| l.sort_by(|a, b| b.cmp(a))), + "reverse-lines" => |s| map_lines(s, |l| l.reverse()), + "unique-lines" => |s| { + map_lines(s, |l| { + let mut seen = std::collections::HashSet::new(); + l.retain(|x| seen.insert(x.to_owned())); + }) + }, + "trim-lines" => |s| s.split('\n').map(str::trim).collect::<Vec<_>>().join("\n"), + "reverse" => |s| s.chars().rev().collect(), + "swap-quotes" => |s| { + s.chars() + .map(|c| match c { + '"' => '\'', + '\'' => '"', + c => c, + }) + .collect() + }, + "increment" => |s| step_numbers(s, 1), + "decrement" => |s| step_numbers(s, -1), + _ => return None, + }) +} + +/// Replaces each selection with `f(text)`: an empty one expands to the word +/// under the cursor, or (`lines`) every selection to its whole lines. +/// Retried once if the user typed meanwhile. +fn apply(editor: &TextEditor, f: Transform, lines: bool) -> Result<u64, Error> { + let selections = editor.selections()?; + for _ in 0..2 { + let doc = editor.document()?; + let mut edits = Vec::with_capacity(selections.len()); + for r in &selections { + let range = if lines { line_range(&doc.text, *r) } else { word_range(&doc.text, *r) }; + let Some(old) = doc.text_in(range) else { continue }; + let new = f(old); + if new != old { + edits.push(TextEdit { range, text: new }); + } + } + // Overlapping selections (two cursors in one word) edit once. + edits.sort_by_key(|e| e.range); + edits.dedup_by(|b, a| b.range.start < a.range.end); + match editor.apply_edits(doc.version, &edits) { + Err(Error::Stale) => continue, + other => return other, + } + } + Err(Error::Stale) +} + +fn word_range(text: &str, r: Range) -> Range { + if !r.is_empty() { + return r; + } + let Some(at) = offset(text, r.start) else { return r }; + let word = |c: char| c.is_alphanumeric() || c == '_' || c == '-' || c == '.'; + let start = text[..at].char_indices().rev().take_while(|(_, c)| word(*c)).last().map_or(at, |(i, _)| i); + let end = text[at..].char_indices().find(|(_, c)| !word(*c)).map_or(text.len(), |(i, _)| at + i); + Range::new(position(text, start), position(text, end)) +} + +fn line_range(text: &str, r: Range) -> Range { + let start = openidea_plugin::Position::new(r.start.line, 0); + let end_line = if r.end.character == 0 && r.end.line > r.start.line { r.end.line - 1 } else { r.end.line }; + let line_end = text.split('\n').nth(end_line as usize).map_or(0, |l| l.encode_utf16().count() as u32); + Range::new(start, openidea_plugin::Position::new(end_line, line_end)) +} + +#[derive(Clone, Copy)] +enum Style { + Camel, + Pascal, + Snake, + ScreamingSnake, + Kebab, + Dot, +} + +/// Splits identifiers on separators and case changes: `fooBar_baz-qux` → +/// [foo, Bar, baz, qux]; `HTTPServer` → [HTTP, Server]. +fn words(s: &str) -> Vec<String> { + let chars: Vec<char> = s.chars().collect(); + let mut out = Vec::new(); + let mut cur = String::new(); + for (i, &c) in chars.iter().enumerate() { + if !c.is_alphanumeric() { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + continue; + } + let prev = i.checked_sub(1).map(|j| chars[j]); + let next = chars.get(i + 1).copied(); + let boundary = c.is_uppercase() + && prev.is_some_and(|p| { + p.is_lowercase() || p.is_ascii_digit() || (p.is_uppercase() && next.is_some_and(char::is_lowercase)) + }); + if boundary && !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + cur.push(c); + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +fn capitalize(w: &str) -> String { + let mut c = w.chars(); + c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect()).unwrap_or_default() +} + +fn join_words(s: &str, style: Style) -> String { + // Keep surrounding whitespace (a whole-line selection). + let lead = &s[..s.len() - s.trim_start().len()]; + let trail = &s[s.trim_end().len()..]; + let w = words(s); + let body = match style { + Style::Camel => { + w.iter().enumerate().map(|(i, x)| if i == 0 { x.to_lowercase() } else { capitalize(x) }).collect() + } + Style::Pascal => w.iter().map(|x| capitalize(x)).collect(), + Style::Snake => w.iter().map(|x| x.to_lowercase()).collect::<Vec<_>>().join("_"), + Style::ScreamingSnake => w.iter().map(|x| x.to_uppercase()).collect::<Vec<_>>().join("_"), + Style::Kebab => w.iter().map(|x| x.to_lowercase()).collect::<Vec<_>>().join("-"), + Style::Dot => w.iter().map(|x| x.to_lowercase()).collect::<Vec<_>>().join("."), + }; + if body.is_empty() { s.to_owned() } else { format!("{lead}{body}{trail}") } +} + +fn title_case(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut start = true; + for c in s.chars() { + if start && c.is_alphabetic() { + out.extend(c.to_uppercase()); + start = false; + } else { + out.extend(c.to_lowercase()); + start = c.is_whitespace(); + } + } + out +} + +fn toggle_case(s: &str) -> String { + s.chars() + .flat_map(|c| -> Box<dyn Iterator<Item = char>> { + if c.is_uppercase() { Box::new(c.to_lowercase()) } else { Box::new(c.to_uppercase()) } + }) + .collect() +} + +fn map_lines(s: &str, f: impl FnOnce(&mut Vec<&str>)) -> String { + let mut lines: Vec<&str> = s.split('\n').collect(); + f(&mut lines); + lines.join("\n") +} + +/// Adds `by` to every integer in `s` (keeping zero padding). +fn step_numbers(s: &str, by: i64) -> String { + let mut out = String::with_capacity(s.len()); + let chars: Vec<char> = s.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i].is_ascii_digit() { + let start = i; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + let neg = start > 0 && chars[start - 1] == '-' && (start < 2 || !chars[start - 2].is_alphanumeric()); + let digits: String = chars[start..i].iter().collect(); + match digits.parse::<i64>() { + Ok(n) => { + let n = if neg { -n } else { n }; + let next = n.saturating_add(by); + if neg { + out.pop(); // the minus sign + } + let width = digits.len(); + let text = if next < 0 { format!("-{:0width$}", -next) } else { format!("{next:0width$}") }; + out.push_str(&text); + } + Err(_) => out.push_str(&digits), + } + } else { + out.push(chars[i]); + i += 1; + } + } + out +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn base64_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for i in 0..4 { + if i <= chunk.len() { + out.push(B64[(n >> (18 - 6 * i) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +fn base64_decode(s: &str) -> Option<Vec<u8>> { + let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + let clean = clean.strip_suffix(b"==").or_else(|| clean.strip_suffix(b"=")).unwrap_or(&clean); + let mut out = Vec::with_capacity(clean.len() * 3 / 4); + for chunk in clean.chunks(4) { + if chunk.len() == 1 { + return None; + } + let mut n = 0u32; + for (i, b) in chunk.iter().enumerate() { + let v = B64.iter().position(|x| x == b).or_else(|| match b { + b'-' => Some(62), + b'_' => Some(63), + _ => None, + })?; + n |= (v as u32) << (18 - 6 * i); + } + for i in 0..chunk.len() - 1 { + out.push((n >> (16 - 8 * i)) as u8); + } + } + Some(out) +} + +fn url_encode(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), + _ => format!("%{b:02X}"), + }) + .collect() +} + +fn url_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = |j: usize| bytes.get(j).and_then(|b| (*b as char).to_digit(16)); + match bytes[i] { + b'%' if hex(i + 1).is_some() && hex(i + 2).is_some() => { + out.push((hex(i + 1).unwrap() * 16 + hex(i + 2).unwrap()) as u8); + i += 3; + } + b'+' => { + out.push(b' '); + i += 1; + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +fn html_unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let Some(end) = rest[..rest.len().min(12)].find(';') else { + out.push('&'); + rest = &rest[1..]; + continue; + }; + let entity = &rest[1..end]; + let decoded = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some('\u{a0}'), + e if e.starts_with("#x") || e.starts_with("#X") => { + u32::from_str_radix(&e[2..], 16).ok().and_then(char::from_u32) + } + e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32), + _ => None, + }; + match decoded { + Some(c) => { + out.push(c); + rest = &rest[end + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + #[test] + fn cases() { + let t = |id: &str, s: &str| transform(id).unwrap()(s); + assert_eq!(t("camel-case", "hello_world-again"), "helloWorldAgain"); + assert_eq!(t("pascal-case", "HTTPServer error"), "HttpServerError"); + assert_eq!(t("snake-case", "parseHTTPResponse2Fast"), "parse_http_response2_fast"); + assert_eq!(t("screaming-snake-case", "maxValue"), "MAX_VALUE"); + assert_eq!(t("kebab-case", " FooBar "), " foo-bar "); + assert_eq!(t("dot-case", "a_b"), "a.b"); + assert_eq!(t("title-case", "the QUICK fox"), "The Quick Fox"); + assert_eq!(t("toggle-case", "aBc"), "AbC"); + } + + #[test] + fn encodings_round_trip() { + for s in ["", "f", "fo", "foo", "héllo wörld ✓"] { + assert_eq!(base64_decode(&base64_encode(s.as_bytes())).unwrap(), s.as_bytes()); + assert_eq!(url_decode(&url_encode(s)), s); + assert_eq!(html_unescape(&html_escape(s)), s); + } + assert_eq!(base64_encode(b"Man"), "TWFu"); + assert_eq!(url_encode("a b&c"), "a%20b%26c"); + assert_eq!(html_unescape("<a> AB &bogus; &"), "<a> AB &bogus; &"); + assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n"); + } + + #[test] + fn lines_and_numbers() { + let t = |id: &str, s: &str| transform(id).unwrap()(s); + assert_eq!(t("sort-lines", "b\na\nc"), "a\nb\nc"); + assert_eq!(t("unique-lines", "a\nb\na"), "a\nb"); + assert_eq!(t("increment", "v1.09 and -1"), "v2.10 and 0"); + assert_eq!(t("decrement", "x0"), "x-1"); + } + + #[test] + fn every_selection_and_cursor_in_one_undoable_change() { + let host = MockHost::install(); + host.open_editor("/p/a.rs", "rust", "let fooBar = baz_qux;\n", &[(0, 6, 0, 6), (0, 13, 0, 20)]); + StringTools.run_command("snake-case", &host.invocation(), None).unwrap(); + assert_eq!(host.editor_text(), "let foo_bar = baz_qux;\n"); + assert_eq!(host.editor_version(), 2, "one change for both"); + // The editor sends the (moved) selections with the next command. + host.open_editor("/p/a.rs", "rust", &host.editor_text(), &[(0, 4, 0, 11), (0, 14, 0, 21)]); + StringTools.run_command("upper-case", &host.invocation(), None).unwrap(); + assert_eq!(host.editor_text(), "let FOO_BAR = BAZ_QUX;\n"); + } + + #[test] + fn line_commands_take_whole_lines() { + let host = MockHost::install(); + host.open_editor("/p/a.txt", "text", "c\nb\na\nz", &[(0, 0, 3, 0)]); + StringTools.run_command("sort-lines", &host.invocation(), None).unwrap(); + assert_eq!(host.editor_text(), "a\nb\nc\nz"); + } + + #[test] + fn degrades_without_write_access() { + let host = MockHost::install().with_capabilities(&["editor.read"]); + host.open_editor("/p/a.txt", "text", "abc", &[(0, 0, 0, 3)]); + let err = StringTools.run_command("upper-case", &host.invocation(), None).unwrap_err(); + assert!(err.contains("editor.write"), "{err}"); + assert_eq!(host.editor_text(), "abc"); + } +} diff --git a/sdk/rust/openidea-plugin/Cargo.lock b/sdk/rust/openidea-plugin/Cargo.lock new file mode 100644 index 000000000..5b8089e7c --- /dev/null +++ b/sdk/rust/openidea-plugin/Cargo.lock @@ -0,0 +1,326 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sdk/rust/openidea-plugin/Cargo.toml b/sdk/rust/openidea-plugin/Cargo.toml new file mode 100644 index 000000000..7728a8600 --- /dev/null +++ b/sdk/rust/openidea-plugin/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "openidea-plugin" +version = "0.1.0" +edition = "2024" +description = "SDK for OpenIDEA plugins: typed host API, plugin trait, and a mock host for `cargo test`" +license = "Apache-2.0" +publish = false + +# Standalone: plugins depend on it by path or version; not part of the IDE workspace. +[workspace] + +[lib] +path = "src/lib.rs" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wit-bindgen = "0.63" diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs new file mode 100644 index 000000000..12cf7a822 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -0,0 +1,588 @@ +//! The IDE functions and handles a plugin may use. Gated ones need the +//! capability in `plugin.toml` (`[capabilities] required = [...]`) and the +//! user's grant; without it they return [`Error::Denied`]. + +use crate::types::*; + +#[cfg(target_arch = "wasm32")] +mod imp { + //! Calls through the `openidea:plugin@1.0.0` world. + use crate::bindings::openidea::plugin as wit; + use crate::types::*; + + pub type InvocationRef<'a> = &'a wit::invocation::Invocation; + pub type ProjectRes = wit::workspace::Project; + pub type EditorRes = wit::editor::TextEditor; + pub type TaskRes = wit::tasks::Task; + + pub fn error(e: wit::types::Error) -> Error { + use wit::types::Error as E; + match e { + E::Denied(c) => Error::Denied(c), + E::Stale => Error::Stale, + E::NotFound(m) => Error::NotFound(m), + E::Invalid(m) => Error::Invalid(m), + E::Cancelled => Error::Cancelled, + E::Failed(m) => Error::Failed(m), + } + } + + fn range_in(r: wit::types::Range) -> Range { + Range::new(Position::new(r.start.line, r.start.character), Position::new(r.end.line, r.end.character)) + } + + fn range_out(r: Range) -> wit::types::Range { + wit::types::Range { + start: wit::types::Position { line: r.start.line, character: r.start.character }, + end: wit::types::Position { line: r.end.line, character: r.end.character }, + } + } + + pub fn log(level: LogLevel, message: &str) { + use wit::types::LogLevel as L; + let level = match level { + LogLevel::Trace => L::Trace, + LogLevel::Debug => L::Debug, + LogLevel::Info => L::Info, + LogLevel::Warn => L::Warn, + LogLevel::Error => L::Error, + }; + wit::log::log(level, message) + } + + pub fn notify(level: NotificationLevel, title: &str, body: &str) { + use wit::types::NotificationLevel as N; + let level = match level { + NotificationLevel::Info => N::Info, + NotificationLevel::Warning => N::Warning, + NotificationLevel::Error => N::Error, + }; + wit::ui::show_notification(level, title, body) + } + + pub fn set_status_item(id: &str, item: &StatusItem) { + let item = wit::ui::StatusItem { + text: item.text.clone(), + tooltip: item.tooltip.clone(), + command: item.command.clone(), + }; + wit::ui::set_status_item(id, &item) + } + + pub fn remove_status_item(id: &str) { + wit::ui::remove_status_item(id) + } + + pub fn register_command(id: &str, title: &str, category: Option<&str>) -> Result<(), Error> { + wit::contributions::register_command(id, title, category).map_err(error) + } + + pub fn register_language(definition: &str) -> Result<(), Error> { + wit::contributions::register_language(definition).map_err(error) + } + + pub fn register_theme(definition: &str) -> Result<(), Error> { + wit::contributions::register_theme(definition).map_err(error) + } + + pub fn storage_get(key: &str) -> Result<Option<String>, Error> { + wit::storage::get(key).map_err(error) + } + + pub fn storage_set(key: &str, value: &str) -> Result<(), Error> { + wit::storage::set(key, value).map_err(error) + } + + pub fn storage_delete(key: &str) -> Result<(), Error> { + wit::storage::delete(key).map_err(error) + } + + pub fn storage_keys(prefix: &str) -> Result<Vec<String>, Error> { + wit::storage::keys(prefix).map_err(error) + } + + pub fn projects() -> Result<Vec<ProjectRes>, Error> { + wit::workspace::projects().map_err(error) + } + + pub fn project_root(p: &ProjectRes) -> Result<String, Error> { + p.root().map_err(error) + } + + pub fn project_read(p: &ProjectRes, path: &str) -> Result<String, Error> { + p.read_file(path).map_err(error) + } + + pub fn invocation_project(i: InvocationRef<'_>) -> Option<ProjectRes> { + i.project() + } + + pub fn invocation_editor(i: InvocationRef<'_>) -> Option<EditorRes> { + i.editor() + } + + pub fn editor_path(e: &EditorRes) -> String { + e.path() + } + + pub fn editor_language(e: &EditorRes) -> String { + e.language() + } + + pub fn editor_document(e: &EditorRes) -> Result<Document, Error> { + e.document().map(|d| Document { version: d.version, text: d.text }).map_err(error) + } + + pub fn editor_selections(e: &EditorRes) -> Result<Vec<Range>, Error> { + e.selections().map(|s| s.into_iter().map(range_in).collect()).map_err(error) + } + + pub fn editor_apply(e: &EditorRes, version: u64, edits: &[TextEdit]) -> Result<u64, Error> { + let edits: Vec<wit::types::TextEdit> = + edits.iter().map(|t| wit::types::TextEdit { range: range_out(t.range), text: t.text.clone() }).collect(); + e.apply_edits(version, &edits).map_err(error) + } + + pub fn vcs_status(p: &ProjectRes) -> Result<Option<RepositoryStatus>, Error> { + wit::vcs::status(p) + .map(|s| { + s.map(|s| RepositoryStatus { + root: s.root, + branch: s.branch, + commit: s.commit, + upstream: s.upstream, + ahead: s.ahead, + behind: s.behind, + operation: s.operation, + changed_files: s.changed_files, + }) + }) + .map_err(error) + } + + pub fn vcs_fetch(p: &ProjectRes) -> Result<(), Error> { + wit::vcs::fetch(p).map_err(error) + } + + fn kind(k: EventKind) -> wit::events::EventKind { + use wit::events::EventKind as K; + match k { + EventKind::FileOpened => K::FileOpened, + EventKind::FileSaved => K::FileSaved, + EventKind::EditorFocused => K::EditorFocused, + EventKind::ActionPerformed => K::ActionPerformed, + EventKind::VcsChanged => K::VcsChanged, + EventKind::Scheduled => K::Scheduled, + } + } + + pub fn subscribe(k: EventKind) -> Result<(), Error> { + wit::events::subscribe(kind(k)).map_err(error) + } + + pub fn unsubscribe(k: EventKind) { + wit::events::unsubscribe(kind(k)) + } + + pub fn begin_task(title: &str, cancellable: bool) -> TaskRes { + wit::tasks::begin(title, cancellable) + } + + pub fn task_report(t: &TaskRes, done: u32, total: Option<u32>, message: Option<&str>) { + t.report(done, total, message) + } + + pub fn task_cancelled(t: &TaskRes) -> bool { + t.is_cancelled() + } + + pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result<Vec<String>, Error> { + p.find_files(glob, limit).map_err(error) + } + + pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { + use wit::diagnostics::{Diagnostic as D, Severity as S}; + let items: Vec<D> = items + .iter() + .map(|d| D { + range: range_out(d.range), + severity: match d.severity { + Severity::Error => S::Error, + Severity::Warning => S::Warning, + Severity::Info => S::Info, + Severity::Hint => S::Hint, + }, + message: d.message.clone(), + code: d.code.clone(), + }) + .collect(); + wit::diagnostics::publish(path, &items).map_err(error) + } + + pub fn fetch(req: &HttpRequest) -> Result<HttpResponse, Error> { + let r = wit::net::Request { + method: req.method.clone(), + url: req.url.clone(), + headers: req.headers.iter().map(|(n, v)| wit::net::Header { name: n.clone(), value: v.clone() }).collect(), + body: req.body.clone(), + timeout_ms: req.timeout_ms, + }; + wit::net::fetch(&r) + .map(|r| HttpResponse { + status: r.status, + headers: r.headers.into_iter().map(|h| (h.name, h.value)).collect(), + body: r.body, + }) + .map_err(error) + } + + pub fn secret_is_set(name: &str) -> bool { + wit::secrets::is_set(name) + } + + pub fn refresh_view(view: &str) { + wit::views::refresh(view) + } + + pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> { + wit::views::post_message(view, message).map_err(error) + } + + pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { + wit::views::open_terminal(title, program, args).map_err(error) + } + + pub fn setting(key: &str) -> Option<String> { + wit::settings::get(key) + } + + pub fn run_process( + program: &str, + args: &[String], + cwd: Option<&ProjectRes>, + stdin: Option<&str>, + ) -> Result<ProcessOutput, Error> { + wit::process::run(program, args, cwd, stdin) + .map(|o| ProcessOutput { status: o.status, stdout: o.stdout, stderr: o.stderr }) + .map_err(error) + } + + pub fn range_from(r: wit::types::Range) -> Range { + range_in(r) + } + + pub fn range_to(r: Range) -> wit::types::Range { + range_out(r) + } + + pub fn event(e: wit::events::Event) -> Event { + use wit::events::{ActionSource as S, Event as E}; + match e { + E::FileOpened(p) => Event::FileOpened(p), + E::FileSaved(p) => Event::FileSaved(p), + E::EditorFocused(p) => Event::EditorFocused(p), + E::VcsChanged(p) => Event::VcsChanged(p), + E::Scheduled => Event::Scheduled, + E::ActionPerformed(a) => Event::ActionPerformed(ActionPerformed { + action: a.action, + title: a.title, + source: match a.source { + S::Keyboard => ActionSource::Keyboard, + S::Menu => ActionSource::Menu, + S::Toolbar => ActionSource::Toolbar, + S::ContextMenu => ActionSource::ContextMenu, + S::Search => ActionSource::Search, + S::Other => ActionSource::Other, + }, + shortcut: a.shortcut, + }), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +use crate::mock::current as imp; + +/// Where a command or event batch comes from: the window's project and +/// focused editor. +pub struct Invocation<'a> { + inner: imp::InvocationRef<'a>, +} + +impl<'a> Invocation<'a> { + #[doc(hidden)] + pub fn __new(inner: imp::InvocationRef<'a>) -> Self { + Self { inner } + } + + /// The window's project. + pub fn project(&self) -> Option<Project> { + imp::invocation_project(self.inner).map(|inner| Project { inner }) + } + + /// The window's focused editor. + pub fn editor(&self) -> Option<TextEditor> { + imp::invocation_editor(self.inner).map(|inner| TextEditor { inner }) + } +} + +/// A project open in the IDE. +pub struct Project { + inner: imp::ProjectRes, +} + +impl Project { + /// Absolute path of the root. Needs `project.read`. + pub fn root(&self) -> Result<String, Error> { + imp::project_root(&self.inner) + } + + /// Reads a UTF-8 file inside the project (absolute or root-relative); + /// unsaved editor contents win. Needs `project.read`. + pub fn read_file(&self, path: &str) -> Result<String, Error> { + imp::project_read(&self.inner, path) + } + + /// The repository containing the project (`None`: not under git). Needs `vcs.read`. + pub fn vcs_status(&self) -> Result<Option<RepositoryStatus>, Error> { + imp::vcs_status(&self.inner) + } + + /// Fetches all remotes. Needs `vcs.write`; long-running, so call it in a [`Task`]. + pub fn vcs_fetch(&self) -> Result<(), Error> { + imp::vcs_fetch(&self.inner) + } + + /// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`), + /// at most `limit`, as absolute paths; ignored files are skipped. Needs `project.read`. + pub fn find_files(&self, glob: &str, limit: u32) -> Result<Vec<String>, Error> { + imp::project_find(&self.inner, glob, limit) + } + + /// Runs the approved `process:<program>` in this project's root. + pub fn run_process(&self, program: &str, args: &[String], stdin: Option<&str>) -> Result<ProcessOutput, Error> { + imp::run_process(program, args, Some(&self.inner), stdin) + } +} + +/// Replaces the plugin's diagnostics for `path` (absolute, in an open +/// project); an empty slice clears them. Needs `project.read`. +pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { + imp::publish_diagnostics(path, items) +} + +/// HTTP(S) to a host granted with `net:<host>` (plain http only to +/// localhost). `{{secret:<name>}}` in a header is filled in by the IDE (needs +/// `secrets`). `Error::Cancelled` when the call was superseded. +pub fn fetch(request: &HttpRequest) -> Result<HttpResponse, Error> { + imp::fetch(request) +} + +/// Whether the user stored secret `name` (declared under `[[contributes.secrets]]`). +pub fn secret_is_set(name: &str) -> bool { + imp::secret_is_set(name) +} + +/// The content of view (or custom editor) `view` changed: the IDE renders it again. +pub fn refresh_view(view: &str) { + imp::refresh_view(view) +} + +/// Sends `message` to the script of view `view`'s HTML body. +pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> { + imp::post_view_message(view, message) +} + +/// Opens a terminal tab in the invoking window running the approved +/// `process:<program>` in the project root: logs that keep coming, +/// interactive sessions (`kubectl exec -it …`). +pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { + imp::open_terminal(title, program, args) +} + +/// One of the plugin's settings (`[contributes.settings]`) as JSON: the +/// user's value (project over global) or the schema's default. +pub fn setting(key: &str) -> Option<String> { + imp::setting(key) +} + +/// A boolean setting. +pub fn setting_bool(key: &str) -> Option<bool> { + setting(key).and_then(|v| v.parse().ok()) +} + +/// A string setting. +pub fn setting_string(key: &str) -> Option<String> { + let v = setting(key)?; + let inner = v.strip_prefix('"')?.strip_suffix('"')?; + Some(crate::text::unescape_json(inner)) +} + +/// A number setting. +pub fn setting_number(key: &str) -> Option<f64> { + setting(key).and_then(|v| v.parse().ok()) +} + +/// Runs the approved `process:<program>` in the plugin's data directory +/// (see [`Project::run_process`] to run it in a project). +pub fn run_process(program: &str, args: &[String], stdin: Option<&str>) -> Result<ProcessOutput, Error> { + imp::run_process(program, args, None, stdin) +} + +/// Projects open in any window. Needs `project.read`. +pub fn projects() -> Result<Vec<Project>, Error> { + Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect()) +} + +/// A file open in an editor. +pub struct TextEditor { + inner: imp::EditorRes, +} + +impl TextEditor { + pub fn path(&self) -> String { + imp::editor_path(&self.inner) + } + + pub fn language(&self) -> String { + imp::editor_language(&self.inner) + } + + /// Current version and text. Needs `editor.read`. + pub fn document(&self) -> Result<Document, Error> { + imp::editor_document(&self.inner) + } + + /// Selections when the command was invoked. Needs `editor.read`. + pub fn selections(&self) -> Result<Vec<Range>, Error> { + imp::editor_selections(&self.inner) + } + + /// Applies `edits` (in `version`'s coordinates, not overlapping) as one + /// undoable change; [`Error::Stale`] if the document changed meanwhile. + /// Returns the new version. Needs `editor.write`. + pub fn apply_edits(&self, version: u64, edits: &[TextEdit]) -> Result<u64, Error> { + imp::editor_apply(&self.inner, version, edits) + } + + /// Replaces every selection with `f(selected text)` in one undoable + /// change, retrying once if the user typed in between. Cursors get + /// `f("")` inserted. Needs `editor.read` and `editor.write`. + pub fn transform_selections(&self, mut f: impl FnMut(&str) -> String) -> Result<u64, Error> { + let selections = self.selections()?; + let mut last = Error::Stale; + for _ in 0..2 { + let doc = self.document()?; + let mut edits = Vec::with_capacity(selections.len()); + for r in &selections { + let old = doc.text_in(*r).ok_or_else(|| Error::Invalid("selection outside the document".into()))?; + let new = f(old); + if new != old { + edits.push(TextEdit { range: *r, text: new }); + } + } + match self.apply_edits(doc.version, &edits) { + Err(Error::Stale) => last = Error::Stale, + other => return other, + } + } + Err(last) + } +} + +/// Long-running work shown in the status bar; dropping it ends it. While a +/// task is open, the current call may run for up to 60 s instead of 5 s. +pub struct Task { + inner: imp::TaskRes, +} + +impl Task { + /// `done` of `total` units (`None`: indeterminate), with a short status. + pub fn report(&self, done: u32, total: Option<u32>, message: Option<&str>) { + imp::task_report(&self.inner, done, total, message) + } + + /// The user cancelled it: stop soon and return. + pub fn is_cancelled(&self) -> bool { + imp::task_cancelled(&self.inner) + } +} + +/// Starts a task (see [`Task`]). +pub fn begin_task(title: &str, cancellable: bool) -> Task { + Task { inner: imp::begin_task(title, cancellable) } +} + +/// Writes to the IDE log, tagged with the plugin id. +pub fn log(level: LogLevel, message: &str) { + imp::log(level, message) +} + +/// Shows a notification balloon in the window the command or events came +/// from (every window when there is none). +pub fn notify(level: NotificationLevel, title: &str, body: &str) { + imp::notify(level, title, body) +} + +/// Shows or updates the plugin's status bar item `id` (at most 8). +pub fn set_status_item(id: &str, item: &StatusItem) { + imp::set_status_item(id, item) +} + +pub fn remove_status_item(id: &str) { + imp::remove_status_item(id) +} + +/// Registers or renames a command at runtime (action `plugin.<plugin-id>.<id>`). +/// Prefer declaring commands in `plugin.toml`: they show before the plugin runs. +pub fn register_command(id: &str, title: &str, category: Option<&str>) -> Result<(), Error> { + imp::register_command(id, title, category) +} + +/// Registers a language (`languages/<id>.toml` format) at runtime. +pub fn register_language(definition: &str) -> Result<(), Error> { + imp::register_language(definition) +} + +/// Registers a theme (theme JSON) at runtime. +pub fn register_theme(definition: &str) -> Result<(), Error> { + imp::register_theme(definition) +} + +/// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of +/// the session (also after the instance is reclaimed: an event activates the +/// plugin again). Needs `events` (`events.input` for `ActionPerformed`). +pub fn subscribe(kind: EventKind) -> Result<(), Error> { + imp::subscribe(kind) +} + +pub fn unsubscribe(kind: EventKind) { + imp::unsubscribe(kind) +} + +/// The plugin's persistent key-value store (≤ 1 MiB). Needs `storage`. +pub mod storage { + use super::imp; + use crate::types::Error; + + pub fn get(key: &str) -> Result<Option<String>, Error> { + imp::storage_get(key) + } + + pub fn set(key: &str, value: &str) -> Result<(), Error> { + imp::storage_set(key, value) + } + + pub fn delete(key: &str) -> Result<(), Error> { + imp::storage_delete(key) + } + + /// Keys starting with `prefix`, sorted. + pub fn keys(prefix: &str) -> Result<Vec<String>, Error> { + imp::storage_keys(prefix) + } +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub use imp::{event as __event, range_from as __range_from, range_to as __range_to}; diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs new file mode 100644 index 000000000..5695cf5e7 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -0,0 +1,649 @@ +//! SDK for OpenIDEA plugins written in Rust (plugin API 1.0). +//! +//! ```ignore +//! use openidea_plugin::{Context, Invocation, Plugin, register_plugin}; +//! +//! #[derive(Default)] +//! struct Shout; +//! +//! impl Plugin for Shout { +//! fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> { +//! match id { +//! "shout" => { +//! let editor = ctx.editor().ok_or("no editor is focused")?; +//! editor.transform_selections(|s| s.to_uppercase())?; +//! Ok(None) +//! } +//! _ => Err(format!("unknown command {id}")), +//! } +//! } +//! } +//! +//! register_plugin!(Shout); +//! ``` +//! +//! On `wasm32-wasip2` the [`host`] functions and handles call the IDE through +//! the `openidea:plugin@1.0.0` WIT world. On any other target they call the +//! [`mock`] host, so plugin logic is tested with plain `cargo test`: +//! +//! ```ignore +//! let host = openidea_plugin::mock::MockHost::install(); +//! host.open_editor("/project/a.txt", "rust", "hi there", &[(0, 0, 0, 2)]); +//! Shout.run_command("shout", &host.invocation(), None).unwrap(); +//! assert_eq!(host.editor_text(), "HI there"); +//! ``` +//! +//! Instance memory is a cache: the instance is dropped after a crash or when +//! idle. Keep what must survive in [`host::storage`]. +//! +//! Build with `openidea-plugin build` (or `cargo build --release --target +//! wasm32-wasip2`); see `plugins/README.md`. + +pub mod host; +#[cfg(not(target_arch = "wasm32"))] +pub mod mock; +pub mod providers; +pub mod syntax; +pub mod text; +mod types; +pub mod views; + +pub use host::{Invocation, Project, Task, TextEditor}; +pub use types::*; + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod bindings { + wit_bindgen::generate!({ + world: "plugin", + path: "wit", + pub_export_macro: true, + export_macro_name: "__export_plugin_world", + default_bindings_module: "openidea_plugin::bindings", + }); +} + +/// The `syntax` world: only the `syntax-provider` export, merged with +/// `plugin` into one component (world `plugin-syntax`). +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod syntax_bindings { + wit_bindgen::generate!({ + world: "syntax", + path: "wit", + pub_export_macro: true, + export_macro_name: "__export_syntax_world", + default_bindings_module: "openidea_plugin::syntax_bindings", + }); +} + +/// The `editor-providers` world: only the `providers` export, merged with +/// `plugin` (world `plugin-providers`). +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod providers_bindings { + wit_bindgen::generate!({ + world: "editor-providers", + path: "wit", + pub_export_macro: true, + export_macro_name: "__export_editor_providers_world", + default_bindings_module: "openidea_plugin::providers_bindings", + with: { + "openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types, + }, + }); +} + +/// The `views-provider` world: only the `view-provider` export, merged with +/// `plugin` (world `plugin-views`). +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod views_bindings { + wit_bindgen::generate!({ + world: "views-provider", + path: "wit", + pub_export_macro: true, + export_macro_name: "__export_views_world", + default_bindings_module: "openidea_plugin::views_bindings", + with: { + "openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.0.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.0.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.0.0": crate::bindings::openidea::plugin::invocation, + }, + }); +} + +/// The plugin API version this SDK targets. +pub const API_VERSION: &str = "1.0"; + +/// A plugin. One instance lives as long as the plugin's Wasm instance (until +/// it is disabled, crashes or is reclaimed when idle). +pub trait Plugin: 'static { + /// Called once when the instance is created. Register runtime commands + /// and subscriptions here; restore state from [`host::storage`]. + fn activate(&mut self, _ctx: &Context) -> Result<(), String> { + Ok(()) + } + + /// Called before the instance is dropped. + fn deactivate(&mut self) {} + + /// Runs one of the plugin's commands; `Ok(Some(text))` is shown to the user. + fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, String>; + + /// A batch of subscribed events (see [`host::subscribe`]), oldest first. + fn handle_events(&mut self, _events: &[Event], _ctx: &Invocation) {} +} + +/// Exports `T` (a [`Plugin`] + `Default`) as the plugin's component. With +/// `syntax`, `T` is also a [`syntax::SyntaxProvider`] for its languages. +#[macro_export] +macro_rules! register_plugin { + ($plugin:ty) => { + // Natively (tests) nothing is exported; keep the type "used". + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, providers) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__providers_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, views) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__views_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, providers, views) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__providers_exports!($plugin, __OpenideaComponent); + $crate::__views_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, syntax) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + + impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for __OpenideaComponent { + fn highlights( + language: ::std::string::String, + text: ::std::string::String, + ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Token> + { + $crate::__rt::highlights::<$plugin>(&language, &text) + } + + fn outline( + language: ::std::string::String, + text: ::std::string::String, + ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Symbol> + { + $crate::__rt::outline::<$plugin>(&language, &text) + } + + fn folding( + language: ::std::string::String, + text: ::std::string::String, + ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Fold> + { + $crate::__rt::folding::<$plugin>(&language, &text) + } + } + + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + $crate::syntax_bindings::__export_syntax_world!(__OpenideaComponent with_types_in $crate::syntax_bindings); + }; + }; +} + +/// `register_plugin!(T, providers)`: `T` is also a [`providers::Providers`]. +#[doc(hidden)] +#[macro_export] +macro_rules! __providers_exports { + ($plugin:ty, $component:ident) => { + impl $crate::providers_bindings::exports::openidea::plugin::providers::Guest for $component { + fn completion( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + at: $crate::bindings::openidea::plugin::types::Position, + trigger: ::core::option::Option<::std::string::String>, + ) -> ::std::vec::Vec<$crate::providers_bindings::exports::openidea::plugin::providers::CompletionItem> { + $crate::__rt::completion::<$plugin>(doc, at, trigger) + } + + fn inline_completion( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + at: $crate::bindings::openidea::plugin::types::Position, + ) -> ::core::option::Option<$crate::providers_bindings::exports::openidea::plugin::providers::InlineSuggestion> { + $crate::__rt::inline_completion::<$plugin>(doc, at) + } + + fn code_actions( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + selection: $crate::bindings::openidea::plugin::types::Range, + ) -> ::std::vec::Vec<$crate::providers_bindings::exports::openidea::plugin::providers::CodeAction> { + $crate::__rt::code_actions::<$plugin>(doc, selection) + } + + fn definition( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + at: $crate::bindings::openidea::plugin::types::Position, + ) -> ::std::vec::Vec<$crate::bindings::openidea::plugin::types::Location> { + $crate::__rt::definition::<$plugin>(doc, at) + } + + fn line_markers( + doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document, + ) -> ::std::vec::Vec<$crate::providers_bindings::exports::openidea::plugin::providers::LineMarker> { + $crate::__rt::line_markers::<$plugin>(doc) + } + } + + $crate::providers_bindings::__export_editor_providers_world!($component with_types_in $crate::providers_bindings); + }; +} + +/// `register_plugin!(T, views)`: `T` is also a [`views::Views`]. +#[doc(hidden)] +#[macro_export] +macro_rules! __views_exports { + ($plugin:ty, $component:ident) => { + impl $crate::views_bindings::exports::openidea::plugin::view_provider::Guest for $component { + fn render( + view: ::std::string::String, + viewport: ::core::option::Option<$crate::views_bindings::openidea::plugin::view_types::Viewport>, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + ) -> ::core::result::Result< + $crate::views_bindings::openidea::plugin::view_types::View, + $crate::bindings::openidea::plugin::types::Error, + > { + $crate::__rt::render_view::<$plugin>(view, viewport, ctx) + } + + fn handle( + view: ::std::string::String, + event: $crate::views_bindings::openidea::plugin::view_types::ViewEvent, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + ) -> ::core::result::Result<(), $crate::bindings::openidea::plugin::types::Error> { + $crate::__rt::view_event::<$plugin>(view, event, ctx) + } + } + + $crate::views_bindings::__export_views_world!($component with_types_in $crate::views_bindings); + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __plugin_exports { + ($plugin:ty, $component:ident, $b:path) => { + impl $crate::bindings::exports::openidea::plugin::lifecycle::Guest for $component { + fn activate( + ctx: $crate::bindings::openidea::plugin::types::Context, + ) -> ::core::result::Result<(), ::std::string::String> { + $crate::__rt::activate::<$plugin>(ctx) + } + + fn deactivate() { + $crate::__rt::deactivate::<$plugin>() + } + } + + impl $crate::bindings::exports::openidea::plugin::commands::Guest for $component { + fn run( + id: ::std::string::String, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + arg: ::core::option::Option<::std::string::String>, + ) -> ::core::result::Result<::core::option::Option<::std::string::String>, ::std::string::String> { + $crate::__rt::run_command::<$plugin>(id, ctx, arg) + } + } + + impl $crate::bindings::exports::openidea::plugin::event_handler::Guest for $component { + fn handle( + events: ::std::vec::Vec<$crate::bindings::openidea::plugin::events::Event>, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + ) { + $crate::__rt::handle_events::<$plugin>(events, ctx) + } + } + }; +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod __rt { + //! The single plugin instance behind the exports. + use super::syntax::SyntaxProvider; + use super::{Context, Invocation, Plugin}; + use crate::bindings::openidea::plugin as b; + use crate::syntax_bindings::exports::openidea::plugin::syntax_provider as wit; + use std::any::Any; + use std::cell::RefCell; + + thread_local! { + static INSTANCE: RefCell<Option<Box<dyn Any>>> = const { RefCell::new(None) }; + } + + fn with<T: Plugin + Default, R>(f: impl FnOnce(&mut T) -> R) -> R { + INSTANCE.with(|cell| { + let mut slot = cell.borrow_mut(); + let plugin = slot.get_or_insert_with(|| Box::new(T::default())); + f(plugin.downcast_mut::<T>().expect("one plugin type per component")) + }) + } + + pub fn activate<T: Plugin + Default>(ctx: b::types::Context) -> Result<(), String> { + let ctx = Context { + plugin_id: ctx.plugin_id, + plugin_version: ctx.plugin_version, + api_version: ctx.api_version, + capabilities: ctx.capabilities, + }; + with::<T, _>(|p| p.activate(&ctx)) + } + + pub fn deactivate<T: Plugin + Default>() { + with::<T, _>(|p| p.deactivate()); + INSTANCE.with(|cell| cell.borrow_mut().take()); + } + + pub fn run_command<T: Plugin + Default>( + id: String, + ctx: &b::invocation::Invocation, + arg: Option<String>, + ) -> Result<Option<String>, String> { + let ctx = Invocation::__new(ctx); + with::<T, _>(|p| p.run_command(&id, &ctx, arg.as_deref())) + } + + // ---- providers ---- + + use crate::providers as p; + use crate::providers_bindings::exports::openidea::plugin::providers as w; + + fn doc(d: w::Document) -> p::Document { + p::Document { path: d.path, language: d.language, version: d.version, text: d.text } + } + + fn at(p: b::types::Position) -> crate::Position { + crate::Position::new(p.line, p.character) + } + + fn loc(l: crate::Location) -> b::types::Location { + b::types::Location { path: l.path, range: crate::host::__range_to(l.range) } + } + + pub fn completion<T: Plugin + p::Providers + Default>( + d: w::Document, + pos: b::types::Position, + trigger: Option<String>, + ) -> Vec<w::CompletionItem> { + let d = doc(d); + with::<T, _>(|x| x.completion(&d, at(pos), trigger.as_deref())) + .into_iter() + .map(|i| w::CompletionItem { + label: i.label, + detail: i.detail, + kind: i.kind.map(|k| match k { + p::CompletionKind::Text => w::CompletionKind::Text, + p::CompletionKind::Method => w::CompletionKind::Method, + p::CompletionKind::Function => w::CompletionKind::Function, + p::CompletionKind::Constructor => w::CompletionKind::Constructor, + p::CompletionKind::Field => w::CompletionKind::Field, + p::CompletionKind::Variable => w::CompletionKind::Variable, + p::CompletionKind::Class => w::CompletionKind::Class, + p::CompletionKind::Interface => w::CompletionKind::Interface, + p::CompletionKind::Module => w::CompletionKind::Module, + p::CompletionKind::Property => w::CompletionKind::Property, + p::CompletionKind::Keyword => w::CompletionKind::Keyword, + p::CompletionKind::Snippet => w::CompletionKind::Snippet, + p::CompletionKind::Value => w::CompletionKind::Value, + p::CompletionKind::Constant => w::CompletionKind::Constant, + p::CompletionKind::File => w::CompletionKind::File, + p::CompletionKind::Reference => w::CompletionKind::Reference, + }), + insert_text: i.insert_text, + replace: i.replace.map(crate::host::__range_to), + sort_text: i.sort_text, + documentation: i.documentation, + }) + .collect() + } + + pub fn inline_completion<T: Plugin + p::Providers + Default>( + d: w::Document, + pos: b::types::Position, + ) -> Option<w::InlineSuggestion> { + let d = doc(d); + with::<T, _>(|x| x.inline_completion(&d, at(pos))).map(|s| w::InlineSuggestion { + insert_text: s.insert_text, + replace: s.replace.map(crate::host::__range_to), + }) + } + + pub fn code_actions<T: Plugin + p::Providers + Default>( + d: w::Document, + sel: b::types::Range, + ) -> Vec<w::CodeAction> { + let d = doc(d); + with::<T, _>(|x| x.code_actions(&d, crate::host::__range_from(sel))) + .into_iter() + .map(|a| w::CodeAction { + title: a.title, + kind: a.kind, + preferred: a.preferred, + edits: a + .edits + .into_iter() + .map(|(path, edits)| w::FileEdit { + path, + edits: edits + .into_iter() + .map(|e| b::types::TextEdit { range: crate::host::__range_to(e.range), text: e.text }) + .collect(), + }) + .collect(), + }) + .collect() + } + + pub fn definition<T: Plugin + p::Providers + Default>( + d: w::Document, + pos: b::types::Position, + ) -> Vec<b::types::Location> { + let d = doc(d); + with::<T, _>(|x| x.definition(&d, at(pos))).into_iter().map(loc).collect() + } + + pub fn line_markers<T: Plugin + p::Providers + Default>(d: w::Document) -> Vec<w::LineMarker> { + let d = doc(d); + with::<T, _>(|x| x.line_markers(&d)) + .into_iter() + .map(|m| w::LineMarker { line: m.line, icon: m.icon, tooltip: m.tooltip, target: m.target.map(loc) }) + .collect() + } + + // ---- views ---- + + use crate::views as v; + use crate::views_bindings::openidea::plugin::view_types as vt; + + fn error_out(e: crate::Error) -> b::types::Error { + use b::types::Error as E; + match e { + crate::Error::Denied(c) => E::Denied(c), + crate::Error::Stale => E::Stale, + crate::Error::NotFound(m) => E::NotFound(m), + crate::Error::Invalid(m) => E::Invalid(m), + crate::Error::Cancelled => E::Cancelled, + crate::Error::Failed(m) => E::Failed(m), + } + } + + fn view_out(view: v::View) -> vt::View { + let body = match view.body { + v::Body::Message(m) => vt::Body::Message(m), + v::Body::Tree(items) => vt::Body::Tree( + items + .into_iter() + .map(|i| vt::TreeItem { + key: i.key, + parent: i.parent, + label: i.label, + icon: i.icon, + detail: i.detail, + expandable: i.expandable, + expanded: i.expanded, + }) + .collect(), + ), + v::Body::List(items) => vt::Body::List( + items + .into_iter() + .map(|i| vt::ListItem { key: i.key, label: i.label, icon: i.icon, detail: i.detail }) + .collect(), + ), + v::Body::Form(fields) => vt::Body::Form( + fields + .into_iter() + .map(|f| vt::Field { + key: f.key, + label: f.label, + value: match f.value { + v::FieldValue::Text(t) => vt::FieldValue::Text(t), + v::FieldValue::Multiline(t) => vt::FieldValue::Multiline(t), + v::FieldValue::Checkbox(c) => vt::FieldValue::Checkbox(c), + v::FieldValue::Choice(o, s) => vt::FieldValue::Choice((o, s)), + }, + }) + .collect(), + ), + v::Body::Table(t) => vt::Body::Table(vt::Table { + columns: t.columns, + total_rows: t.total_rows, + first_row: t.first_row, + rows: t.rows, + editable: t.editable, + }), + v::Body::Html(h) => vt::Body::Html(h), + }; + vt::View { + toolbar: view + .toolbar + .into_iter() + .map(|b| vt::Button { id: b.id, label: b.label, icon: b.icon, enabled: b.enabled }) + .collect(), + body, + status: view.status, + } + } + + pub fn render_view<T: Plugin + v::Views + Default>( + view: String, + viewport: Option<vt::Viewport>, + ctx: &b::invocation::Invocation, + ) -> Result<vt::View, b::types::Error> { + let ctx = Invocation::__new(ctx); + let viewport = viewport.map(|v| v::Viewport { first_row: v.first_row, rows: v.rows }); + with::<T, _>(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out) + } + + pub fn view_event<T: Plugin + v::Views + Default>( + view: String, + event: vt::ViewEvent, + ctx: &b::invocation::Invocation, + ) -> Result<(), b::types::Error> { + let ctx = Invocation::__new(ctx); + let event = match event { + vt::ViewEvent::Button(id) => v::ViewEvent::Button(id), + vt::ViewEvent::Activate(k) => v::ViewEvent::Activate(k), + vt::ViewEvent::Select(k) => v::ViewEvent::Select(k), + vt::ViewEvent::Expand(k) => v::ViewEvent::Expand(k), + vt::ViewEvent::Collapse(k) => v::ViewEvent::Collapse(k), + vt::ViewEvent::Field((k, value)) => v::ViewEvent::Field(k, value), + vt::ViewEvent::Cell(c) => v::ViewEvent::Cell { row: c.row, column: c.column, value: c.value }, + vt::ViewEvent::InsertRow(r) => v::ViewEvent::InsertRow(r), + vt::ViewEvent::DeleteRows(r) => v::ViewEvent::DeleteRows(r), + vt::ViewEvent::Message(m) => v::ViewEvent::Message(m), + }; + with::<T, _>(|p| p.handle(&view, event, &ctx)).map_err(error_out) + } + + pub fn handle_events<T: Plugin + Default>(events: Vec<b::events::Event>, ctx: &b::invocation::Invocation) { + let events: Vec<crate::Event> = events.into_iter().map(crate::host::__event).collect(); + let ctx = Invocation::__new(ctx); + with::<T, _>(|p| p.handle_events(&events, &ctx)) + } + + fn offset(n: usize) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) + } + + pub fn highlights<T: Plugin + SyntaxProvider + Default>(language: &str, text: &str) -> Vec<wit::Token> { + with::<T, _>(|p| p.highlights(language, text)) + .into_iter() + .map(|t| wit::Token { start: offset(t.range.start), end: offset(t.range.end), kind: t.kind.name().into() }) + .collect() + } + + pub fn outline<T: Plugin + SyntaxProvider + Default>(language: &str, text: &str) -> Vec<wit::Symbol> { + with::<T, _>(|p| p.outline(language, text)) + .into_iter() + .map(|s| wit::Symbol { + name: s.name, + detail: s.detail, + kind: s.kind.name().into(), + start: offset(s.range.start), + end: offset(s.range.end), + name_start: offset(s.name_range.start), + name_end: offset(s.name_range.end), + parent: s.parent.map(offset), + }) + .collect() + } + + pub fn folding<T: Plugin + SyntaxProvider + Default>(language: &str, text: &str) -> Vec<wit::Fold> { + with::<T, _>(|p| p.folding(language, text)) + .into_iter() + .map(|f| wit::Fold { + start_line: f.start_line, + end_line: f.end_line, + kind: match f.kind { + super::syntax::FoldKind::Region => "region", + super::syntax::FoldKind::Comment => "comment", + super::syntax::FoldKind::Imports => "imports", + } + .into(), + }) + .collect() + } +} diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs new file mode 100644 index 000000000..e82a96cfa --- /dev/null +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -0,0 +1,788 @@ +//! A mock IDE for testing plugins with `cargo test` on the host target. +//! +//! [`MockHost::install`] makes the [`crate::host`] functions of the current +//! thread talk to a fresh mock: it records what the plugin did, keeps one +//! editor with a versioned document, and enforces the capabilities you give +//! it like the real host (a missing one returns [`Error::Denied`]). + +use crate::host::Invocation; +use crate::types::*; +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::marker::PhantomData; +use std::rc::Rc; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Notification { + pub level: NotificationLevel, + pub title: String, + pub body: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Command { + pub id: String, + pub title: String, + pub category: Option<String>, +} + +/// A task the plugin began. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MockTask { + pub title: String, + /// `(done, total, message)` reports, in order. + pub reports: Vec<(u32, Option<u32>, Option<String>)>, + pub cancelled: bool, + pub ended: bool, +} + +type NetHandler = Box<dyn Fn(&HttpRequest) -> Result<HttpResponse, Error>>; +type ProcessHandler = Box<dyn Fn(&[String], Option<&str>) -> Result<ProcessOutput, Error>>; + +/// A process run the plugin made. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProcessRun { + pub program: String, + pub args: Vec<String>, + /// The project root it ran in (`None`: the data directory). + pub cwd: Option<String>, +} + +struct Editor { + path: String, + language: String, + text: String, + version: u64, + selections: Vec<Range>, +} + +#[derive(Default)] +struct State { + capabilities: Vec<String>, + root: Option<String>, + other_projects: Vec<String>, + files: BTreeMap<String, String>, + editor: Option<Editor>, + notifications: Vec<Notification>, + commands: Vec<Command>, + languages: Vec<String>, + themes: Vec<String>, + logs: Vec<(LogLevel, String)>, + status: BTreeMap<String, StatusItem>, + storage: BTreeMap<String, String>, + subscriptions: BTreeSet<EventKind>, + tasks: Vec<MockTask>, + vcs: Option<RepositoryStatus>, + fetches: u32, + fetch_error: Option<Error>, + diagnostics: BTreeMap<String, Vec<Diagnostic>>, + secrets: BTreeMap<String, String>, + net: Option<NetHandler>, + requests: Vec<HttpRequest>, + processes: BTreeMap<String, ProcessHandler>, + runs: Vec<ProcessRun>, + refreshed: Vec<String>, + view_messages: Vec<(String, String)>, + terminals: Vec<MockTerminal>, + settings: BTreeMap<String, String>, +} + +/// A terminal the plugin opened. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MockTerminal { + pub title: String, + pub program: String, + pub args: Vec<String>, +} + +thread_local! { + static CURRENT: RefCell<Option<Rc<RefCell<State>>>> = const { RefCell::new(None) }; +} + +/// Handle to the mock installed for this thread. Dropping it uninstalls it. +pub struct MockHost { + state: Rc<RefCell<State>>, +} + +/// Every capability a plugin can hold, except `process:`/`net:`/`fs.read:`. +const ALL: &[&str] = + &["project.read", "editor.read", "editor.write", "vcs.read", "vcs.write", "storage", "events", "events.input"]; + +impl MockHost { + /// Installs a mock granting the usual capabilities, with project `/project`. + pub fn install() -> Self { + let state = Rc::new(RefCell::new(State { + capabilities: ALL.iter().map(|s| (*s).to_owned()).collect(), + root: Some("/project".into()), + ..State::default() + })); + CURRENT.with(|c| *c.borrow_mut() = Some(state.clone())); + Self { state } + } + + /// Grants exactly these capabilities. + pub fn with_capabilities(self, caps: &[&str]) -> Self { + self.state.borrow_mut().capabilities = caps.iter().map(|s| (*s).to_owned()).collect(); + self + } + + /// The invoking window's project (`None`: no project). + pub fn set_project_root(&self, root: Option<&str>) { + self.state.borrow_mut().root = root.map(str::to_owned); + } + + /// More open projects (for [`crate::host::projects`]). + pub fn add_project(&self, root: &str) { + self.state.borrow_mut().other_projects.push(root.to_owned()); + } + + /// A project file `read_file` can return (project-relative or absolute path). + pub fn add_file(&self, path: &str, text: &str) { + self.state.borrow_mut().files.insert(path.to_owned(), text.to_owned()); + } + + /// Focuses an editor on `text` with selections `(line, col, line, col)`. + pub fn open_editor(&self, path: &str, language: &str, text: &str, selections: &[(u32, u32, u32, u32)]) { + let selections = + selections.iter().map(|&(a, b, c, d)| Range::new(Position::new(a, b), Position::new(c, d))).collect(); + self.state.borrow_mut().editor = + Some(Editor { path: path.into(), language: language.into(), text: text.into(), version: 1, selections }); + } + + pub fn editor_text(&self) -> String { + self.state.borrow().editor.as_ref().map(|e| e.text.clone()).unwrap_or_default() + } + + pub fn editor_version(&self) -> u64 { + self.state.borrow().editor.as_ref().map_or(0, |e| e.version) + } + + /// The user edits the document (its version moves on). + pub fn type_in_editor(&self, text: &str) { + if let Some(e) = self.state.borrow_mut().editor.as_mut() { + e.text.push_str(text); + e.version += 1; + } + } + + /// The invocation a command from the focused window gets. + pub fn invocation(&self) -> Invocation<'static> { + Invocation::__new(MockInvocation(PhantomData)) + } + + pub fn notifications(&self) -> Vec<Notification> { + self.state.borrow().notifications.clone() + } + + pub fn commands(&self) -> Vec<Command> { + self.state.borrow().commands.clone() + } + + pub fn languages(&self) -> Vec<String> { + self.state.borrow().languages.clone() + } + + pub fn themes(&self) -> Vec<String> { + self.state.borrow().themes.clone() + } + + pub fn logs(&self) -> Vec<(LogLevel, String)> { + self.state.borrow().logs.clone() + } + + pub fn status_items(&self) -> BTreeMap<String, StatusItem> { + self.state.borrow().status.clone() + } + + pub fn storage(&self) -> BTreeMap<String, String> { + self.state.borrow().storage.clone() + } + + pub fn subscriptions(&self) -> BTreeSet<EventKind> { + self.state.borrow().subscriptions.clone() + } + + pub fn tasks(&self) -> Vec<MockTask> { + self.state.borrow().tasks.clone() + } + + /// The user cancels every open task. + pub fn cancel_tasks(&self) { + for t in &mut self.state.borrow_mut().tasks { + t.cancelled = true; + } + } + + /// What `vcs-status` returns for every project (`None`: not under git). + pub fn set_vcs_status(&self, status: Option<RepositoryStatus>) { + self.state.borrow_mut().vcs = status; + } + + /// Makes `vcs-fetch` fail. + pub fn fail_fetch(&self, error: Option<Error>) { + self.state.borrow_mut().fetch_error = error; + } + + pub fn fetches(&self) -> u32 { + self.state.borrow().fetches + } + + /// Diagnostics the plugin published, per path. + pub fn diagnostics(&self) -> BTreeMap<String, Vec<Diagnostic>> { + self.state.borrow().diagnostics.clone() + } + + /// Views the plugin asked to refresh, in order. + pub fn refreshed_views(&self) -> Vec<String> { + self.state.borrow().refreshed.clone() + } + + /// `(view, message)` the plugin posted to its views' frames. + pub fn view_messages(&self) -> Vec<(String, String)> { + self.state.borrow().view_messages.clone() + } + + /// Terminals the plugin opened. + pub fn terminals(&self) -> Vec<MockTerminal> { + self.state.borrow().terminals.clone() + } + + /// The value of one of the plugin's settings, as JSON (`true`, `"text"`). + pub fn set_setting(&self, key: &str, json: &str) { + self.state.borrow_mut().settings.insert(key.into(), json.into()); + } + + /// A secret the user stored. + pub fn set_secret(&self, name: &str, value: &str) { + self.state.borrow_mut().secrets.insert(name.into(), value.into()); + } + + /// Answers the plugin's HTTP requests (secrets already filled in). + pub fn on_fetch(&self, handler: impl Fn(&HttpRequest) -> Result<HttpResponse, Error> + 'static) { + self.state.borrow_mut().net = Some(Box::new(handler)); + } + + /// Requests the plugin made, as sent (secrets filled in). + pub fn requests(&self) -> Vec<HttpRequest> { + self.state.borrow().requests.clone() + } + + /// What running `process:<program>` does: `(args, stdin) → output`. + pub fn on_process( + &self, + program: &str, + handler: impl Fn(&[String], Option<&str>) -> Result<ProcessOutput, Error> + 'static, + ) { + self.state.borrow_mut().processes.insert(program.into(), Box::new(handler)); + } + + pub fn process_runs(&self) -> Vec<ProcessRun> { + self.state.borrow().runs.clone() + } + + /// A context as `activate` receives it. + pub fn context(&self, plugin_id: &str) -> Context { + Context { + plugin_id: plugin_id.into(), + plugin_version: "0.0.0".into(), + api_version: crate::API_VERSION.into(), + capabilities: self.state.borrow().capabilities.clone(), + } + } +} + +impl Drop for MockHost { + fn drop(&mut self) { + CURRENT.with(|c| { + let mut cur = c.borrow_mut(); + if cur.as_ref().is_some_and(|s| Rc::ptr_eq(s, &self.state)) { + *cur = None; + } + }); + } +} + +#[doc(hidden)] +#[derive(Clone, Copy)] +pub struct MockInvocation<'a>(PhantomData<&'a ()>); + +#[doc(hidden)] +pub struct MockProject { + root: String, +} + +#[doc(hidden)] +pub struct MockEditor; + +#[doc(hidden)] +pub struct MockTaskRes { + index: usize, +} + +impl Drop for MockTaskRes { + fn drop(&mut self) { + current::with(|s| { + if let Some(t) = s.tasks.get_mut(self.index) { + t.ended = true; + } + }) + } +} + +#[doc(hidden)] +pub mod current { + //! The host functions, backed by the installed mock. + use super::*; + + pub type InvocationRef<'a> = MockInvocation<'a>; + pub type ProjectRes = MockProject; + pub type EditorRes = MockEditor; + pub type TaskRes = MockTaskRes; + + pub(super) fn with<R>(f: impl FnOnce(&mut State) -> R) -> R { + CURRENT.with(|c| { + let cur = c.borrow(); + let state = cur.as_ref().expect("no mock host: call openidea_plugin::mock::MockHost::install() first"); + f(&mut state.borrow_mut()) + }) + } + + fn require(s: &State, cap: &str) -> Result<(), Error> { + if s.capabilities.iter().any(|c| c == cap) { Ok(()) } else { Err(Error::Denied(cap.to_owned())) } + } + + pub fn log(level: LogLevel, message: &str) { + with(|s| s.logs.push((level, message.to_owned()))) + } + + pub fn notify(level: NotificationLevel, title: &str, body: &str) { + with(|s| s.notifications.push(Notification { level, title: title.into(), body: body.into() })) + } + + pub fn set_status_item(id: &str, item: &StatusItem) { + with(|s| s.status.insert(id.to_owned(), item.clone())); + } + + pub fn remove_status_item(id: &str) { + with(|s| s.status.remove(id)); + } + + pub fn register_command(id: &str, title: &str, category: Option<&str>) -> Result<(), Error> { + with(|s| { + let cmd = Command { id: id.into(), title: title.into(), category: category.map(str::to_owned) }; + match s.commands.iter_mut().find(|c| c.id == id) { + Some(existing) => *existing = cmd, + None => s.commands.push(cmd), + } + Ok(()) + }) + } + + pub fn register_language(definition: &str) -> Result<(), Error> { + with(|s| { + if !definition.contains("id") { + return Err(Error::Invalid("invalid language definition".into())); + } + s.languages.push(definition.into()); + Ok(()) + }) + } + + pub fn register_theme(definition: &str) -> Result<(), Error> { + with(|s| { + if !definition.trim_start().starts_with('{') { + return Err(Error::Invalid("invalid theme JSON".into())); + } + s.themes.push(definition.into()); + Ok(()) + }) + } + + pub fn storage_get(key: &str) -> Result<Option<String>, Error> { + with(|s| { + require(s, "storage")?; + Ok(s.storage.get(key).cloned()) + }) + } + + pub fn storage_set(key: &str, value: &str) -> Result<(), Error> { + with(|s| { + require(s, "storage")?; + s.storage.insert(key.to_owned(), value.to_owned()); + Ok(()) + }) + } + + pub fn storage_delete(key: &str) -> Result<(), Error> { + with(|s| { + require(s, "storage")?; + s.storage.remove(key); + Ok(()) + }) + } + + pub fn storage_keys(prefix: &str) -> Result<Vec<String>, Error> { + with(|s| { + require(s, "storage")?; + Ok(s.storage.keys().filter(|k| k.starts_with(prefix)).cloned().collect()) + }) + } + + pub fn projects() -> Result<Vec<ProjectRes>, Error> { + with(|s| { + require(s, "project.read")?; + Ok(s.root.iter().chain(&s.other_projects).map(|root| MockProject { root: root.clone() }).collect()) + }) + } + + pub fn project_root(p: &ProjectRes) -> Result<String, Error> { + with(|s| { + require(s, "project.read")?; + Ok(p.root.clone()) + }) + } + + pub fn project_read(p: &ProjectRes, path: &str) -> Result<String, Error> { + with(|s| { + require(s, "project.read")?; + let root = p.root.as_str(); + let rel = path.strip_prefix(root).map(|p| p.trim_start_matches('/')).unwrap_or(path); + if rel.starts_with('/') || rel.split('/').any(|seg| seg == "..") { + return Err(Error::Invalid(format!("{path}: access outside the project root is not allowed"))); + } + let open = s.editor.as_ref().filter(|e| e.path == path || e.path == format!("{root}/{rel}")); + if let Some(e) = open { + return Ok(e.text.clone()); + } + s.files.get(rel).or_else(|| s.files.get(path)).cloned().ok_or_else(|| Error::NotFound(path.into())) + }) + } + + pub fn invocation_project(_: InvocationRef<'_>) -> Option<ProjectRes> { + with(|s| s.root.clone().map(|root| MockProject { root })) + } + + pub fn invocation_editor(_: InvocationRef<'_>) -> Option<EditorRes> { + with(|s| s.editor.as_ref().map(|_| MockEditor)) + } + + fn editor<R>(f: impl FnOnce(&mut State, &mut Editor) -> R) -> R { + with(|s| { + let mut e = s.editor.take().expect("the editor was closed"); + let r = f(s, &mut e); + s.editor = Some(e); + r + }) + } + + pub fn editor_path(_: &EditorRes) -> String { + editor(|_, e| e.path.clone()) + } + + pub fn editor_language(_: &EditorRes) -> String { + editor(|_, e| e.language.clone()) + } + + pub fn editor_document(_: &EditorRes) -> Result<Document, Error> { + editor(|s, e| { + require(s, "editor.read")?; + Ok(Document { version: e.version, text: e.text.clone() }) + }) + } + + pub fn editor_selections(_: &EditorRes) -> Result<Vec<Range>, Error> { + editor(|s, e| { + require(s, "editor.read")?; + Ok(e.selections.clone()) + }) + } + + pub fn editor_apply(_: &EditorRes, version: u64, edits: &[TextEdit]) -> Result<u64, Error> { + editor(|s, e| { + require(s, "editor.write")?; + if version != e.version { + return Err(Error::Stale); + } + let mut spans = Vec::with_capacity(edits.len()); + for edit in edits { + let start = crate::text::offset(&e.text, edit.range.start); + let end = crate::text::offset(&e.text, edit.range.end); + match (start, end) { + (Some(a), Some(b)) if a <= b => spans.push((a, b, edit.text.as_str())), + _ => return Err(Error::Invalid("edit outside the document".into())), + } + } + spans.sort_by_key(|(a, b, _)| (*a, *b)); + if spans.windows(2).any(|w| w[0].1 > w[1].0) { + return Err(Error::Invalid("overlapping edits".into())); + } + for (a, b, text) in spans.into_iter().rev() { + e.text.replace_range(a..b, text); + } + if !edits.is_empty() { + e.version += 1; + } + Ok(e.version) + }) + } + + pub fn vcs_status(_: &ProjectRes) -> Result<Option<RepositoryStatus>, Error> { + with(|s| { + require(s, "vcs.read")?; + Ok(s.vcs.clone()) + }) + } + + pub fn vcs_fetch(_: &ProjectRes) -> Result<(), Error> { + with(|s| { + require(s, "vcs.write")?; + if let Some(e) = s.fetch_error.clone() { + return Err(e); + } + s.fetches += 1; + Ok(()) + }) + } + + pub fn subscribe(kind: EventKind) -> Result<(), Error> { + with(|s| { + let cap = match kind { + EventKind::ActionPerformed => Some("events.input"), + EventKind::Scheduled => None, + _ => Some("events"), + }; + if let Some(cap) = cap { + require(s, cap)?; + } + s.subscriptions.insert(kind); + Ok(()) + }) + } + + pub fn unsubscribe(kind: EventKind) { + with(|s| s.subscriptions.remove(&kind)); + } + + pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result<Vec<String>, Error> { + with(|s| { + require(s, "project.read")?; + let root = p.root.trim_end_matches('/'); + let mut out: Vec<String> = s + .files + .keys() + .map(|k| if k.starts_with('/') { k.clone() } else { format!("{root}/{k}") }) + .chain(s.editor.as_ref().map(|e| e.path.clone())) + .filter(|path| { + path.strip_prefix(root).map(|r| r.trim_start_matches('/')).is_some_and(|rel| glob_match(glob, rel)) + }) + .collect(); + out.sort(); + out.dedup(); + out.truncate(limit as usize); + Ok(out) + }) + } + + pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { + with(|s| { + require(s, "project.read")?; + if items.is_empty() { + s.diagnostics.remove(path); + } else { + s.diagnostics.insert(path.to_owned(), items.to_vec()); + } + Ok(()) + }) + } + + fn net_host(url: &str) -> Option<String> { + let rest = url.split_once("://")?.1; + let host = rest.split(['/', ':', '?']).next()?; + Some(host.to_ascii_lowercase()) + } + + pub fn fetch(req: &HttpRequest) -> Result<HttpResponse, Error> { + with(|s| { + let host = net_host(&req.url).ok_or_else(|| Error::Invalid(format!("{:?} is not a URL", req.url)))?; + let cap = format!("net:{host}"); + if !s.capabilities.iter().any(|c| *c == cap || c == "net:*") { + return Err(Error::Denied(cap)); + } + let mut sent = req.clone(); + for (_, value) in &mut sent.headers { + while let Some(i) = value.find("{{secret:") { + require(s, "secrets")?; + let end = value[i..].find("}}").ok_or_else(|| Error::Invalid("unterminated secret".into()))? + i; + let name = value[i + 9..end].to_owned(); + let secret = + s.secrets.get(&name).cloned().ok_or_else(|| Error::NotFound(format!("secret {name:?}")))?; + value.replace_range(i..end + 2, &secret); + } + } + s.requests.push(sent.clone()); + match &s.net { + Some(handler) => handler(&sent), + None => Err(Error::Failed("no network in the mock (MockHost::on_fetch)".into())), + } + }) + } + + pub fn secret_is_set(name: &str) -> bool { + with(|s| s.capabilities.iter().any(|c| c == "secrets") && s.secrets.contains_key(name)) + } + + pub fn refresh_view(view: &str) { + with(|s| s.refreshed.push(view.into())) + } + + pub fn post_view_message(view: &str, message: &str) -> Result<(), Error> { + with(|s| s.view_messages.push((view.into(), message.into()))); + Ok(()) + } + + pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { + with(|s| { + require(s, &format!("process:{program}"))?; + s.terminals.push(MockTerminal { title: title.into(), program: program.into(), args: args.to_vec() }); + Ok(()) + }) + } + + pub fn setting(key: &str) -> Option<String> { + with(|s| s.settings.get(key).cloned()) + } + + pub fn run_process( + program: &str, + args: &[String], + cwd: Option<&ProjectRes>, + stdin: Option<&str>, + ) -> Result<ProcessOutput, Error> { + with(|s| { + require(s, &format!("process:{program}"))?; + s.runs.push(ProcessRun { program: program.into(), args: args.to_vec(), cwd: cwd.map(|p| p.root.clone()) }); + match s.processes.get(program) { + Some(handler) => handler(args, stdin), + None => Err(Error::NotFound(format!("{program} (MockHost::on_process)"))), + } + }) + } + + pub fn begin_task(title: &str, _cancellable: bool) -> TaskRes { + with(|s| { + s.tasks.push(MockTask { title: title.into(), ..MockTask::default() }); + MockTaskRes { index: s.tasks.len() - 1 } + }) + } + + pub fn task_report(t: &TaskRes, done: u32, total: Option<u32>, message: Option<&str>) { + with(|s| s.tasks[t.index].reports.push((done, total, message.map(str::to_owned)))) + } + + pub fn task_cancelled(t: &TaskRes) -> bool { + with(|s| s.tasks[t.index].cancelled) + } +} + +/// `*` within a segment, `**` across segments, `?` one character. +fn glob_match(glob: &str, path: &str) -> bool { + fn go(p: &[u8], s: &[u8]) -> bool { + match p { + [] => s.is_empty(), + [b'*', b'*', b'/', rest @ ..] => go(rest, s) || (!s.is_empty() && go(p, &s[1..])), + [b'*', b'*', rest @ ..] => (0..=s.len()).any(|i| go(rest, &s[i..])), + [b'*', rest @ ..] => (0..=s.len()).take_while(|&i| i == 0 || s[i - 1] != b'/').any(|i| go(rest, &s[i..])), + [b'?', rest @ ..] => !s.is_empty() && s[0] != b'/' && go(rest, &s[1..]), + [c, rest @ ..] => s.first() == Some(c) && go(rest, &s[1..]), + } + } + go(glob.as_bytes(), path.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host; + + #[test] + fn records_and_enforces() { + let mock = MockHost::install(); + mock.add_file("src/a.txt", "hello"); + let ctx = mock.invocation(); + let project = ctx.project().unwrap(); + assert_eq!(project.read_file("/project/src/a.txt").unwrap(), "hello"); + assert_eq!(project.read_file("src/a.txt").unwrap(), "hello"); + assert!(matches!(project.read_file("../etc/passwd"), Err(Error::Invalid(_)))); + host::notify(NotificationLevel::Info, "t", "b"); + host::register_command("c", "C", None).unwrap(); + host::register_command("c", "C2", Some("Cat")).unwrap(); + assert_eq!(mock.notifications()[0].title, "t"); + assert_eq!(mock.commands(), [Command { id: "c".into(), title: "C2".into(), category: Some("Cat".into()) }]); + host::storage::set("k", "v").unwrap(); + assert_eq!(host::storage::get("k").unwrap().as_deref(), Some("v")); + assert_eq!(host::storage::keys("").unwrap(), ["k"]); + host::set_status_item("s", &StatusItem { text: "x".into(), tooltip: None, command: None }); + assert_eq!(mock.status_items()["s"].text, "x"); + { + let task = host::begin_task("Work", true); + task.report(1, Some(2), None); + mock.cancel_tasks(); + assert!(task.is_cancelled()); + } + assert!(mock.tasks()[0].ended); + } + + #[test] + fn versioned_edits() { + let mock = MockHost::install(); + mock.open_editor("/project/a.txt", "text", "one two", &[(0, 0, 0, 3), (0, 4, 0, 7)]); + let editor = mock.invocation().editor().unwrap(); + let v = editor.transform_selections(|s| s.to_uppercase()).unwrap(); + assert_eq!((mock.editor_text().as_str(), v), ("ONE TWO", 2)); + assert_eq!(editor.apply_edits(1, &[]), Err(Error::Stale)); + mock.type_in_editor("!"); + assert_eq!(mock.editor_version(), 3); + } + + #[test] + fn missing_capabilities_are_denied() { + let mock = MockHost::install().with_capabilities(&["project.read"]); + mock.open_editor("/project/a.txt", "text", "x", &[]); + let editor = mock.invocation().editor().unwrap(); + assert_eq!(editor.apply_edits(1, &[]), Err(Error::Denied("editor.write".into()))); + assert_eq!(host::storage::get("k"), Err(Error::Denied("storage".into()))); + assert_eq!(host::subscribe(EventKind::ActionPerformed), Err(Error::Denied("events.input".into()))); + } + + #[test] + fn network_secrets_processes_and_files() { + let mock = MockHost::install(); + mock.add_file("src/a/UserMapper.xml", "<mapper/>"); + mock.add_file("src/B.java", "class B {}"); + let project = mock.invocation().project().unwrap(); + assert_eq!(project.find_files("**/*Mapper.xml", 10).unwrap(), ["/project/src/a/UserMapper.xml"]); + assert!( + matches!(host::fetch(&HttpRequest::get("https://api.x.io/v1")), Err(Error::Denied(c)) if c == "net:api.x.io") + ); + + let mock = MockHost::install().with_capabilities(&["net:api.x.io", "secrets", "project.read", "process:java"]); + mock.set_secret("key", "s3cr3t"); + mock.on_fetch(|r| Ok(HttpResponse { status: 200, headers: vec![], body: r.headers[0].1.clone().into_bytes() })); + let r = host::fetch(&HttpRequest::get("https://api.x.io/v1").header("Authorization", "Bearer {{secret:key}}")) + .unwrap(); + assert_eq!(r.text(), "Bearer s3cr3t"); + assert!(host::secret_is_set("key")); + mock.on_process("java", |args, _| { + Ok(ProcessOutput { status: Some(0), stdout: args.join(" "), stderr: String::new() }) + }); + let out = mock.invocation().project().unwrap().run_process("java", &["-version".into()], None).unwrap(); + assert_eq!(out.stdout, "-version"); + assert_eq!(mock.process_runs()[0].cwd.as_deref(), Some("/project")); + host::publish_diagnostics( + "/project/a.java", + &[Diagnostic { range: Range::default(), severity: Severity::Warning, message: "m".into(), code: None }], + ) + .unwrap(); + assert_eq!(mock.diagnostics()["/project/a.java"].len(), 1); + } +} diff --git a/sdk/rust/openidea-plugin/src/providers.rs b/sdk/rust/openidea-plugin/src/providers.rs new file mode 100644 index 000000000..52a0d7446 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/providers.rs @@ -0,0 +1,123 @@ +//! Editor providers: implement [`Providers`] and register with +//! `register_plugin!(T, providers)`, then declare the kinds and languages in +//! `plugin.toml`: +//! +//! ```toml +//! [[contributes.providers]] +//! kinds = ["completion", "definition"] # also inline-completion, code-actions, line-markers +//! languages = ["xml"] +//! ``` +//! +//! They run on the editing path: answer within ~50 ms (later results are +//! dropped; at 500 ms the call is interrupted). Inline completion may take +//! longer (it may use the network) but is cancelled when the user types on. + +use crate::types::{Location, Position, Range, TextEdit}; + +/// The document a request is about. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Document { + pub path: String, + pub language: String, + pub version: u64, + pub text: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompletionKind { + Text, + Method, + Function, + Constructor, + Field, + Variable, + Class, + Interface, + Module, + Property, + Keyword, + Snippet, + Value, + Constant, + File, + Reference, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CompletionItem { + pub label: String, + pub detail: Option<String>, + pub kind: Option<CompletionKind>, + pub insert_text: String, + /// Replaced on accept; `None`: the word at the caret. + pub replace: Option<Range>, + pub sort_text: Option<String>, + /// Markdown. + pub documentation: Option<String>, +} + +impl CompletionItem { + pub fn new(label: &str, kind: CompletionKind) -> Self { + Self { + label: label.into(), + detail: None, + kind: Some(kind), + insert_text: label.into(), + replace: None, + sort_text: None, + documentation: None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineSuggestion { + pub insert_text: String, + /// Replaced range; `None` inserts at the caret. + pub replace: Option<Range>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CodeAction { + pub title: String, + /// `quickfix`, `refactor`, … + pub kind: Option<String>, + pub preferred: bool, + /// (absolute path, edits) per file. + pub edits: Vec<(String, Vec<TextEdit>)>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineMarker { + /// 0-based. + pub line: u32, + /// `navigate` (default), `implemented`, `implementing`, `run`. + pub icon: Option<String>, + pub tooltip: String, + /// Opened on click. + pub target: Option<Location>, +} + +/// What a plugin provides in the editor. Only the kinds declared in +/// `plugin.toml` are ever called; the defaults return nothing. +pub trait Providers { + fn completion(&mut self, _doc: &Document, _at: Position, _trigger: Option<&str>) -> Vec<CompletionItem> { + Vec::new() + } + + fn inline_completion(&mut self, _doc: &Document, _at: Position) -> Option<InlineSuggestion> { + None + } + + fn code_actions(&mut self, _doc: &Document, _selection: Range) -> Vec<CodeAction> { + Vec::new() + } + + fn definition(&mut self, _doc: &Document, _at: Position) -> Vec<Location> { + Vec::new() + } + + fn line_markers(&mut self, _doc: &Document) -> Vec<LineMarker> { + Vec::new() + } +} diff --git a/sdk/rust/openidea-plugin/src/syntax.rs b/sdk/rust/openidea-plugin/src/syntax.rs new file mode 100644 index 000000000..b7b8516d5 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/syntax.rs @@ -0,0 +1,224 @@ +//! Syntax for the plugin's own languages (highlighting, structure view, +//! folding) when the IDE has no grammar for them. Implement [`SyntaxProvider`] +//! and register with `register_plugin!(MyPlugin, syntax)`. +//! +//! The IDE calls these with the whole document on the editing path, so keep +//! them fast (a hand-written lexer is typical). Offsets are UTF-8 byte offsets +//! into `text`; lines are 0-based. + +use std::ops::Range; + +/// Highlight categories; the theme decides the colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Highlight { + Keyword, + String, + StringSpecial, + Escape, + Regexp, + Atom, + Number, + Constant, + Comment, + DocComment, + Function, + FunctionCall, + Builtin, + Type, + Namespace, + Variable, + SelfVariable, + Property, + Attribute, + Macro, + Label, + Tag, + Operator, + Punctuation, +} + +impl Highlight { + pub fn name(self) -> &'static str { + match self { + Self::Keyword => "keyword", + Self::String => "string", + Self::StringSpecial => "string-special", + Self::Escape => "escape", + Self::Regexp => "regexp", + Self::Atom => "atom", + Self::Number => "number", + Self::Constant => "constant", + Self::Comment => "comment", + Self::DocComment => "doc-comment", + Self::Function => "function", + Self::FunctionCall => "function-call", + Self::Builtin => "builtin", + Self::Type => "type", + Self::Namespace => "namespace", + Self::Variable => "variable", + Self::SelfVariable => "self-variable", + Self::Property => "property", + Self::Attribute => "attribute", + Self::Macro => "macro", + Self::Label => "label", + Self::Tag => "tag", + Self::Operator => "operator", + Self::Punctuation => "punctuation", + } + } +} + +/// A highlighted range. Tokens must not overlap; the IDE sorts them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Token { + pub range: Range<usize>, + pub kind: Highlight, +} + +/// Kinds of structure-view symbols. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SymbolKind { + Module, + Namespace, + Package, + Class, + Interface, + Struct, + Enum, + EnumMember, + Trait, + Function, + Method, + Constructor, + Field, + Property, + Variable, + Constant, + TypeParameter, + TypeAlias, + Macro, + Event, + Operator, + Key, +} + +impl SymbolKind { + pub fn name(self) -> &'static str { + match self { + Self::Module => "module", + Self::Namespace => "namespace", + Self::Package => "package", + Self::Class => "class", + Self::Interface => "interface", + Self::Struct => "struct", + Self::Enum => "enum", + Self::EnumMember => "enum-member", + Self::Trait => "trait", + Self::Function => "function", + Self::Method => "method", + Self::Constructor => "constructor", + Self::Field => "field", + Self::Property => "property", + Self::Variable => "variable", + Self::Constant => "constant", + Self::TypeParameter => "type-parameter", + Self::TypeAlias => "type-alias", + Self::Macro => "macro", + Self::Event => "event", + Self::Operator => "operator", + Self::Key => "key", + } + } +} + +/// A symbol of the structure view. `parent` is the index of the enclosing +/// symbol in the same list, which must come before it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Symbol { + pub name: String, + pub detail: Option<String>, + pub kind: SymbolKind, + /// The whole declaration. + pub range: Range<usize>, + /// The name; where navigation lands. + pub name_range: Range<usize>, + pub parent: Option<usize>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FoldKind { + Region, + Comment, + Imports, +} + +/// A foldable range of lines (the first stays visible). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Fold { + pub start_line: u32, + pub end_line: u32, + pub kind: FoldKind, +} + +/// Syntax of the languages the plugin contributes. `language` is the +/// language id from the plugin's definition. +pub trait SyntaxProvider { + fn highlights(&mut self, language: &str, text: &str) -> Vec<Token>; + + fn outline(&mut self, _language: &str, _text: &str) -> Vec<Symbol> { + Vec::new() + } + + fn folding(&mut self, _language: &str, _text: &str) -> Vec<Fold> { + Vec::new() + } +} + +/// Folds for every multi-line `open` … `close` pair (braces, brackets), +/// skipping the ones inside the given non-code ranges (strings, comments). +pub fn bracket_folds(text: &str, open: u8, close: u8, skip: &[Range<usize>]) -> Vec<Fold> { + let mut folds = Vec::new(); + let mut stack: Vec<u32> = Vec::new(); + let mut line = 0u32; + let mut skip = skip.iter().peekable(); + for (i, b) in text.bytes().enumerate() { + while skip.peek().is_some_and(|r| r.end <= i) { + skip.next(); + } + if b == b'\n' { + line += 1; + continue; + } + if skip.peek().is_some_and(|r| r.start <= i) { + continue; + } + if b == open { + stack.push(line); + } else if b == close + && let Some(start) = stack.pop() + && line > start + { + folds.push(Fold { start_line: start, end_line: line, kind: FoldKind::Region }); + } + } + folds +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bracket_folds_skip_strings() { + let text = "a {\n \"{\"\n b {\n }\n}\n"; + let s = text.find('"').unwrap(); + let folds = bracket_folds(text, b'{', b'}', &[s..s + 3]); + assert_eq!( + folds, + [ + Fold { start_line: 2, end_line: 3, kind: FoldKind::Region }, + Fold { start_line: 0, end_line: 4, kind: FoldKind::Region }, + ] + ); + } +} diff --git a/sdk/rust/openidea-plugin/src/text.rs b/sdk/rust/openidea-plugin/src/text.rs new file mode 100644 index 000000000..2ba0f7afc --- /dev/null +++ b/sdk/rust/openidea-plugin/src/text.rs @@ -0,0 +1,82 @@ +//! Helpers for the editor's coordinates (0-based lines, UTF-16 columns). + +use crate::types::Position; + +/// Byte offset of `pos` in `text`, if it exists (a column past the end of +/// the line is clamped to it). +pub fn offset(text: &str, pos: Position) -> Option<usize> { + let mut line_start = 0; + for _ in 0..pos.line { + line_start += text[line_start..].find('\n')? + 1; + } + let line_end = text[line_start..].find('\n').map_or(text.len(), |i| line_start + i); + let mut units = 0u32; + for (i, ch) in text[line_start..line_end].char_indices() { + if units >= pos.character { + return Some(line_start + i); + } + units += ch.len_utf16() as u32; + } + Some(line_end) +} + +/// Position of the byte offset `at` in `text` (clamped to the text). +pub fn position(text: &str, at: usize) -> Position { + let at = at.min(text.len()); + let before = &text[..at]; + let line = before.matches('\n').count() as u32; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + let character = text[line_start..at].chars().map(|c| c.len_utf16() as u32).sum(); + Position { line, character } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_utf16_columns() { + let text = "ab\n😀x\nlast"; + for at in [0, 1, 2, 3, 7, 8, 9, text.len()] { + assert_eq!(offset(text, position(text, at)), Some(at), "{at}"); + } + assert_eq!(position(text, 7), Position::new(1, 2)); + assert_eq!(offset(text, Position::new(0, 99)), Some(2), "clamped to the line"); + assert_eq!(offset(text, Position::new(5, 0)), None); + } +} + +/// The text of a JSON string literal's contents (`\"`, `\\`, `\n`, `\uXXXX`). +pub fn unescape_json(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('r') => out.push('\r'), + Some('b') => out.push('\u{8}'), + Some('f') => out.push('\u{c}'), + Some('u') => { + let hex: String = chars.by_ref().take(4).collect(); + let mut code = u32::from_str_radix(&hex, 16).unwrap_or(0xfffd); + // A surrogate pair: `\ud83d\ude00`. + if (0xd800..0xdc00).contains(&code) { + let rest: String = chars.clone().take(6).collect(); + if let Some(low) = rest.strip_prefix("\\u").and_then(|h| u32::from_str_radix(h, 16).ok()) { + chars.by_ref().nth(5); + code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00); + } + } + out.push(char::from_u32(code).unwrap_or('\u{fffd}')); + } + Some(other) => out.push(other), + None => {} + } + } + out +} diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs new file mode 100644 index 000000000..34237c474 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -0,0 +1,265 @@ +//! Plain data types of the API (independent of the target). + +/// Why a host call failed. A capability that is not granted is `Denied`: +/// degrade gracefully (show what you can) instead of failing the command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Error { + /// Not granted; names the capability (`editor.write`). + Denied(String), + /// The document changed since the version the edit was made for. + Stale, + NotFound(String), + Invalid(String), + Cancelled, + Failed(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Denied(c) => write!(f, "permission denied: {c} is not granted"), + Self::Stale => f.write_str("the document changed in the meantime"), + Self::NotFound(m) => write!(f, "not found: {m}"), + Self::Invalid(m) => write!(f, "invalid: {m}"), + Self::Cancelled => f.write_str("cancelled"), + Self::Failed(m) => f.write_str(m), + } + } +} + +impl std::error::Error for Error {} + +impl From<Error> for String { + fn from(e: Error) -> Self { + e.to_string() + } +} + +/// Log level of [`crate::host::log`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +/// Severity of [`crate::host::notify`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NotificationLevel { + Info, + Warning, + Error, +} + +/// 0-based line and UTF-16 code unit column (as in the editor and LSP). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Position { + pub line: u32, + pub character: u32, +} + +impl Position { + pub const fn new(line: u32, character: u32) -> Self { + Self { line, character } + } +} + +/// Half-open range; a cursor is an empty range. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Range { + pub start: Position, + pub end: Position, +} + +impl Range { + pub const fn new(start: Position, end: Position) -> Self { + Self { start, end } + } + + pub fn is_empty(&self) -> bool { + self.start == self.end + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextEdit { + pub range: Range, + pub text: String, +} + +/// An editor's document at one version. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Document { + pub version: u64, + pub text: String, +} + +impl Document { + /// The text of `range` (UTF-16 columns), if it lies inside the document. + pub fn text_in(&self, range: Range) -> Option<&str> { + let start = crate::text::offset(&self.text, range.start)?; + let end = crate::text::offset(&self.text, range.end)?; + self.text.get(start.min(end)..end.max(start)) + } +} + +/// What `activate` receives. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Context { + pub plugin_id: String, + pub plugin_version: String, + /// Plugin API version the host implements. + pub api_version: String, + /// Capabilities granted to the plugin (`project.read`, …). + pub capabilities: Vec<String>, +} + +impl Context { + pub fn has(&self, capability: &str) -> bool { + self.capabilities.iter().any(|c| c == capability) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StatusItem { + pub text: String, + pub tooltip: Option<String>, + /// One of the plugin's commands, run on click. + pub command: Option<String>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum EventKind { + FileOpened, + FileSaved, + EditorFocused, + /// Needs `events.input`. + ActionPerformed, + VcsChanged, + Scheduled, +} + +/// How the user started an action. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActionSource { + Keyboard, + Menu, + Toolbar, + ContextMenu, + Search, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActionPerformed { + /// Action id (`editor.duplicate-line`). + pub action: String, + pub title: String, + pub source: ActionSource, + /// Its keyboard shortcut, if it has one. + pub shortcut: Option<String>, +} + +/// A workbench event (see [`crate::host::subscribe`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + FileOpened(String), + FileSaved(String), + EditorFocused(Option<String>), + ActionPerformed(ActionPerformed), + /// A repository (its root) changed. + VcsChanged(String), + /// The `on-schedule` period elapsed. + Scheduled, +} + +/// The repository containing a project. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RepositoryStatus { + pub root: String, + /// `None` when HEAD is detached. + pub branch: Option<String>, + pub commit: Option<String>, + pub upstream: Option<String>, + pub ahead: u32, + pub behind: u32, + /// Merge / rebase / cherry-pick in progress. + pub operation: Option<String>, + pub changed_files: u32, +} + +/// A range in a file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Location { + /// Absolute path. + pub path: String, + pub range: Range, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Severity { + Error, + Warning, + Info, + Hint, +} + +/// A problem in a file (see [`crate::host::publish_diagnostics`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Diagnostic { + pub range: Range, + pub severity: Severity, + pub message: String, + /// Rule or check id. + pub code: Option<String>, +} + +/// An HTTP request (see [`crate::host::fetch`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HttpRequest { + pub method: String, + pub url: String, + /// `{{secret:<name>}}` in a value is filled in by the IDE. + pub headers: Vec<(String, String)>, + pub body: Option<Vec<u8>>, + pub timeout_ms: Option<u32>, +} + +impl HttpRequest { + pub fn get(url: &str) -> Self { + Self { method: "GET".into(), url: url.into(), ..Self::default() } + } + + pub fn post(url: &str, body: impl Into<Vec<u8>>) -> Self { + Self { method: "POST".into(), url: url.into(), body: Some(body.into()), ..Self::default() } + } + + pub fn header(mut self, name: &str, value: &str) -> Self { + self.headers.push((name.into(), value.into())); + self + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HttpResponse { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec<u8>, +} + +impl HttpResponse { + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } +} + +/// What a process printed (see [`crate::host::run_process`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProcessOutput { + /// `None` when killed by a signal. + pub status: Option<i32>, + pub stdout: String, + pub stderr: String, +} diff --git a/sdk/rust/openidea-plugin/src/views.rs b/sdk/rust/openidea-plugin/src/views.rs new file mode 100644 index 000000000..3da8e506b --- /dev/null +++ b/sdk/rust/openidea-plugin/src/views.rs @@ -0,0 +1,225 @@ +//! Views and custom editors (`[[contributes.views]]`, +//! `[[contributes.customEditors]]`): implement [`Views`] and register with +//! `register_plugin!(MyPlugin, views)`. +//! +//! A view is data — a toolbar, a body (tree, list, form, table, message or +//! sandboxed HTML) and a status line. The IDE renders it and calls +//! [`Views::handle`] with what the user did; call [`crate::host::refresh_view`] +//! when the content changes. For a custom editor, `ctx.editor()` is its +//! document: read it with `document()` and change it with `apply_edits`, so +//! the text editor of the same file follows and undo is shared. + +use crate::{Error, Invocation}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Button { + pub id: String, + pub label: String, + /// An IDE icon (`refresh`, `add`, `remove`, `run`, `stop`, `settings`…). + pub icon: Option<String>, + pub enabled: bool, +} + +impl Button { + pub fn new(id: &str, label: &str) -> Self { + Self { id: id.into(), label: label.into(), icon: None, enabled: true } + } + + pub fn icon(mut self, icon: &str) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } +} + +/// A tree node; `parent` is the key of an item listed before it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TreeItem { + pub key: String, + pub parent: Option<String>, + pub label: String, + pub icon: Option<String>, + pub detail: Option<String>, + /// Shows an expander before the children are known ([`ViewEvent::Expand`]). + pub expandable: bool, + pub expanded: bool, +} + +impl TreeItem { + pub fn new(key: &str, label: &str) -> Self { + Self { + key: key.into(), + parent: None, + label: label.into(), + icon: None, + detail: None, + expandable: false, + expanded: false, + } + } + + pub fn parent(mut self, parent: &str) -> Self { + self.parent = Some(parent.into()); + self + } + + pub fn icon(mut self, icon: &str) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn detail(mut self, detail: &str) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Has (or may have) children; `expanded` shows them. + pub fn expandable(mut self, expanded: bool) -> Self { + self.expandable = true; + self.expanded = expanded; + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ListItem { + pub key: String, + pub label: String, + pub icon: Option<String>, + pub detail: Option<String>, +} + +impl ListItem { + pub fn new(key: &str, label: &str) -> Self { + Self { key: key.into(), label: label.into(), icon: None, detail: None } + } + + pub fn icon(mut self, icon: &str) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn detail(mut self, detail: &str) -> Self { + self.detail = Some(detail.into()); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FieldValue { + Text(String), + Multiline(String), + Checkbox(bool), + /// Options and the chosen one. + Choice(Vec<String>, String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Field { + pub key: String, + pub label: String, + pub value: FieldValue, +} + +impl Field { + pub fn new(key: &str, label: &str, value: FieldValue) -> Self { + Self { key: key.into(), label: label.into(), value } + } +} + +/// Rows `first_row..first_row + rows.len()` of `total_rows`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Table { + pub columns: Vec<String>, + pub total_rows: u32, + pub first_row: u32, + pub rows: Vec<Vec<String>>, + pub editable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Body { + Message(String), + Tree(Vec<TreeItem>), + List(Vec<ListItem>), + Form(Vec<Field>), + Table(Table), + /// Shown in a sandboxed frame; its script talks to the plugin with + /// `openidea.postMessage(text)` and `openidea.onMessage(fn)`. + Html(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct View { + pub toolbar: Vec<Button>, + pub body: Body, + pub status: Option<String>, +} + +impl View { + pub fn new(body: Body) -> Self { + Self { toolbar: Vec::new(), body, status: None } + } + + pub fn message(text: &str) -> Self { + Self::new(Body::Message(text.into())) + } + + pub fn button(mut self, b: Button) -> Self { + self.toolbar.push(b); + self + } + + pub fn status(mut self, status: &str) -> Self { + self.status = Some(status.into()); + self + } +} + +/// The table rows the IDE shows (ask for no more than these). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Viewport { + pub first_row: u32, + pub rows: u32, +} + +impl Viewport { + /// `first_row..end` clamped to `total`. + pub fn range(viewport: Option<Viewport>, total: u32, default_rows: u32) -> std::ops::Range<u32> { + let v = viewport.unwrap_or(Viewport { first_row: 0, rows: default_rows }); + let start = v.first_row.min(total); + start..start.saturating_add(v.rows).min(total) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ViewEvent { + Button(String), + Activate(String), + Select(String), + Expand(String), + Collapse(String), + /// A form field: key and new value (`true`/`false` for checkboxes). + Field(String, String), + Cell { + row: u32, + column: u32, + value: String, + }, + InsertRow(u32), + DeleteRows(Vec<u32>), + Message(String), +} + +/// Content of the plugin's declared views and custom editors. +pub trait Views { + fn render(&mut self, view: &str, viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error>; + + fn handle(&mut self, _view: &str, _event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + Ok(()) + } +} diff --git a/sdk/rust/openidea-plugin/wit/events.wit b/sdk/rust/openidea-plugin/wit/events.wit new file mode 100644 index 000000000..8470c298c --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/events.wit @@ -0,0 +1,68 @@ +package openidea:plugin@1.0.0; + +/// Workbench events, delivered in batches to `event-handler.handle`. +interface events { + use types.{error}; + + enum event-kind { + file-opened, + file-saved, + /// The focused editor changed. + editor-focused, + /// An IDE action ran. Requires `events.input`. + action-performed, + /// A repository's state changed (commit, checkout, fetch, …). + vcs-changed, + /// The plugin's `on-schedule` period elapsed. + scheduled, + } + + /// How the user started an action. + enum action-source { + keyboard, + menu, + toolbar, + context-menu, + search, + other, + } + + record action-performed { + /// Action id (`editor.duplicate-line`). + action: string, + title: string, + source: action-source, + /// Its keyboard shortcut, when it has one (`Ctrl+D`). + shortcut: option<string>, + } + + variant event { + file-opened(string), + file-saved(string), + editor-focused(option<string>), + action-performed(action-performed), + vcs-changed(string), + scheduled, + } + + /// Delivers `kind` events from now on, for the rest of the session (also + /// across idle reclaim: an event activates the plugin again). Declare + /// `activation = ["on-event:<kind>"]` to get them before first use. + /// Requires `events` (`events.input` for `action-performed`). + subscribe: func(kind: event-kind) -> result<_, error>; + unsubscribe: func(kind: event-kind); +} + +/// Long-running work with a progress indicator. While a task is open, the +/// call that began it may run for the background budget (60 s) instead of 5 s. +interface tasks { + resource task { + /// `done` of `total` units (`none`: indeterminate), with a short status. + report: func(done: u32, total: option<u32>, message: option<string>); + /// The user cancelled it: stop soon and return. + is-cancelled: func() -> bool; + } + + /// Starts a task shown in the status bar; dropping it ends it. + begin: func(title: string, cancellable: bool) -> task; +} diff --git a/sdk/rust/openidea-plugin/wit/exports.wit b/sdk/rust/openidea-plugin/wit/exports.wit new file mode 100644 index 000000000..f907ff472 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/exports.wit @@ -0,0 +1,70 @@ +package openidea:plugin@1.0.0; + +interface lifecycle { + use types.{context}; + + /// Called once after the instance is created. Register runtime + /// contributions and subscriptions here; restore state from `storage`. + activate: func(ctx: context) -> result<_, string>; + /// Called before the instance is dropped (disable, uninstall, idle reclaim). + deactivate: func(); +} + +interface commands { + use invocation.{invocation}; + + /// Runs one of the plugin's commands. Returns an optional message. + run: func(id: string, ctx: borrow<invocation>, arg: option<string>) -> result<option<string>, string>; +} + +interface event-handler { + use events.{event}; + use invocation.{invocation}; + + /// A batch of subscribed events, oldest first, from one window (`ctx`). + handle: func(events: list<event>, ctx: borrow<invocation>); +} + +/// Optional export (world `plugin-syntax`): syntax of the plugin's own +/// languages, for languages the IDE has no grammar for. The IDE calls it +/// with the whole document; results are flat. Offsets are UTF-8 byte offsets +/// into `text` (the IDE converts them); lines are 0-based. +interface syntax-provider { + /// One highlighted range. `kind` is a highlight category: keyword, string, + /// string-special, escape, regexp, atom, number, constant, comment, + /// doc-comment, function, function-call, builtin, type, namespace, + /// variable, self-variable, property, attribute, macro, label, tag, + /// operator, punctuation. Unknown kinds are ignored; ranges must be + /// sorted and not overlap. + record token { + start: u32, + end: u32, + kind: string, + } + + /// A symbol of the structure view. `parent` is the index of the enclosing + /// symbol in the same list (which must come first). + record symbol { + name: string, + detail: option<string>, + /// class, interface, struct, enum, function, method, constructor, + /// field, property, variable, constant, module, namespace, event, … + kind: string, + start: u32, + end: u32, + name-start: u32, + name-end: u32, + parent: option<u32>, + } + + /// A foldable line range. `kind`: region, comment or imports. + record fold { + start-line: u32, + end-line: u32, + kind: string, + } + + highlights: func(language: string, text: string) -> list<token>; + outline: func(language: string, text: string) -> list<symbol>; + folding: func(language: string, text: string) -> list<fold>; +} diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit new file mode 100644 index 000000000..750bfd342 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -0,0 +1,51 @@ +package openidea:plugin@1.0.0; + +/// Writes to the IDE log, tagged with the plugin id. +interface log { + use types.{log-level}; + + log: func(level: log-level, message: string); +} + +/// Contributions made at run time (declare static ones in plugin.toml). +interface contributions { + use types.{error}; + + /// Registers (or updates) a command `plugin.<plugin-id>.<id>`. + /// `id` must match `[a-z0-9][a-z0-9._-]*`. + register-command: func(id: string, title: string, category: option<string>) -> result<_, error>; + /// A language from a `languages/<id>.toml`-style definition. + register-language: func(definition: string) -> result<_, error>; + /// A theme from JSON: {"id","name","kind":"dark"|"light","tokens":{"--var":"value"}}. + register-theme: func(definition: string) -> result<_, error>; +} + +interface ui { + use types.{notification-level}; + + record status-item { + text: string, + tooltip: option<string>, + /// One of the plugin's commands, run when the item is clicked. + command: option<string>, + } + + /// Shows a notification balloon in the window the current command or + /// event came from (every window when there is none). + show-notification: func(level: notification-level, title: string, body: string); + /// Shows or updates the plugin's status bar item `id` in every window. + set-status-item: func(id: string, item: status-item); + remove-status-item: func(id: string); +} + +/// Persistent key-value storage of the plugin (survives restarts and idle +/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB. +interface storage { + use types.{error}; + + get: func(key: string) -> result<option<string>, error>; + set: func(key: string, value: string) -> result<_, error>; + delete: func(key: string) -> result<_, error>; + /// Keys starting with `prefix`, sorted. + keys: func(prefix: string) -> result<list<string>, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/io.wit b/sdk/rust/openidea-plugin/wit/io.wit new file mode 100644 index 000000000..a1aeb8490 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/io.wit @@ -0,0 +1,85 @@ +package openidea:plugin@1.0.0; + +/// Problems in files, shown in the editor and the Problems view. +interface diagnostics { + use types.{error, range}; + + enum severity { + error, + warning, + info, + hint, + } + + record diagnostic { + range: range, + severity: severity, + message: string, + /// Rule or check id (`LineLength`). + code: option<string>, + } + + /// Replaces the plugin's diagnostics for the file at `path` (absolute, in + /// an open project); an empty list clears them. Requires `project.read`. + publish: func(path: string, diagnostics: list<diagnostic>) -> result<_, error>; +} + +/// HTTP(S) to the hosts granted with `net:<host>`. +interface net { + use types.{error}; + + record header { + name: string, + value: string, + } + + record request { + method: string, + /// `https://…`; plain `http://` only to `localhost` / `127.0.0.1`. + url: string, + /// `{{secret:<name>}}` in a value is replaced by the plugin's secret + /// `name` on the way out (requires `secrets`); the plugin never sees it. + headers: list<header>, + body: option<list<u8>>, + /// Default 30 s; the call's own budget applies too. + timeout-ms: option<u32>, + } + + record response { + status: u16, + headers: list<header>, + /// At most 8 MiB. + body: list<u8>, + } + + /// Fails with `denied` for a host the plugin may not reach, `cancelled` + /// when a newer request replaced the call or the user cancelled. + fetch: func(req: request) -> result<response, error>; +} + +/// Secrets the user stored for the plugin (Settings › Plugins, kept in the +/// OS keychain). Plugins only use them through `{{secret:<name>}}` in +/// `net` requests. Requires `secrets`. +interface secrets { + is-set: func(name: string) -> bool; +} + +/// One-off runs of programs granted with `process:<program>` (the file the +/// user approved, checked before every run). +interface process { + use types.{error}; + use workspace.{project}; + + record output { + /// `none` when killed by a signal. + status: option<s32>, + /// At most 4 MiB each (the rest is dropped). + stdout: string, + stderr: string, + } + + /// Runs `program` with `args` in `cwd`'s root (else the plugin's data + /// directory) and waits; cancelled with the task or call. Call it inside + /// a task for anything slow. + run: func(program: string, args: list<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit new file mode 100644 index 000000000..84b20a900 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -0,0 +1,68 @@ +/// OpenIDEA plugin API, version 1.0 (docs/plugin_plan.md 4.3). Frozen: 1.x +/// only adds (new interfaces, new optional exports); a 2.0 keeps 1.0 running +/// through an adapter in the host. +/// +/// A plugin is a WebAssembly component targeting the `plugin` world (or +/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment +/// or processes beyond WASI's clocks and randomness; everything else goes +/// through the imports below, gated by the capabilities granted in +/// `plugin.toml` (a missing one returns `error::denied`). +package openidea:plugin@1.0.0; + +world plugin { + import log; + import contributions; + import ui; + import storage; + import workspace; + import editor; + import invocation; + import vcs; + import events; + import tasks; + import diagnostics; + import net; + import secrets; + import process; + import views; + import settings; + + export lifecycle; + export commands; + export event-handler; +} + +/// A plugin that also provides syntax for its languages. +world plugin-syntax { + include plugin; + export syntax-provider; +} + +/// A plugin with editor providers (`[[contributes.providers]]`). +world plugin-providers { + include plugin; + export providers; +} + +/// A plugin with views or custom editors (`[[contributes.views]]`, +/// `[[contributes.custom-editors]]`). +world plugin-views { + include plugin; + export view-provider; +} + +/// Only the `syntax-provider` export: guests generate it next to `plugin` +/// (the component tools merge both into one component). +world syntax { + export syntax-provider; +} + +/// Only the `providers` export, generated next to `plugin` like `syntax`. +world editor-providers { + export providers; +} + +/// Only the `view-provider` export, generated next to `plugin` like `syntax`. +world views-provider { + export view-provider; +} diff --git a/sdk/rust/openidea-plugin/wit/providers.wit b/sdk/rust/openidea-plugin/wit/providers.wit new file mode 100644 index 000000000..af3f86eb4 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/providers.wit @@ -0,0 +1,83 @@ +package openidea:plugin@1.0.0; + +/// Editor features a plugin provides for the languages it declares under +/// `[[contributes.providers]]` (the host calls only declared kinds). They run +/// on the editing path: a result later than ~50 ms is dropped, a call still +/// running at 500 ms is interrupted. +interface providers { + use types.{position, range, text-edit, location}; + + /// The document a request is about. + record document { + path: string, + language: string, + version: u64, + text: string, + } + + enum completion-kind { + text, + method, + function, + %constructor, + field, + variable, + class, + %interface, + module, + property, + keyword, + snippet, + value, + constant, + file, + reference, + } + + record completion-item { + label: string, + detail: option<string>, + kind: option<completion-kind>, + insert-text: string, + /// Replaced on accept; `none`: the word at the caret. + replace: option<range>, + sort-text: option<string>, + documentation: option<string>, + } + + record inline-suggestion { + insert-text: string, + /// Replaced range; `none` inserts at the caret. + replace: option<range>, + } + + record file-edit { + path: string, + edits: list<text-edit>, + } + + record code-action { + title: string, + /// `quickfix`, `refactor`, … + kind: option<string>, + preferred: bool, + edits: list<file-edit>, + } + + record line-marker { + line: u32, + /// Gutter icon: `navigate` (default), `implemented`, `implementing`, `run`. + icon: option<string>, + tooltip: string, + /// Opened on click. + target: option<location>, + } + + completion: func(doc: document, at: position, trigger: option<string>) -> list<completion-item>; + /// Ghost text at the caret (typing replaces an older request: it is cancelled). + inline-completion: func(doc: document, at: position) -> option<inline-suggestion>; + /// Actions for `selection` (quick fixes for the plugin's own diagnostics). + code-actions: func(doc: document, selection: range) -> list<code-action>; + definition: func(doc: document, at: position) -> list<location>; + line-markers: func(doc: document) -> list<line-marker>; +} diff --git a/sdk/rust/openidea-plugin/wit/types.wit b/sdk/rust/openidea-plugin/wit/types.wit new file mode 100644 index 000000000..5c4f237e1 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/types.wit @@ -0,0 +1,64 @@ +package openidea:plugin@1.0.0; + +/// Shared types. Positions are 0-based lines and UTF-16 code unit columns, +/// as in the editor and LSP. +interface types { + /// Why a host call failed. A capability that is not granted is reported + /// as `denied`, so the plugin can degrade (0.1 trapped instead). + variant error { + /// Not granted; the payload names the capability (`editor.write`). + denied(string), + /// The document changed since the version the edit was made for. + stale, + not-found(string), + invalid(string), + /// Cancelled by the user, or the plugin is being stopped. + cancelled, + failed(string), + } + + enum log-level { + trace, + debug, + info, + warn, + error, + } + + enum notification-level { + info, + warning, + error, + } + + record position { + line: u32, + character: u32, + } + + record range { + start: position, + end: position, + } + + record text-edit { + range: range, + text: string, + } + + /// A range in a file (absolute path). + record location { + path: string, + range: range, + } + + /// Passed to `lifecycle.activate`. + record context { + plugin-id: string, + plugin-version: string, + /// Version of this API the host implements, e.g. "1.0". + api-version: string, + /// Capabilities granted to the plugin (`editor.write`, `process:git`, …). + capabilities: list<string>, + } +} diff --git a/sdk/rust/openidea-plugin/wit/views.wit b/sdk/rust/openidea-plugin/wit/views.wit new file mode 100644 index 000000000..1dfbd1357 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/views.wit @@ -0,0 +1,146 @@ +package openidea:plugin@1.0.0; + +/// Declarative views (docs/plugin_plan.md 4.2, L2): tool windows declared +/// under `[[contributes.views]]` and custom editors under +/// `[[contributes.customEditors]]`. The plugin describes what to show; the +/// IDE renders it with its own components (theme, keyboard, accessibility) +/// and sends the user's actions back as `view-event`s. Nothing of the plugin +/// runs in the WebView, except a `html` body, which is shown in a sandboxed +/// frame that can only exchange messages with the plugin. +interface view-types { + record button { + id: string, + label: string, + /// An IDE icon name (`refresh`, `add`, `remove`, `run`, …). + icon: option<string>, + enabled: bool, + } + + /// A tree node; `parent` is the key of an earlier item (none: a root). + record tree-item { + key: string, + parent: option<string>, + label: string, + icon: option<string>, + /// Grey text after the label. + detail: option<string>, + /// Shows an expander even before its children are known + /// (`expand` asks for them). + expandable: bool, + expanded: bool, + } + + record list-item { + key: string, + label: string, + icon: option<string>, + detail: option<string>, + } + + variant field-value { + text(string), + multiline(string), + checkbox(bool), + /// The options and the chosen one. + choice(tuple<list<string>, string>), + } + + record field { + key: string, + label: string, + value: field-value, + } + + /// Rows `first-row`..`first-row + len(rows)` of `total-rows`: the IDE + /// asks for the rows it shows (`viewport`), so large tables stay cheap. + record table { + columns: list<string>, + total-rows: u32, + first-row: u32, + rows: list<list<string>>, + editable: bool, + } + + variant body { + /// A centred message (empty state, errors). + message(string), + tree(list<tree-item>), + %list(list<list-item>), + form(list<field>), + table(table), + /// HTML shown in a sandboxed frame: no scripts from elsewhere, no + /// network, no IDE API. Its script talks to the plugin with + /// `openidea.postMessage(text)` / `openidea.onMessage(fn)`. + html(string), + } + + record view { + toolbar: list<button>, + body: body, + /// A line below the body (counts, last refresh…). + status: option<string>, + } + + /// The table rows the IDE shows. + record viewport { + first-row: u32, + rows: u32, + } + + record cell-edit { + row: u32, + column: u32, + value: string, + } + + variant view-event { + /// A toolbar button. + button(string), + /// Double click / Enter on a tree or list item. + activate(string), + select(string), + expand(string), + collapse(string), + /// A form field changed: key and new value (`true`/`false` for checkboxes). + field(tuple<string, string>), + cell(cell-edit), + /// Insert an empty row before this one (the row count: append). + insert-row(u32), + delete-rows(list<u32>), + /// From the `html` body's script. + message(string), + } +} + +/// What a plugin's code can ask of its views. +interface views { + use types.{error}; + + /// The view (or every custom editor of that id) shows outdated content: + /// the IDE renders it again. + refresh: func(view: string); + /// Sends a message to the script of a view's `html` body. + post-message: func(view: string, message: string) -> result<_, error>; + /// Opens a terminal tab in the window the call came from, running + /// `program` (pinned by a `process:<program>` grant) with `args` in the + /// project root: long-running output (logs) and interactive sessions. + open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>; +} + +/// The plugin's own settings (`[contributes.settings]`), as the user set them +/// (project over global) or the schema's default. +interface settings { + /// The value of `key` as JSON (`true`, `3`, `"text"`), none if unknown. + get: func(key: string) -> option<string>; +} + +/// Optional export (world `plugin-views`): content of declared views and +/// custom editors. For a custom editor, `ctx.editor()` is its document. +interface view-provider { + use view-types.{view, viewport, view-event}; + use invocation.{invocation}; + use types.{error}; + + render: func(view: string, viewport: option<viewport>, ctx: borrow<invocation>) -> result<view, error>; + handle: func(view: string, event: view-event, ctx: borrow<invocation>) -> result<_, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit new file mode 100644 index 000000000..febca7c6f --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -0,0 +1,90 @@ +package openidea:plugin@1.0.0; + +interface workspace { + use types.{error}; + + /// A project open in the IDE. + resource project { + /// Absolute path of the project root. Requires `project.read`. + root: func() -> result<string, error>; + /// Reads a UTF-8 text file inside the project (absolute, or relative + /// to the root); paths leaving the root are refused. Unsaved editor + /// contents win over the disk. Requires `project.read`. + read-file: func(path: string) -> result<string, error>; + /// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`), + /// at most `limit`, as absolute paths. Ignored and excluded files are + /// skipped. Requires `project.read`. + find-files: func(glob: string, limit: u32) -> result<list<string>, error>; + } + + /// Projects open in any window. Requires `project.read`. + projects: func() -> result<list<project>, error>; +} + +interface editor { + use types.{error, range, text-edit}; + + record document { + /// Grows with every change; edits name the version they were made for. + version: u64, + text: string, + } + + /// A file open in an editor. + resource text-editor { + /// Absolute path of the file. + path: func() -> string; + /// Language id (`rust`, `solidity`, …). + language: func() -> string; + /// Current version and text. Requires `editor.read`. + document: func() -> result<document, error>; + /// Selections when the command was invoked (a cursor is an empty + /// range). Requires `editor.read`. + selections: func() -> result<list<range>, error>; + /// Applies `edits` as one undoable change, only if the document is + /// still at `version` (else `stale`, and nothing changes). Ranges are + /// in that version and must not overlap. Returns the new version. + /// Requires `editor.write`. + apply-edits: func(version: u64, edits: list<text-edit>) -> result<u64, error>; + } +} + +/// Where a command or event comes from. +interface invocation { + use workspace.{project}; + use editor.{text-editor}; + + resource invocation { + /// The window's project. + project: func() -> option<project>; + /// The window's focused editor. + editor: func() -> option<text-editor>; + } +} + +/// Git, for the repository containing a project. +interface vcs { + use types.{error}; + use workspace.{project}; + + record repository-status { + /// Absolute path of the working tree root. + root: string, + /// `none` when HEAD is detached. + branch: option<string>, + commit: option<string>, + upstream: option<string>, + ahead: u32, + behind: u32, + /// Merge / rebase / cherry-pick in progress. + operation: option<string>, + /// Files with staged or unstaged changes. + changed-files: u32, + } + + /// `none` when the project is not in a repository. Requires `vcs.read`. + status: func(project: borrow<project>) -> result<option<repository-status>, error>; + /// Fetches all remotes (pruning deleted branches). Requires `vcs.write`. + /// Long-running: call it inside a task. + fetch: func(project: borrow<project>) -> result<_, error>; +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 633a43b9e..c99577e25 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,11 +67,14 @@ ide-notebook.workspace = true ide-jsonrpc.workspace = true ide-tools = { path = "../crates/ide-tools" } ide-plugin = { path = "../crates/ide-plugin" } +ide-extension = { path = "../crates/ide-extension" } ide-docker = { path = "../crates/ide-docker" } ide-github = { path = "../crates/ide-github" } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" +# Plugin secrets in the login keychain. +security-framework = "3.7" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSView", "NSWindow"] } objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index e383c9fa4..dbef3318c 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -126,14 +126,8 @@ pub fn close_project(window: Window, state: State<'_, AppState>) { #[specta::specta] pub fn languages(state: State<'_, AppState>) -> Vec<LanguageInfo> { state.note_ipc(); - let mut out: Vec<LanguageInfo> = state.languages.all().iter().map(LanguageInfo::from).collect(); - // Plugin-contributed languages; built-in definitions win on id clashes. - for lang in state.plugins.languages() { - if !out.iter().any(|l| l.id == lang.id) { - out.push(LanguageInfo::from(&lang)); - } - } - out + // Includes plugin languages (built-in definitions win on id clashes). + state.languages.all().iter().map(LanguageInfo::from).collect() } /// Effective settings for the window's project, or global settings without one. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 07101df69..beaf69086 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -419,11 +419,33 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> { plugin::plugin_subscribe, plugin::plugin_list, plugin::plugin_set_enabled, + plugin::plugin_preview_install, plugin::plugin_install_dir, + plugin::plugin_grant, + plugin::plugin_set_capability, + plugin::plugin_link, + plugin::plugin_unlink, + plugin::plugin_assets, + plugin::plugin_activation_event, plugin::plugin_catalog, plugin::plugin_uninstall, plugin::plugin_reload, plugin::plugin_run_command, + plugin::plugin_post_events, + plugin::plugin_event_kinds, + plugin::plugin_status_items, + plugin::plugin_cancel_task, + plugin::plugin_view_render, + plugin::plugin_view_event, + plugin::plugin_line_markers, + plugin::plugin_market, + plugin::plugin_market_url, + plugin::plugin_set_market_url, + plugin::plugin_market_download, + plugin::plugin_unpack_package, + plugin::plugin_roll_back, + plugin::plugin_secrets, + plugin::plugin_set_secret, plugin::plugin_set_active_file, plugin::plugin_set_theme, plugin::plugin_set_theme_frame, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 6c91ad079..15685257d 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -1,13 +1,18 @@ -//! WASM plugins (`ide-plugin`). Each window subscribes once with -//! `plugin_subscribe(channel)` and receives [`PluginEvent`]s addressed to it -//! (insert-text requests, notifications) plus broadcasts (list/theme changes). +//! Plugins: Tauri adapter over `ide_host::plugins::PluginHost`. Each window +//! subscribes once with `plugin_subscribe(channel)` and receives +//! [`PluginEvent`]s addressed to it (notifications, consent requests, tasks) +//! plus broadcasts (list/theme/status item changes). use crate::state::AppState; use ide_core::{IdeError, IdeResult}; -use ide_plugin::{ - ActiveTheme, CatalogPlugin, Invocation, Limits, PluginEvent, PluginHost, PluginInfo, PluginsSnapshot, +use ide_extension::ActivationEvent; +use ide_host::plugins::{ + ActiveTheme, CatalogPlugin, Consent, EventKind, HostWorkspace, InstallPreview, PluginEditor, PluginEvent, + PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent, }; +use ide_plugin::{Invocation, Limits}; use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock}; @@ -17,6 +22,16 @@ use tauri::{AppHandle, Manager, State, Window}; /// How long `plugin_list` waits for the startup load before answering with what it has. const INITIAL_LOAD_WAIT: Duration = Duration::from_secs(5); +/// `on-startup-finished` fires this long after the installed plugins are loaded. +const STARTUP_FINISHED_DELAY: Duration = Duration::from_millis(1500); +/// How often the marketplace index is read again (revocations). +const MARKET_REFRESH: Duration = Duration::from_secs(24 * 60 * 60); +/// Period of [`PluginHost::flush_events`]; [`PluginHost::tick`] runs every +/// [`TICKS_PER_SUPERVISION`] of them. +const EVENT_FLUSH: Duration = Duration::from_millis(250); +const TICKS_PER_SUPERVISION: u32 = 20; +/// Plugin changes (install, enable, grants) reach remote servers after this quiet time. +const REMOTE_SYNC_DELAY: Duration = Duration::from_millis(800); /// Window label → its event channel. static SUBSCRIBERS: LazyLock<Mutex<HashMap<String, Channel<PluginEvent>>>> = LazyLock::new(Default::default); @@ -35,19 +50,100 @@ fn deliver(client: Option<&str>, event: PluginEvent) { } } -/// Creates the host over `<app-data>/plugins` and loads installed plugins in the background. -pub fn create_host( - _app: &AppHandle, - data_dir: &Path, - project_host: &Arc<ide_host::Host>, -) -> IdeResult<Arc<PluginHost>> { - let host = Arc::new(PluginHost::new(data_dir.join("plugins"), Arc::new(deliver), Limits::default())?); +/// Delivers a plugin event to one window (remote windows: from their server). +pub(crate) fn deliver_to(window: &str, event: PluginEvent) { + deliver(Some(window), event); +} + +/// Re-syncs remote servers' plugins once plugin changes settle. +fn schedule_remote_sync(app: &AppHandle) { + static SCHEDULED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if SCHEDULED.swap(true, std::sync::atomic::Ordering::AcqRel) { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(REMOTE_SYNC_DELAY).await; + SCHEDULED.store(false, std::sync::atomic::Ordering::Release); + let Some(state) = app.try_state::<crate::state::AppState>() else { return }; + for (window, connection) in state.remote_dev.all() { + crate::remote_dev::sync_plugin_packages(&state.plugins, &connection, &window).await; + } + }); +} + +/// Creates the host over `<app-data>/plugins` and loads installed plugins in +/// the background (no Wasm is instantiated until an activation event). +pub fn create_host(app: &AppHandle, data_dir: &Path, project_host: &Arc<ide_host::Host>) -> IdeResult<Arc<PluginHost>> { + let projects = Arc::downgrade(project_host); + let app = app.clone(); + let sink = move |client: Option<&str>, event: PluginEvent| { + if matches!(event, PluginEvent::CommandsChanged) + && app.try_state::<crate::state::AppState>().is_some_and(|s| !s.remote_dev.all().is_empty()) + { + schedule_remote_sync(&app); + } + if matches!(event, PluginEvent::LanguagesChanged) { + if let Some(projects) = projects.upgrade() { + projects.plugin_languages_changed(); + } + // Remote windows: their servers run the languages. + if let Some(state) = app.try_state::<crate::state::AppState>() { + let plugins = state.plugins.clone(); + for (window, connection) in state.remote_dev.all() { + let plugins = plugins.clone(); + tauri::async_runtime::spawn(async move { + crate::remote_dev::sync_plugin_languages(&plugins, &connection, &window).await + }); + } + } + } + deliver(client, event); + }; + let host = Arc::new(PluginHost::new(data_dir.join("plugins"), Arc::new(sink), Limits::default())?); + // Plugin languages join detection, LSP and DAP of every project (forks share the store), + // and the syntax of the ones without a built-in grammar comes from their plugin. + project_host.languages.attach_plugins(host.language_store().clone()); + ide_language::set_external_syntax(Some(host.syntax_provider())); + ide_run::set_external_detection(Some(host.run_detection())); + host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(project_host))))); + #[cfg(target_os = "macos")] + host.set_secret_store(Arc::new(KeychainSecrets)); + ide_host::plugins::set_editor_providers(Some(&host)); let weak = Arc::downgrade(project_host); host.set_project_policy(Arc::new(move |root| { weak.upgrade().is_some_and(|host| host.trust.trusted(root).unwrap_or(false)) })); - let loader = host.clone(); - std::thread::Builder::new().name("plugin-load".into()).spawn(move || loader.load_all())?; + let loader = Arc::downgrade(&host); + std::thread::Builder::new().name("plugin-load".into()).spawn(move || { + let Some(host) = loader.upgrade() else { return }; + host.load_all(); + drop(host); + std::thread::sleep(STARTUP_FINISHED_DELAY); + let Some(host) = loader.upgrade() else { return }; + host.startup_finished(); + host.warm_component_cache(); + drop(host); + // Event delivery; supervision (crash reports, idle reclaim, `on-schedule`); + // the daily marketplace check (revocations). + let mut market_due = std::time::Instant::now() + Duration::from_secs(60); + for n in 1u32.. { + std::thread::sleep(EVENT_FLUSH); + let Some(host) = loader.upgrade() else { return }; + host.flush_events(); + if n % TICKS_PER_SUPERVISION == 0 { + host.tick(); + } + if std::time::Instant::now() >= market_due { + market_due = std::time::Instant::now() + MARKET_REFRESH; + if host.market_url().is_some() + && let Err(e) = host.refresh_market() + { + tracing::info!("plugin marketplace: {e}"); + } + } + } + })?; Ok(host) } @@ -66,8 +162,9 @@ pub fn plugin_subscribe(window: Window, on_event: Channel<PluginEvent>) { SUBSCRIBERS.lock().insert(window.label().to_owned(), on_event); } -/// Installed plugins plus the dev plugins of this window's project -/// (`<project>/.openidea/plugins/*`, rescanned on every call). +/// Installed and linked plugins plus the project plugins of this window's +/// project (`<project>/.openidea/plugins/*`, rescanned on every call), with +/// the UI contributions to project. #[tauri::command] #[specta::specta] pub async fn plugin_list(window: Window, state: State<'_, AppState>) -> IdeResult<PluginsSnapshot> { @@ -76,6 +173,7 @@ pub async fn plugin_list(window: Window, state: State<'_, AppState>) -> IdeResul blocking(move || { // A window opened at startup would otherwise see no plugins and drop the selected theme. host.wait_initial_load(INITIAL_LOAD_WAIT); + host.sync_links(); if let Some(root) = &root { host.sync_project(root); } @@ -91,25 +189,111 @@ pub async fn plugin_set_enabled(state: State<'_, AppState>, id: String, enabled: blocking(move || host.set_enabled(&id, enabled)).await } -/// Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. +/// Validates the package in `path` for the install page (nothing is copied). #[tauri::command] #[specta::specta] -pub async fn plugin_install_dir(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PluginInfo> { +pub async fn plugin_preview_install(state: State<'_, AppState>, path: PathBuf) -> IdeResult<InstallPreview> { let host = state.plugins.clone(); - blocking(move || host.install_from_path(&path)).await + blocking(move || host.preview_install(&path)).await +} + +/// Installs the plugin package in `path` (a folder with `plugin.toml`) with +/// the capabilities the user accepted on the install page. +#[tauri::command] +#[specta::specta] +pub async fn plugin_install_dir(state: State<'_, AppState>, path: PathBuf, consent: Consent) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.install_from_dir(&path, &consent)).await +} + +/// Confirms a plugin waiting for consent (project plugins, new capabilities). +#[tauri::command] +#[specta::specta] +pub async fn plugin_grant(state: State<'_, AppState>, id: String, consent: Consent) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.grant(&id, &consent)).await +} + +/// Grants or revokes one declared capability. +#[tauri::command] +#[specta::specta] +pub async fn plugin_set_capability( + state: State<'_, AppState>, + id: String, + capability: String, + granted: bool, +) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.set_capability(&id, &capability, granted)).await +} + +/// Links a development plugin folder (`openidea-plugin link` does the same from a terminal). +#[tauri::command] +#[specta::specta] +pub async fn plugin_link(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.link(&path)).await +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_unlink(state: State<'_, AppState>, id: String) -> IdeResult<()> { + let host = state.plugins.clone(); + blocking(move || host.unlink(&id)).await +} + +/// Validated assets of a plugin whose id starts with `prefix` (icon theme mapping and SVGs). +#[tauri::command] +#[specta::specta] +pub async fn plugin_assets( + state: State<'_, AppState>, + plugin_id: String, + prefix: String, +) -> IdeResult<Vec<(String, String)>> { + let host = state.plugins.clone(); + blocking(move || host.assets(&plugin_id, &prefix)).await +} + +/// An activation event from the UI: `kind` is `language`, `file` (project-relative path) or `view`. +#[tauri::command] +#[specta::specta] +pub async fn plugin_activation_event( + window: Window, + state: State<'_, AppState>, + kind: String, + value: String, +) -> IdeResult<()> { + let event = match kind.as_str() { + "language" => ActivationEvent::Language(value), + "file" => ActivationEvent::File(value), + "view" => ActivationEvent::View(value), + _ => return Err(IdeError::invalid(format!("unknown activation event kind {kind:?}"))), + }; + let project = state.project(window.label()).ok(); + if project.as_ref().is_some_and(|p| !p.is_trusted()) { + return Ok(()); // Restricted Mode: no plugin code runs for this project. + } + let host = state.plugins.clone(); + blocking(move || { + host.activation_event(&event, project.as_ref().map(|p| p.root.as_path())); + Ok(()) + }) + .await } /// Plugin packages available locally for installation: `<resources>/plugins` /// when bundled, plus the repository's sample `plugins/` in debug builds. -/// There is no network marketplace. #[tauri::command] #[specta::specta] -pub async fn plugin_catalog(app: AppHandle) -> IdeResult<Vec<CatalogPlugin>> { +pub async fn plugin_catalog(app: AppHandle, state: State<'_, AppState>) -> IdeResult<Vec<CatalogPlugin>> { let mut dirs: Vec<PathBuf> = app.path().resource_dir().map(|d| vec![d.join("plugins")]).unwrap_or_default(); if cfg!(debug_assertions) { - dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugins")); + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + dirs.push(repo.join("plugins")); + dirs.push(repo.join("plugins/official")); } - blocking(move || Ok(ide_plugin::scan_catalog(&dirs))).await + let host = state.plugins.clone(); + blocking(move || Ok(host.catalog(&dirs))).await } #[tauri::command] @@ -127,7 +311,7 @@ pub async fn plugin_reload(state: State<'_, AppState>, id: String) -> IdeResult< blocking(move || host.reload(&id)).await } -/// Runs a plugin command in the context of this window's project. +/// Runs a plugin command in the context of this window's project and focused editor. #[tauri::command] #[specta::specta] pub async fn plugin_run_command( @@ -136,21 +320,206 @@ pub async fn plugin_run_command( plugin_id: String, command_id: String, arg: Option<String>, + editor: Option<PluginEditor>, ) -> IdeResult<Option<String>> { let host = state.plugins.clone(); let project = state.project(window.label()).ok(); let invocation = Invocation { client: Some(window.label().to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), - buffer_text: project.map(|p| { - let documents = p.documents.clone(); - Arc::new(move |path: &Path| documents.text_of(path)) as ide_plugin::BufferText - }), + editor: editor.map(PluginEditor::context), }; blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await } -/// The UI reports the focused editor's file (`active-file` for plugins). +/// Workbench events from this window (only the kinds of `plugin_event_kinds`). +#[tauri::command] +#[specta::specta] +pub fn plugin_post_events(window: Window, state: State<'_, AppState>, events: Vec<WorkbenchEvent>) { + state.plugins.post_events(Some(window.label()), events); +} + +/// Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). +#[tauri::command] +#[specta::specta] +pub async fn plugin_event_kinds(app: tauri::AppHandle) -> Vec<EventKind> { + let host = app.state::<AppState>().plugins.clone(); + // Asked once at startup: before the first load, the declared `on-event:` kinds + // would be missing, and the `commandsChanged` that load sends may precede the window's subscription. + let kinds = tauri::async_runtime::spawn_blocking(move || { + host.wait_initial_load(INITIAL_LOAD_WAIT); + host.event_kinds().into_iter().collect() + }); + kinds.await.unwrap_or_default() +} + +/// Every plugin status bar item (later changes arrive as `statusItemChanged`). +#[tauri::command] +#[specta::specta] +pub fn plugin_status_items(state: State<'_, AppState>) -> Vec<PluginStatusItem> { + state + .plugins + .status_items() + .into_iter() + .map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item }) + .collect() +} + +/// Cancels a plugin task (`taskStarted` with `cancellable`). +#[tauri::command] +#[specta::specta] +pub fn plugin_cancel_task(state: State<'_, AppState>, task_id: String) -> bool { + state.plugins.cancel_task(&task_id) +} + +/// Gutter markers of plugin `line-markers` providers for an open document. +#[tauri::command] +#[specta::specta] +pub async fn plugin_line_markers( + window: Window, + state: State<'_, AppState>, + doc: ide_core::DocId, +) -> IdeResult<Vec<ide_host::plugins::LineMarkerInfo>> { + ide_host::ops::lsp::plugin_line_markers(&state.host, window.label(), doc).await +} + +/// Content of a plugin view (`doc`: the document of a custom editor). +#[tauri::command] +#[specta::specta] +pub async fn plugin_view_render( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + view: String, + viewport: Option<ide_plugin::Viewport>, + doc: Option<ide_core::DocId>, +) -> IdeResult<ide_plugin::View> { + let plugins = state.plugins.clone(); + ide_host::plugins::views::render(&state.host, plugins, window.label(), plugin_id, view, viewport, doc).await +} + +/// A user action in a plugin view or custom editor. +#[tauri::command] +#[specta::specta] +pub async fn plugin_view_event( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + view: String, + event: ide_plugin::ViewEvent, + doc: Option<ide_core::DocId>, +) -> IdeResult<()> { + let plugins = state.plugins.clone(); + ide_host::plugins::views::event(&state.host, plugins, window.label(), plugin_id, view, event, doc).await +} + +/// The marketplace's plugins: read again when `refresh`, else the last index read. +#[tauri::command] +#[specta::specta] +pub async fn plugin_market( + state: State<'_, AppState>, + refresh: bool, +) -> IdeResult<Vec<ide_host::plugins::MarketPlugin>> { + let host = state.plugins.clone(); + blocking(move || if refresh { host.refresh_market() } else { Ok(host.market_plugins()) }).await +} + +#[tauri::command] +#[specta::specta] +pub fn plugin_market_url(state: State<'_, AppState>) -> Option<String> { + state.plugins.market_url() +} + +/// Sets the marketplace index URL (an `https://…/index.json` or a local path; `None` for none). +#[tauri::command] +#[specta::specta] +pub fn plugin_set_market_url(state: State<'_, AppState>, url: Option<String>) { + state.plugins.set_market_url(url); +} + +/// Downloads and checks a marketplace plugin; returns the folder for the install page. +#[tauri::command] +#[specta::specta] +pub async fn plugin_market_download(state: State<'_, AppState>, id: String) -> IdeResult<PathBuf> { + let host = state.plugins.clone(); + blocking(move || host.download_market_plugin(&id)).await +} + +/// Unpacks a package file (`.oiplugin`); returns the folder for the install page. +#[tauri::command] +#[specta::specta] +pub async fn plugin_unpack_package(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PathBuf> { + let host = state.plugins.clone(); + blocking(move || host.unpack_package(&path)).await +} + +/// Goes back to the version the last update of `id` replaced. +#[tauri::command] +#[specta::specta] +pub async fn plugin_roll_back(state: State<'_, AppState>, id: String) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + blocking(move || host.roll_back_update(&id)).await +} + +/// A secret a plugin declares, and whether the user stored it. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginSecretState { + pub name: String, + pub set: bool, +} + +#[tauri::command] +#[specta::specta] +pub fn plugin_secrets(state: State<'_, AppState>, plugin_id: String) -> IdeResult<Vec<PluginSecretState>> { + Ok(state.plugins.secrets(&plugin_id)?.into_iter().map(|(name, set)| PluginSecretState { name, set }).collect()) +} + +/// Stores a plugin secret (`None` or empty deletes it). The value never goes back to the UI or the plugin. +#[tauri::command] +#[specta::specta] +pub fn plugin_set_secret( + state: State<'_, AppState>, + plugin_id: String, + name: String, + value: Option<String>, +) -> IdeResult<()> { + state.plugins.set_secret(&plugin_id, &name, value.as_deref()) +} + +/// Plugin secrets in the login keychain (service `ai.l0ng.openidea.plugin.<id>`). +#[cfg(target_os = "macos")] +struct KeychainSecrets; + +#[cfg(target_os = "macos")] +impl ide_host::plugins::SecretStore for KeychainSecrets { + fn get(&self, plugin: &str, name: &str) -> Option<String> { + let bytes = security_framework::passwords::get_generic_password(&Self::service(plugin), name).ok()?; + String::from_utf8(bytes).ok() + } + + fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> { + let service = Self::service(plugin); + match value { + Some(v) => security_framework::passwords::set_generic_password(&service, name, v.as_bytes()), + None => match security_framework::passwords::delete_generic_password(&service, name) { + // Deleting what isn't there is fine. + Err(e) if e.code() == -25300 => Ok(()), + other => other, + }, + } + .map_err(|e| e.to_string()) + } +} + +#[cfg(target_os = "macos")] +impl KeychainSecrets { + fn service(plugin: &str) -> String { + format!("ai.l0ng.openidea.plugin.{plugin}") + } +} + +/// The UI reports the focused editor's file (`editor-focused` events). #[tauri::command] #[specta::specta] pub fn plugin_set_active_file(window: Window, state: State<'_, AppState>, path: Option<String>) { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 8ad7a7aee..571912328 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -169,6 +169,8 @@ const SEP: Entry = Entry::Separator; /// [`Entry::Dynamic`] slots the frontend fills. pub const DYNAMIC_TOOL_WINDOWS: &str = "toolWindows"; pub const DYNAMIC_LAYOUTS: &str = "toolWindowLayouts"; +/// Tools › entries of plugins (`[[contributes.menus]] menu = "tools"`). +pub const DYNAMIC_PLUGIN_TOOLS: &str = "pluginTools"; /// The menu bar for `os`. Labels are English defaults; the frontend replaces /// them with `menu.<id>` strings from the active locale catalog. @@ -725,6 +727,7 @@ pub fn spec(os: Os) -> Vec<Entry> { ), SEP, act("vim.toggle", "Vim"), + Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS), ], )); @@ -1484,7 +1487,7 @@ mod tests { let spec = spec(os); let mut found = Vec::new(); slots(&spec, &mut found); - assert_eq!(found, [DYNAMIC_TOOL_WINDOWS, DYNAMIC_LAYOUTS], "{os:?}"); + assert_eq!(found, [DYNAMIC_TOOL_WINDOWS, DYNAMIC_PLUGIN_TOOLS, DYNAMIC_LAYOUTS], "{os:?}"); assert!(!action_ids(&spec).iter().any(|id| { id.starts_with("toolwindow.") && *id != "toolwindow.hideAll" diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index e45f7eef7..d9d47b8f6 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -90,6 +90,11 @@ impl Connections { .map(|(label, _)| label.clone()) } + /// Every connected window. + pub fn all(&self) -> Vec<(String, Arc<Connection>)> { + self.0.lock().iter().map(|(w, c)| (w.clone(), c.clone())).collect() + } + pub fn disconnect(&self, window: &str) { if let Some(c) = self.0.lock().remove(window) { c.peer.kill(); @@ -157,6 +162,16 @@ impl Handler for ClientHandler { } Err(e) => tracing::warn!("remote event: {e}"), }, + // Plugin code of this window's project runs on the server (docs/plugin_plan.md 3). + ide_host::plugins::remote_host::EVENT_NOTIFICATION => { + match serde_json::from_value::<ide_host::plugins::remote_host::PluginEventMessage>(params) { + Ok(m) if ide_host::plugins::remote_host::forwarded(&m.event) => { + crate::commands::plugin::deliver_to(&self.window, m.event) + } + Ok(_) => {} + Err(e) => tracing::warn!("remote plugin event: {e}"), + } + } "channel" => match serde_json::from_value::<ChannelMessage>(params) { Ok(m) => { let channel = self.channels.lock().get(&m.id).cloned(); @@ -170,8 +185,18 @@ impl Handler for ClientHandler { } } - fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> { - Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}"))) + fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> { + use ide_host::plugins::remote_host::{SECRET_REQUEST, SecretRequest}; + match method { + // A remote plugin's request header needs a secret from this machine's keychain. + SECRET_REQUEST => { + let r: SecretRequest = + serde_json::from_value(params).map_err(|e| (ide_jsonrpc::codes::INVALID_PARAMS, e.to_string()))?; + let state = self.app.state::<AppState>(); + Ok(serde_json::to_value(state.plugins.declared_secret(&r.plugin, &r.name)).unwrap_or(Value::Null)) + } + _ => Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}"))), + } } } @@ -401,7 +426,12 @@ pub async fn remote_connect( next_channel: std::sync::atomic::AtomicU64::new(1), _session: session, }); - state.remote_dev.0.lock().insert(label, connection); + state.remote_dev.0.lock().insert(label.clone(), connection.clone()); + let plugins = state.plugins.clone(); + tauri::async_runtime::spawn(async move { + sync_plugin_languages(&plugins, &connection, &label).await; + sync_plugin_packages(&plugins, &connection, &label).await; + }); Ok(RemoteProject { ssh_config, root, @@ -413,6 +443,80 @@ pub async fn remote_connect( }) } +/// Sends the desktop's plugin languages, with the programs granted to them, to +/// the window's server: `Workspace` contributions take effect where the project +/// is (docs/plugin_plan.md 3). Servers without the command are left alone. +pub async fn sync_plugin_languages(plugins: &ide_host::plugins::PluginHost, c: &Connection, window: &str) { + use ide_host::plugins::remote::{SYNC_COMMAND, SyncReport}; + if !c.commands.iter().any(|cmd| cmd == SYNC_COMMAND) { + return; + } + let call = Invoke { + window: window.to_owned(), + cmd: SYNC_COMMAND.into(), + args: serde_json::json!({ "languages": plugins.remote_languages() }), + }; + match c.peer.request::<_, SyncReport>("invoke", call, None).await { + Ok(report) => { + for program in &report.unresolved { + tracing::info!("{}: plugin program {program} is not installed", c.host); + } + for rejected in &report.rejected { + tracing::warn!("{}: plugin language rejected: {rejected}", c.host); + } + } + Err(e) => tracing::warn!("{}: syncing plugin languages: {e}", c.host), + } +} + +/// Sends the desktop's live code plugins to the window's server, which runs +/// them next to the project (docs/plugin_plan.md 3): digests and grants first, +/// then the files of the packages the server lacks, one per request. +pub async fn sync_plugin_packages(plugins: &Arc<ide_host::plugins::PluginHost>, c: &Connection, window: &str) { + use ide_host::plugins::remote_host::{PackageSyncReport, SYNC_PACKAGES}; + if !c.commands.iter().any(|cmd| cmd == SYNC_PACKAGES) { + return; + } + let host = plugins.clone(); + let Ok(packages) = tauri::async_runtime::spawn_blocking(move || host.remote_packages()).await else { return }; + let sync = |files: serde_json::Value| Invoke { + window: window.to_owned(), + cmd: SYNC_PACKAGES.into(), + args: serde_json::json!({ "packages": packages, "files": files }), + }; + let report: PackageSyncReport = match c.peer.request("invoke", sync(Value::Null), None).await { + Ok(r) => r, + Err(e) => return tracing::warn!("{}: syncing plugins: {e}", c.host), + }; + let mut failed = report.failed; + for id in report.missing { + let host = plugins.clone(); + let pid = id.clone(); + let files = match tauri::async_runtime::spawn_blocking(move || host.remote_package_files(&pid)).await { + Ok(Ok(files)) => files, + Ok(Err(e)) => { + failed.push(format!("{id}: {e}")); + continue; + } + Err(e) => { + failed.push(format!("{id}: {e}")); + continue; + } + }; + match c + .peer + .request::<_, PackageSyncReport>("invoke", sync(serde_json::json!({ (id.clone()): files })), None) + .await + { + Ok(r) => failed.extend(r.failed), + Err(e) => failed.push(format!("{id}: {e}")), + } + } + for f in failed { + tracing::warn!("{}: plugin not available remotely: {f}", c.host); + } +} + #[derive(Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct RemoteSession { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e3ad4ea39..b205e44c6 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -16,8 +16,8 @@ pub struct AppState { pub host: Arc<Host>, pub startup: Startup, pub recent: RecentProjects, - /// WASM plugins (`ide-plugin`), shared by all windows. - pub plugins: Arc<ide_plugin::PluginHost>, + /// Plugins (`ide_host::plugins`), shared by all windows. + pub plugins: Arc<ide_host::plugins::PluginHost>, /// The built-in web server (Open In › Browser, the editor's browser bar). pub web: ide_web::WebServer, /// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start). diff --git a/src/editor/api.ts b/src/editor/api.ts index 45b2a70fd..fe71e2c48 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -585,12 +585,25 @@ export function isLibraryPath(path: string): boolean { */ export interface DocumentAttachment { path: string; + /** The backend document (for document-scoped commands). */ + doc: number; /** Current document text; build the view's state from it. */ text(): string; readOnly: boolean; /** Add to the view (one view at a time). */ extension: Extension; save(options?: { exact?: boolean }): Promise<void>; + /** Resolves once local edits have reached the backend. */ + synced(): Promise<void>; + /** Unsaved changes. */ + dirty(): boolean; + /** + * Undo / redo of the document's history: the text editor tab's when one + * shows the file (backend edits are recorded there), else this view's. + * Returns whether anything changed. + */ + undo(): boolean; + redo(): boolean; /** Detach; the document closes when nothing else shows it. */ release(): void; } diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 2b8231d76..ddff2757a 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -21,8 +21,10 @@ import { _languageMode } from "@/editor/api"; let infos: Map<string, LanguageInfo> | null = null; let infosPromise: Promise<Map<string, LanguageInfo>> | null = null; +let infosBackend: DocBackend | null = null; export function loadLanguageInfos(backend: DocBackend): Promise<Map<string, LanguageInfo>> { + infosBackend = backend; return (infosPromise ??= backend .languages() .then((list) => (infos = new Map(list.map((l) => [l.id, l])))) @@ -37,6 +39,15 @@ export function languageInfo(id: string): LanguageInfo | undefined { return infos?.get(id); } +/** + * The language list changed (a plugin added or removed languages): fetch it + * again. Open documents follow through their `docLanguage` events. + */ +export function refreshLanguageInfos(): Promise<unknown> { + infosPromise = null; + return infosBackend ? loadLanguageInfos(infosBackend) : Promise.resolve(); +} + /** Test hook. */ export function _resetLanguages() { infos = null; diff --git a/src/editor/core/model/attachment.test.ts b/src/editor/core/model/attachment.test.ts index 1be4ef1dc..b733293b9 100644 --- a/src/editor/core/model/attachment.test.ts +++ b/src/editor/core/model/attachment.test.ts @@ -110,4 +110,33 @@ describe("attached document views (editable diff side)", () => { att.release(); tab.dispose(); }); + + it("undoes backend edits in the document's history: the text tab's when one is open (custom editors)", async () => { + const { backend, manager, frame } = setup({ "/p/c.csv": "a,b\n" }); + const att = await attachDocument(manager, "/p/c.csv"); + const view = new EditorView({ doc: att.text(), extensions: [att.extension, history()] }); + const model = manager.get("/p/c.csv")!; + expect(att.doc).toBe(model.doc); + + // No tab: the attachment holds the history. + backend.patch(model.doc, [{ from: 0, to: 1, insert: "X" }]); + expect(att.undo()).toBe(true); + expect(view.state.doc.toString()).toBe("a,b\n"); + expect(att.redo()).toBe(true); + expect(view.state.doc.toString()).toBe("X,b\n"); + await frame(); + await att.synced(); + expect(backend.docs.get(model.doc)?.text).toBe("X,b\n"); + + // With a text tab, backend edits are recorded there: undo from the custom editor reaches it. + const tab = new EditorSlot(model, "t1", host); + backend.patch(model.doc, [{ from: 2, to: 3, insert: "Y" }]); + expect(tab.currentState().doc.toString()).toBe("X,Y\n"); + expect(att.undo()).toBe(true); + expect(tab.currentState().doc.toString()).toBe("X,b\n"); + expect(view.state.doc.toString()).toBe("X,b\n"); + view.destroy(); + att.release(); + tab.dispose(); + }); }); diff --git a/src/editor/core/model/attachment.ts b/src/editor/core/model/attachment.ts index be24aed91..51c656d02 100644 --- a/src/editor/core/model/attachment.ts +++ b/src/editor/core/model/attachment.ts @@ -3,7 +3,8 @@ * diff): local edits flow view → model → backend and other editors; edits * from elsewhere are mirrored into the view. See `attachDocument` in editor/api. */ -import { Transaction, type Annotation, type ChangeSet } from "@codemirror/state"; +import { redo, undo } from "@codemirror/commands"; +import { Transaction, type Annotation, type ChangeSet, type StateCommand } from "@codemirror/state"; import { EditorView, ViewPlugin } from "@codemirror/view"; import { _addEditor, @@ -65,8 +66,24 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr }; }); + /** Runs a history command where the document's history is (see `DocumentAttachment.undo`). */ + const historyCommand = (command: StateCommand): boolean => { + const slot = model.primarySlot(); + if (slot?.view) return command(slot.view); + if (slot) + return command({ + state: slot.state, + dispatch: (tr) => { + slot.state = tr.state; + if (tr.docChanged) model.onLocal(tr.changes, slot); + }, + }); + return target.view ? command(target.view) : false; + }; + return { path, + doc: model.doc, text: () => model.sync.localText.toString(), readOnly: model.getMeta().readOnly || model.virtual, extension: [ @@ -81,6 +98,10 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr }), ], save: (options) => model.save(options?.exact ? "exact" : "explicit"), + synced: () => model.sync.whenIdle(), + dirty: () => model.getMeta().dirty, + undo: () => historyCommand(undo), + redo: () => historyCommand(redo), release: () => { if (released) return; released = true; diff --git a/src/features/app/menuBridge.ts b/src/features/app/menuBridge.ts index 7b5d4f84c..1cb210867 100644 --- a/src/features/app/menuBridge.ts +++ b/src/features/app/menuBridge.ts @@ -7,13 +7,13 @@ * shortcut text of every keymap binding, so the menu shows the JS keymap's * shortcuts without registering native accelerators (see src-tauri/src/menu.rs). */ -import { executeAction, getAction, isActionEnabled, useActions } from "@/platform/actions"; +import { executeActionFrom, getAction, isActionEnabled, useActions } from "@/platform/actions"; import { useContextKeys } from "@/platform/context"; import { keysWithPrefix, t, tx, useI18n } from "@/platform/i18n"; import { commands, isTauri, type DynamicItem, type MenuConfig, type MenuNode } from "@/platform/ipc"; import { effectiveBindings, formatBinding, primaryKeyBinding, useKeymap } from "@/platform/keymap"; import { useProject } from "@/platform/project"; -import { useContributions } from "@/workbench/contributions"; +import { useContributions, useMenuSlotItems } from "@/workbench/contributions"; import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree"; import { useToolWindowLayouts } from "@/workbench/toolwindows/layouts"; import { hostOs, type HostOs } from "./host"; @@ -30,7 +30,10 @@ export function isMenuRoute(v: unknown): v is MenuRoute { } /** Runs the action a menu item stands for. Returns whether it ran. */ -export function routeMenuEvent(payload: unknown, run = executeAction): Promise<boolean> { +export function routeMenuEvent( + payload: unknown, + run = (id: string, arg?: unknown) => executeActionFrom("menu", id, arg), +): Promise<boolean> { if (!isMenuRoute(payload) || !payload.action) return Promise.resolve(false); return run(payload.action, payload.arg ?? undefined); } @@ -62,7 +65,15 @@ export function dynamicMenuItems(): Record<string, DynamicItem[]> { const layouts = useToolWindowLayouts .getState() .layouts.map((l) => ({ action: "layout.restore", arg: l.name, label: l.name })); - return { toolWindows, toolWindowLayouts: layouts }; + const slots = Object.fromEntries( + Object.entries(useMenuSlotItems.getState().slots).map(([slot, items]) => [ + slot, + [...items] + .map((i) => ({ action: i.action, arg: i.arg ?? null, label: tx(i.label) ?? i.label })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]), + ); + return { ...slots, toolWindows, toolWindowLayouts: layouts }; } /** Action ids of every IDE item in a menu model. */ @@ -150,6 +161,7 @@ export function installMenuBridge(): () => void { if (s.toolWindows !== prev.toolWindows) push(); }), useToolWindowLayouts.subscribe(push), + useMenuSlotItems.subscribe(push), useContextKeys.subscribe(() => pushEnablement()), useActions.subscribe(() => pushEnablement()), // Opening / closing a project changes the Open Recent list. diff --git a/src/features/plugins/PluginsPage.module.css b/src/features/plugins/PluginsPage.module.css index 1f03caa78..f54100aa9 100644 --- a/src/features/plugins/PluginsPage.module.css +++ b/src/features/plugins/PluginsPage.module.css @@ -198,11 +198,13 @@ .header { display: flex; align-items: flex-start; + /* In a narrow window the actions go below rather than over the name. */ + flex-wrap: wrap; gap: 14px; } .headerText { display: flex; - flex: 1; + flex: 1 1 160px; flex-direction: column; gap: 2px; min-width: 0; @@ -222,6 +224,11 @@ flex-wrap: wrap; gap: 8px; } +/* Badges are counter pills (`min-width: 16px`): in a narrow window they wrap instead of shrinking under their text. */ +.titleLine > * { + flex: none; + white-space: nowrap; +} .title { margin: 0; color: var(--fg-strong); @@ -313,3 +320,67 @@ text-overflow: ellipsis; white-space: nowrap; } + +/* Permission dialog and notices */ +.consent { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.consentWarning { + margin: 0; + color: var(--fg-warning, var(--fg)); +} + +.consentRequired { + width: 16px; + text-align: center; + color: var(--fg-muted); +} + +.consentNotice { + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 12px; +} + +.settingsPage { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 560px; +} + +.settingRow { + display: flex; + flex-direction: column; + gap: 4px; +} + +.marketItem { + list-style: none; + padding: 4px 8px; +} + +.marketBar { + display: flex; + gap: 4px; + align-items: center; +} + +.marketBar > :first-child { + flex: 1; + min-width: 0; +} + +.marketOverview { + padding: 12px 16px; + overflow: auto; +} diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 8d496a3fb..1bdddb992 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -8,7 +8,7 @@ */ import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; import clsx from "clsx"; -import type { CatalogPlugin, Permission, PluginInfo, PluginStatus } from "@/bindings"; +import type { CapabilityInfo, CatalogPlugin, MarketPlugin, PluginInfo, PluginStatus } from "@/bindings"; import { Badge, Button, @@ -16,7 +16,7 @@ import { EmptyState, Icon, IconButton, - Kbd, + Input, SearchField, Spinner, Tabs, @@ -24,16 +24,21 @@ import { type MenuItem, T, } from "@/ui"; -import { pluginActionId } from "./bridge"; +import { RISK_LABEL, sortByRisk } from "./consent"; +import { itemsOf, uiPoint } from "./projection"; import { compareVersions, + grantPlugin, installFromDisk, + installFromMarket, + refreshMarket, + rollBackPlugin, + setMarketUrl, refreshCatalog, refreshPlugins, reloadPlugin, - runPluginCommand, - selectPluginTheme, setAllInstalledEnabled, + setPluginCapability, setPluginEnabled, uninstallPlugin, usePlugins, @@ -41,19 +46,26 @@ import { import styles from "./PluginsPage.module.css"; import { tx } from "@/platform/i18n"; -const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" }> = { +const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" | "warning" }> = { active: { label: "Running", tone: "success" }, + inactive: { label: "Enabled", tone: "accent" }, static: { label: "Enabled", tone: "accent" }, disabled: { label: "Disabled", tone: "neutral" }, + needsConsent: { label: "Needs permission", tone: "warning" }, failed: { label: "Failed", tone: "danger" }, }; -export const PERMISSION_TEXT: Record<Permission, string> = { - "project-read": "Read files inside the open project", - "editor-read": "See which file is open in the editor", - "editor-write": "Insert text into the active editor", +/** Human names of the built-in contribution points (others show their id). */ +const POINT_TITLES: Record<string, string> = { + commands: "Commands", + themes: "Themes", + iconThemes: "File Icon Themes", + languages: "Languages", + providers: "Editor Providers", }; +const pointTitle = (point: string) => uiPoint(point)?.title ?? POINT_TITLES[point] ?? point; + export const NO_REPOSITORY = "There is no online plugin repository"; type Tab = "marketplace" | "installed"; @@ -65,16 +77,14 @@ export function filterPlugins(plugins: readonly PluginInfo[], query: string): Pl const q = query.trim().toLowerCase(); if (!q) return [...plugins]; return plugins.filter((p) => - matches([p.name, p.id, p.description, ...p.authors, ...p.commands.map((c) => c.title)], q), + matches([p.name, p.id, p.description, ...p.authors, ...p.contributions.map((c) => c.label)], q), ); } export function filterCatalog(catalog: readonly CatalogPlugin[], query: string): CatalogPlugin[] { const q = query.trim().toLowerCase(); if (!q) return [...catalog]; - return catalog.filter((p) => - matches([p.name, p.id, p.description, ...p.authors, ...p.commands.map((c) => c.title)], q), - ); + return catalog.filter((p) => matches([p.name, p.id, p.description, ...p.authors], q)); } /** What the Available tab offers for a catalog package given what is installed. */ @@ -84,7 +94,7 @@ export function catalogState( ): "install" | "update" | "installed" | "dev" { const have = installed.find((p) => p.id === entry.id); if (!have) return "install"; - if (have.source === "dev") return "dev"; + if (have.source !== "installed") return "dev"; return compareVersions(entry.version, have.version) > 0 ? "update" : "installed"; } @@ -124,16 +134,19 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele <span className={styles.rowMeta}> <span className={styles.rowVendor}>{vendorOf(plugin.authors) || plugin.id}</span> <span>{plugin.version}</span> - {plugin.source === "dev" && ( - <span className={styles.devTag}> - <T>dev</T> - </span> + {plugin.source !== "installed" && ( + <span className={styles.devTag}>{plugin.source === "linked" ? tx("linked") : tx("dev")}</span> )} {plugin.status === "failed" && ( <Badge dot tone="danger"> <T>Failed</T> </Badge> )} + {plugin.status === "needsConsent" && ( + <Badge dot tone="warning"> + <T>Needs permission</T> + </Badge> + )} </span> </span> </button> @@ -234,12 +247,73 @@ function DetailTabs({ notes, overview }: { notes: string | null; overview: React ); } +/** Contribution points of a plugin, in the order they were committed. */ +function pointsOf(plugin: PluginInfo): string[] { + return [...new Set(plugin.contributions.map((c) => c.point))]; +} + +function PointSection({ plugin, point }: { plugin: PluginInfo; point: string }) { + const ui = usePlugins((s) => s.ui); + const Details = uiPoint(point)?.Details; + const items = itemsOf(ui, point, plugin.id); + return ( + <Section title={pointTitle(point)}> + {Details && items.length > 0 ? ( + <Details plugin={plugin} items={items as never} /> + ) : ( + <ul className={styles.items}> + {plugin.contributions + .filter((c) => c.point === point) + .map((c, i) => ( + <li key={`${c.label}-${i}`} className={styles.item}> + <span className={styles.itemMain}>{c.label}</span> + </li> + ))} + </ul> + )} + </Section> + ); +} + +function CapabilityRow({ cap, children }: { cap: CapabilityInfo; children?: ReactNode }) { + return ( + <li className={styles.item}> + {children} + <span className={styles.itemMain}>{cap.description}</span> + <span className={styles.mono}>{cap.id}</span> + <Badge tone={RISK_LABEL[cap.risk].tone}>{RISK_LABEL[cap.risk].label}</Badge> + </li> + ); +} + +function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) { + if (plugin.capabilities.length === 0) { + return ( + <div className={styles.muted}> + <T>No special permissions.</T> + </div> + ); + } + const confirmed = plugin.status !== "needsConsent"; + return ( + <ul className={styles.items} aria-label={tx("Permissions")}> + {sortByRisk(plugin.capabilities).map((c) => ( + <CapabilityRow key={c.id} cap={c}> + <Checkbox + checked={c.granted} + disabled={busy || !confirmed} + onChange={(on) => void setPluginCapability(plugin.id, c.id, on)} + aria-label={c.granted ? tx`Revoke ${c.id}` : tx`Allow ${c.id}`} + /> + </CapabilityRow> + ))} + </ul> + ); +} + function InstalledDetails({ plugin }: { plugin: PluginInfo }) { const busy = usePlugins((s) => !!s.busy[plugin.id]); - const activeTheme = usePlugins((s) => s.activeTheme); const status = STATUS[plugin.status]; - const running = plugin.status === "active"; - const live = running || plugin.status === "static"; const [confirmUninstall, setConfirmUninstall] = useState(false); const overview = ( @@ -276,95 +350,41 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) { )} </dl> - {plugin.hasCode && ( - <Section title="Permissions"> - {plugin.permissions.length === 0 ? ( - <div className={styles.muted}> - <T>No special permissions.</T> - </div> - ) : ( - <ul className={styles.items}> - {plugin.permissions.map((p) => ( - <li key={p} className={styles.item}> - <span className={styles.itemMain}>{PERMISSION_TEXT[p] ?? p}</span> - <span className={styles.mono}>{p}</span> - </li> - ))} - </ul> - )} - </Section> + {plugin.status === "needsConsent" && ( + <div className={styles.consentNotice} role="note"> + <span> + {plugin.source === "installed" + ? tx("This version asks for new permissions. It stays off until you allow them.") + : tx("Plugins from a project folder run only after you allow them.")} + </span> + <Button size="small" variant="primary" disabled={busy} onClick={() => void grantPlugin(plugin.id)}> + <T>Review Permissions…</T> + </Button> + </div> )} - {plugin.commands.length > 0 && ( - <Section title="Commands"> - <ul className={styles.items}> - {plugin.commands.map((c) => { - const actionId = pluginActionId(plugin.id, c.id); - return ( - <li key={c.id} className={styles.item}> - <span className={styles.itemMain}>{c.title}</span> - <Kbd action={actionId} /> - <span className={styles.mono} title={tx("Action id (bind it in Keymap)")}> - {actionId} - </span> - <Button - size="small" - variant="ghost" - icon="run" - disabled={!running} - onClick={() => void runPluginCommand(plugin.id, c.id)} - aria-label={tx`Run ${c.title}`} - > - <T>Run</T> - </Button> - </li> - ); - })} - </ul> - </Section> - )} + {plugin.warnings.map((w) => ( + <div key={w} className={styles.muted}> + {w} + </div> + ))} - {plugin.themes.length > 0 && ( - <Section title="Themes"> - <ul className={styles.items}> - {plugin.themes.map((t) => { - const isActive = activeTheme?.pluginId === plugin.id && activeTheme.theme.id === t.id; - return ( - <li key={t.id} className={styles.item}> - <span className={styles.itemMain}>{t.name}</span> - <span className={styles.muted}>{t.kind === "dark" ? tx("Dark") : tx("Light")}</span> - {isActive ? ( - <Button size="small" variant="secondary" onClick={() => void selectPluginTheme(null, null)}> - <T>Use Default Theme</T> - </Button> - ) : ( - <Button - size="small" - variant="ghost" - disabled={!live} - onClick={() => void selectPluginTheme(plugin.id, t.id)} - aria-label={tx`Apply theme ${t.name}`} - > - <T>Apply</T> - </Button> - )} - </li> - ); - })} - </ul> - </Section> - )} + <Section title="Permissions"> + <CapabilityList plugin={plugin} busy={busy} /> + </Section> - {plugin.languages.length > 0 && ( - <Section title="Languages"> - <ul className={styles.items}> - {plugin.languages.map((l) => ( - <li key={l.id} className={styles.item}> - <span className={styles.itemMain}>{l.name}</span> - <span className={styles.mono}>{l.extensions.map((e) => `.${e}`).join(" ") || l.id}</span> - </li> - ))} - </ul> + {pointsOf(plugin).map((point) => ( + <PointSection key={point} plugin={plugin} point={point} /> + ))} + + {plugin.hasCode && plugin.stats.activations > 0 && ( + <Section title="Statistics"> + <div className={styles.muted}> + {tx`Activated ${plugin.stats.activations}× (last in ${(plugin.stats.activationMs ?? 0).toFixed(1)} ms), ${plugin.stats.calls} calls, ${plugin.stats.errors} errors, slowest ${(plugin.stats.maxCallMs ?? 0).toFixed(1)} ms`} + </div> + <div className={styles.muted}> + {tx`Recent calls p95 ${(plugin.stats.p95CallMs ?? 0).toFixed(1)} ms; ${plugin.stats.crashes} crashes, ${plugin.stats.slowCalls} editor results too late, reclaimed ${plugin.stats.reclaims}× when idle`} + </div> </Section> )} </> @@ -380,11 +400,15 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) { <div className={styles.titleLine}> <span className={styles.version}>{plugin.version}</span> <Badge tone={status.tone}>{status.label}</Badge> - {plugin.source === "dev" && ( - <Badge tone="warning"> - <T>Dev plugin</T> - </Badge> + {plugin.source !== "installed" && ( + <Badge tone="warning">{plugin.source === "linked" ? tx("Linked plugin") : tx("Project plugin")}</Badge> )} + {plugin.source === "installed" && + (plugin.publisher ? ( + <Badge tone="success">{tx`Signed by ${plugin.publisher}`}</Badge> + ) : ( + <Badge tone="neutral">{tx("Unsigned")}</Badge> + ))} </div> </div> <div className={styles.headerActions}> @@ -404,6 +428,11 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) { disabled={busy} onClick={() => void reloadPlugin(plugin.id)} /> + {plugin.previousVersion && ( + <Button size="small" disabled={busy} onClick={() => void rollBackPlugin(plugin.id)}> + {tx`Roll Back to ${plugin.previousVersion}`} + </Button> + )} {plugin.source === "installed" && (confirmUninstall ? ( <> @@ -433,11 +462,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) { } function CatalogDetails({ entry }: { entry: CatalogPlugin }) { - const contributes = [ - entry.commands.length && `${entry.commands.length} ${entry.commands.length === 1 ? "command" : "commands"}`, - entry.themeCount && `${entry.themeCount} ${entry.themeCount === 1 ? "theme" : "themes"}`, - entry.languageCount && `${entry.languageCount} ${entry.languageCount === 1 ? "language" : "languages"}`, - ].filter(Boolean); + const contributes = entry.contributions.map((c) => `${pointTitle(c.point)}: ${c.count}`); const overview = ( <> {entry.problem && ( @@ -470,14 +495,11 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) { </> )} </dl> - {entry.permissions.length > 0 && ( + {entry.capabilities.length > 0 && ( <Section title="Permissions"> <ul className={styles.items}> - {entry.permissions.map((p) => ( - <li key={p} className={styles.item}> - <span className={styles.itemMain}>{PERMISSION_TEXT[p] ?? p}</span> - <span className={styles.mono}>{p}</span> - </li> + {sortByRisk(entry.capabilities).map((c) => ( + <CapabilityRow key={c.id} cap={c} /> ))} </ul> </Section> @@ -504,10 +526,11 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) { ); } -function gearMenu(plugins: readonly PluginInfo[], install: () => void): MenuItem[] { +function gearMenu(plugins: readonly PluginInfo[], install: (packageFile: boolean) => void): MenuItem[] { const installed = plugins.filter((p) => p.source === "installed"); return [ - { label: "Install Plugin from Disk…", icon: "package", run: install }, + { label: "Install Plugin from Disk…", icon: "package", run: () => install(false) }, + { label: "Install Plugin from Package File…", run: () => install(true) }, { kind: "separator" }, { label: "Disable All Downloaded Plugins", @@ -526,13 +549,127 @@ function gearMenu(plugins: readonly PluginInfo[], install: () => void): MenuItem run: () => { void refreshPlugins(); void refreshCatalog(); + void refreshMarket(true); }, }, ]; } +/** The marketplace index URL, editable in place. */ +function MarketUrlBar() { + const current = usePlugins((s) => s.marketUrl); + const [value, setValue] = useState(current ?? ""); + useEffect(() => setValue(current ?? ""), [current]); + return ( + <form + className={styles.marketBar} + onSubmit={(e) => { + e.preventDefault(); + void setMarketUrl(value); + }} + > + <Input + value={value} + placeholder="Marketplace index URL (https://…/index.json or a folder path)" + aria-label="Marketplace index URL" + onChange={(e) => setValue(e.target.value)} + /> + <IconButton icon="refresh" title="Refresh Marketplace" type="submit" /> + </form> + ); +} + +function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) { + const update = entry.installed && compareVersions(entry.version, entry.installed) > 0; + return ( + <li className={clsx(styles.row, selected && styles.rowSelected)}> + <button + type="button" + className={styles.rowMain} + aria-current={selected || undefined} + onClick={onSelect} + title={entry.description || entry.name} + > + <PluginIcon /> + <span className={styles.rowText}> + <span className={styles.rowName}>{entry.name}</span> + <span className={styles.rowMeta}> + <span className={styles.rowVendor}>{entry.publisher}</span> + <span>{entry.version}</span> + </span> + </span> + </button> + {update ? ( + <Badge tone="accent">{tx("Update")}</Badge> + ) : entry.installed ? ( + <Badge tone="neutral">{tx("Installed")}</Badge> + ) : null} + </li> + ); +} + +function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalled: (id: string) => void }) { + const installing = usePlugins((s) => s.installing); + const update = entry.installed && compareVersions(entry.version, entry.installed) > 0; + const install = async () => { + const id = await installFromMarket(entry.id); + if (id) onInstalled(id); + }; + return ( + <div className={styles.details} aria-label={`${entry.name} details`}> + <header className={styles.header}> + <PluginIcon large /> + <div className={styles.headerText}> + <h2 className={styles.title}>{entry.name}</h2> + <div className={styles.authors}>{entry.authors?.length ? vendorOf(entry.authors) : entry.publisher}</div> + <div className={styles.titleLine}> + <span className={styles.version}>{entry.version}</span> + <Badge tone="success">{tx`Signed by ${entry.publisher}`}</Badge> + </div> + </div> + <div className={styles.headerActions}> + {(!entry.installed || update) && ( + <Button size="small" variant="primary" disabled={installing} onClick={() => void install()}> + {update ? tx`Update to ${entry.version}` : tx("Install")} + </Button> + )} + </div> + </header> + <div className={styles.marketOverview}> + {entry.description && <p className={styles.description}>{entry.description}</p>} + <dl className={styles.facts}> + <dt> + <T>Id</T> + </dt> + <dd className={styles.mono}>{entry.id}</dd> + {entry.installed && ( + <> + <dt> + <T>Installed</T> + </dt> + <dd>{entry.installed}</dd> + </> + )} + </dl> + {(entry.capabilities?.length ?? 0) > 0 && ( + <Section title="Permissions"> + <ul className={styles.items}> + {(entry.capabilities ?? []).map((c) => ( + <li key={c} className={clsx(styles.item, styles.mono)}> + {c} + </li> + ))} + </ul> + </Section> + )} + </div> + </div> + ); +} + export function PluginsPage() { - const { plugins, loaded, loading, error, installing, catalog, catalogError } = usePlugins(); + const { plugins, loaded, loading, error, installing, catalog, catalogError, market, marketUrl, marketError } = + usePlugins(); const [tab, setTab] = useState<Tab>("installed"); const [query, setQuery] = useState(""); const [selectedId, setSelectedId] = useState<string | null>(null); @@ -541,18 +678,30 @@ export function PluginsPage() { useEffect(() => { void refreshPlugins(); void refreshCatalog(); + void refreshMarket(false); }, []); + const [selectedMarketId, setSelectedMarketId] = useState<string | null>(null); + const marketShown = useMemo( + () => + (market ?? []).filter((m) => { + const q = query.trim().toLowerCase(); + return !q || matches([m.name, m.id, m.description, m.publisher, ...(m.authors ?? [])], q); + }), + [market, query], + ); + const selectedMarket = marketShown.find((m) => m.id === selectedMarketId) ?? null; const shown = useMemo(() => filterPlugins(plugins, query), [plugins, query]); const downloaded = shown.filter((p) => p.source === "installed"); const dev = shown.filter((p) => p.source === "dev"); + const linked = shown.filter((p) => p.source === "linked"); const selected = shown.find((p) => p.id === selectedId) ?? shown[0] ?? null; const catalogShown = useMemo(() => filterCatalog(catalog ?? [], query), [catalog, query]); const selectedEntry = catalogShown.find((c) => c.id === selectedCatalogId) ?? catalogShown[0] ?? null; - const install = async () => { - const id = await installFromDisk(); + const install = async (packageFile = false) => { + const id = await installFromDisk(undefined, packageFile); if (id) { setTab("installed"); setSelectedId(id); @@ -563,7 +712,7 @@ export function PluginsPage() { const r = e.currentTarget.getBoundingClientRect(); showContextMenu( { x: r.left, y: r.bottom + 2 }, - gearMenu(plugins, () => void install()), + gearMenu(plugins, (packageFile) => void install(packageFile)), ); }; @@ -616,6 +765,15 @@ export function PluginsPage() { onSelect={() => setSelectedId(p.id)} /> ))} + {linked.length > 0 && <ListHeader title="Linked for development" count={linked.length} />} + {linked.map((p) => ( + <InstalledRow + key={p.id} + plugin={p} + selected={p.id === selected?.id} + onSelect={() => setSelectedId(p.id)} + /> + ))} {dev.length > 0 && <ListHeader title="Project (.openidea/plugins)" count={dev.length} />} {dev.map((p) => ( <InstalledRow @@ -633,17 +791,35 @@ export function PluginsPage() { </ul> ) : ( <ul className={styles.list} aria-label={tx("Available plugins")}> - <li className={styles.notice} role="note"> - <Icon name="info" size={14} /> - <span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span> + <li className={styles.marketItem}> + <MarketUrlBar /> </li> + {!marketUrl && ( + <li className={styles.notice} role="note"> + <Icon name="info" size={14} /> + <span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span> + </li> + )} + {marketError && <li className={clsx(styles.listEmpty, styles.errorText)}>{marketError}</li>} + {marketShown.length > 0 && <ListHeader title="Marketplace" count={marketShown.length} />} + {marketShown.map((m) => ( + <MarketRow + key={`market:${m.id}`} + entry={m} + selected={m.id === selectedMarket?.id} + onSelect={() => setSelectedMarketId(m.id)} + /> + ))} {catalogShown.length > 0 && <ListHeader title="Local" count={catalogShown.length} />} {catalogShown.map((c) => ( <CatalogRow key={c.id} entry={c} selected={c.id === selectedEntry?.id} - onSelect={() => setSelectedCatalogId(c.id)} + onSelect={() => { + setSelectedMarketId(null); + setSelectedCatalogId(c.id); + }} /> ))} {catalog && catalogShown.length === 0 && ( @@ -666,6 +842,15 @@ export function PluginsPage() { <T>inside your project.</T> </EmptyState> ) + ) : selectedMarket ? ( + <MarketDetails + key={`market:${selectedMarket.id}`} + entry={selectedMarket} + onInstalled={(id) => { + setTab("installed"); + setSelectedId(id); + }} + /> ) : selectedEntry ? ( <CatalogDetails key={selectedEntry.id} entry={selectedEntry} /> ) : ( diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 7e637357a..7dae1fc7e 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -5,28 +5,37 @@ import { EditorSelection, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import type { PluginInfo } from "@/bindings"; import { _setActiveEditor } from "@/editor/api"; -import { executeAction, getAction } from "@/platform/actions"; +import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions"; +import { useProject } from "@/platform/project"; import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme"; import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend"; import { applyPluginTheme, - applyTextToActiveEditor, PLUGIN_THEME_STYLE_ID, handlePluginEvent, pluginActionId, - pluginActions, startPluginBridge, } from "../bridge"; +import { iconResolver } from "../iconThemes"; +import { markerIcon, placeMarkers } from "../lineMarkerGutter"; +import { SecretsDetails } from "../secrets"; +import { usePluginStatusItems } from "../runtimeUi"; +import { useTasks } from "@/workbench/tasks"; +import { defineBuiltinUiPoints } from "../points"; +import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection"; import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage"; import { compareVersions, + installFromDisk, refreshPlugins, runPluginCommand, selectPluginTheme, setAllInstalledEnabled, + setConsentPrompt, setPluginEnabled, usePlugins, + type ConsentRequest, } from "../store"; type Note = Parameters<NotificationService["notify"]>[0]; @@ -48,10 +57,23 @@ function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): PluginIn status: "active", error: null, hasCode: true, - permissions: [], - commands: [], - languages: [], - themes: [], + capabilities: [], + contributions: [], + activation: [], + warnings: [], + stats: { + activations: 0, + activationMs: 0, + calls: 0, + errors: 0, + maxCallMs: 0, + p95CallMs: 0, + slowCalls: 0, + crashes: 0, + reclaims: 0, + }, + publisher: null, + previousVersion: null, ...partial, }; } @@ -63,13 +85,17 @@ function editor(doc: string, anchor: number, head = anchor, readOnly = false) { return view; } +let undefinePoints: () => void = () => {}; + beforeEach(() => { + undefinePoints = defineBuiltinUiPoints(); notes = []; provideNotificationService({ notify: (n) => void notes.push(n) }); backend = createMemoryPluginsBackend(); setPluginsBackend(backend); usePlugins.setState({ plugins: [], + ui: [], activeTheme: null, loaded: false, loading: false, @@ -82,6 +108,9 @@ beforeEach(() => { }); afterEach(() => { + clearUiContributions(); + undefinePoints(); + setConsentPrompt(null); setPluginsBackend(null); _setActiveEditor(null); view?.destroy(); @@ -90,31 +119,124 @@ afterEach(() => { }); describe("plugin actions", () => { - it("maps commands of running plugins to plugin.<plugin>.<command> actions", () => { - const actions = pluginActions([ - plugin({ id: "a", name: "Alpha", commands: [{ id: "go", title: "Alpha: Go", category: null }] }), - plugin({ id: "b", status: "disabled", commands: [{ id: "x", title: "X", category: "Cat" }] }), - plugin({ id: "c", status: "failed", commands: [{ id: "y", title: "Y", category: null }] }), - ]); - expect(actions.map((a) => a.id)).toEqual(["plugin.a.go"]); - expect(actions[0]!.category).toBe("Alpha"); + it("projects command contributions to plugin.<plugin>.<command> actions", () => { + const cmd = (pluginId: string, id: string, extra: Record<string, unknown> = {}) => ({ + pluginId, + point: "commands", + key: id, + decl: { id, title: `T ${id}`, ...extra }, + }); + syncUiContributions( + [cmd("a", "go"), cmd("a", "when", { when: "editorFocus", category: "Cat" }), cmd("gone", "x")], + [plugin({ id: "a", name: "Alpha" })], + ); + expect(getAction("plugin.a.go")).toMatchObject({ title: "T go", category: "Alpha" }); + expect(getAction("plugin.a.when")).toMatchObject({ when: "editorFocus", category: "Cat" }); + expect(getAction("plugin.gone.x")).toBeUndefined(); expect(pluginActionId("p", "c.d")).toBe("plugin.p.c.d"); + // Changed declarations are re-projected; removed ones disposed. + syncUiContributions([cmd("a", "go", { title: "Renamed" })], [plugin({ id: "a", name: "Alpha" })]); + expect(getAction("plugin.a.go")?.title).toBe("Renamed"); + expect(getAction("plugin.a.when")).toBeUndefined(); + }); + + it("isolates a failing projection (a defect) from the other items", () => { + const errors: unknown[] = []; + const original = console.error; + console.error = (...args: unknown[]) => void errors.push(args); + const live: string[] = []; + const undefine = defineUiPoint<{ n: number }>("test.items", { + project: ({ decl }) => { + if (decl.n === 2) throw new Error("bug"); + live.push(String(decl.n)); + return () => live.splice(live.indexOf(String(decl.n)), 1); + }, + }); + const item = (n: number) => ({ pluginId: "a", point: "test.items", key: String(n), decl: { n } }); + syncUiContributions([item(1), item(2), item(3)], [plugin({ id: "a" })]); + expect(live).toEqual(["1", "3"]); + expect(errors).toHaveLength(1); + undefine(); + expect(live).toEqual([]); + console.error = original; }); it("keeps the registry in sync and runs commands through the backend", async () => { + // The bridge defines the points itself (main window). + undefinePoints(); + undefinePoints = () => {}; const stop = startPluginBridge(); await refreshPlugins(); expect(getAction("plugin.hello.insert-date")?.title).toBe("Hello: Insert Date"); expect(getAction("plugin.hello.word-count")).toBeDefined(); - const v = editor("ab", 1); + // Edits come back as document patches; here the command's result shows it ran. + const ran: unknown[] = []; + const run = backend.runCommand; + backend.runCommand = async (...args) => { + const out = await run(...args); + ran.push(out); + return out; + }; + editor("ab", 1); await act(() => executeAction("plugin.hello.insert-date")); - expect(v.state.doc.toString()).toMatch(/^a\d{4}-\d{2}-\d{2}b$/); + expect(ran).toEqual([expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)]); await setPluginEnabled("hello", false); expect(getAction("plugin.hello.insert-date")).toBeUndefined(); stop(); }); + + it("reports actions to on-event:action-performed plugins once the project opens and when plugins change", async () => { + // Like `authorize_command`: refused until the window's project is open and trusted. + let kinds: Awaited<ReturnType<PluginsBackend["eventKinds"]>> = []; + const posted: unknown[] = []; + setPluginsBackend({ + ...backend, + eventKinds: async () => { + if (!useProject.getState().project?.trusted) throw new Error("no project is open in this window"); + return kinds; + }, + postEvents: async (events) => void posted.push(...events), + }); + const offAction = registerAction({ id: "test.columnMode", title: "Column Mode", run: () => {} }); + const performed = (n: number) => + Array.from({ length: n }, () => ({ + kind: "actionPerformed", + value: { action: "test.columnMode", title: "Column Mode", source: "menu", shortcut: null }, + })); + undefinePoints(); + undefinePoints = () => {}; + kinds = ["action-performed"]; + const stop = startPluginBridge(); + try { + // Asked at startup, before the project is open: refused. + await new Promise((r) => setTimeout(r, 20)); + await executeActionFrom("menu", "test.columnMode"); + await new Promise((r) => setTimeout(r, 600)); + expect(posted).toEqual([]); + + useProject.setState({ project: { root: "/p", trusted: true } as never }); + await new Promise((r) => setTimeout(r, 20)); + await executeActionFrom("menu", "test.columnMode"); + await waitFor(() => expect(posted).toEqual(performed(1))); + + // Declared kinds change with the installed plugins (`commandsChanged`). + kinds = []; + handlePluginEvent({ type: "commandsChanged" }); + await new Promise((r) => setTimeout(r, 60)); + await executeActionFrom("menu", "test.columnMode"); + kinds = ["action-performed"]; + handlePluginEvent({ type: "commandsChanged" }); + await new Promise((r) => setTimeout(r, 60)); + await executeActionFrom("menu", "test.columnMode"); + await waitFor(() => expect(posted).toEqual(performed(2))); + } finally { + stop(); + offAction(); + useProject.setState({ project: null }); + } + }); }); describe("store", () => { @@ -150,9 +272,26 @@ describe("store", () => { expect(usePlugins.getState().activeTheme).toBeNull(); }); - it("filters by name, id, author and command title", () => { + it("asks for consent before installing and passes the answer on", async () => { + const asked: ConsentRequest[] = []; + setConsentPrompt(async (req) => { + asked.push(req); + return { optional: [] }; + }); + setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog())); + expect(await installFromDisk("plugins/hello")).toBe("hello"); + expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install", unsigned: true }); + expect(asked[0]!.capabilities.map((c) => c.id)).toContain("editor.write"); + + setConsentPrompt(async () => null); + setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog())); + expect(await installFromDisk("plugins/hello")).toBeNull(); + expect(usePlugins.getState().plugins).toEqual([]); + }); + + it("filters by name, id, author and contribution", () => { const list = [ - plugin({ id: "a", name: "Alpha", commands: [{ id: "c", title: "Count Words", category: null }] }), + plugin({ id: "a", name: "Alpha", contributions: [{ point: "commands", label: "Count Words" }] }), plugin({ id: "b", name: "Beta", authors: ["Zed"] }), ]; expect(filterPlugins(list, "count").map((p) => p.id)).toEqual(["a"]); @@ -199,22 +338,44 @@ describe("bridge events", () => { useThemeOverride.setState({ theme: null }); }); - it("inserts at the cursor and replaces selections", () => { - const v = editor("hello world", 6, 11); - handlePluginEvent({ type: "replaceSelection", pluginId: "p", text: "there" }); - expect(v.state.doc.toString()).toBe("hello there"); - expect(v.state.selection.main).toMatchObject({ from: 6, to: 11 }); - handlePluginEvent({ type: "insertText", pluginId: "p", text: "!" }); - expect(v.state.doc.toString()).toBe("hello !"); - expect(v.state.selection.main.head).toBe(7); + it("runs commands with the focused editor's path, language and UTF-16 selections", async () => { + const calls: unknown[] = []; + setPluginsBackend({ + ...backend, + runCommand: async (...args) => { + calls.push(args); + return null; + }, + }); + editor("ab\n😀cd", 3, 7); + await runPluginCommand("p", "c"); + expect(calls).toEqual([ + [ + "p", + "c", + null, + { + path: "/p/a.txt", + language: "plaintext", + selections: [{ start: { line: 1, col: 0 }, end: { line: 1, col: 4 } }], + }, + ], + ]); }); - it("warns instead of editing read-only or missing editors", () => { - expect(applyTextToActiveEditor("x", false, "p")).toBe(false); - const v = editor("abc", 0, 0, true); - expect(applyTextToActiveEditor("x", false, "p")).toBe(false); - expect(v.state.doc.toString()).toBe("abc"); - expect(notes.map((n) => n.level)).toEqual(["warning", "warning"]); + it("shows status items and tasks of running plugin code", () => { + const item = { text: "main ↑1", tooltip: "Branch", command: "refresh" }; + handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item }); + expect(usePluginStatusItems.getState().items.get("git/branch")).toEqual({ pluginId: "git", item }); + handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item: null }); + expect(usePluginStatusItems.getState().items.size).toBe(0); + + handlePluginEvent({ type: "taskStarted", pluginId: "git", taskId: "7", title: "Fetching", cancellable: true }); + handlePluginEvent({ type: "taskProgress", pluginId: "git", taskId: "7", done: 1, total: 4, message: "origin" }); + const task = useTasks.getState().tasks.find((t) => t.id === "plugin:7")!; + expect([task.title, task.fraction, task.message, !!task.cancel]).toEqual(["Fetching", 0.25, "origin", true]); + handlePluginEvent({ type: "taskEnded", pluginId: "git", taskId: "7" }); + expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true); }); it("shows plugin notifications and tracks theme changes", () => { @@ -235,11 +396,12 @@ describe("PluginsPage", () => { // Sorted as returned; first one selected by default. expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Hello"); expect(screen.getByText("plugin.hello.insert-date")).toBeInTheDocument(); - expect(screen.getByText("Insert text into the active editor")).toBeInTheDocument(); + expect(screen.getByText("Change text in the editor")).toBeInTheDocument(); + expect(screen.getByText("Medium")).toBeInTheDocument(); await user.click(screen.getByRole("checkbox", { name: "Disable Hello" })); await waitFor(() => expect(screen.getByRole("checkbox", { name: "Enable Hello" })).toBeInTheDocument()); - expect(screen.getByRole("button", { name: "Run Hello: Insert Date" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Run Hello: Insert Date" })).not.toBeInTheDocument(); await user.click(within(list).getByText("Harbor Night Theme")); await user.click(screen.getByRole("button", { name: "Apply theme Harbor Night" })); @@ -248,7 +410,7 @@ describe("PluginsPage", () => { await user.type(screen.getByLabelText("Filter plugins"), "toy"); await waitFor(() => expect(within(list).getAllByRole("listitem")).toHaveLength(1)); expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Toy Language"); - expect(screen.getByText(".toy")).toBeInTheDocument(); + expect(screen.getByText("Toy (.toy)")).toBeInTheDocument(); }); it("restarts a failed plugin instead of disabling it", async () => { @@ -256,7 +418,7 @@ describe("PluginsPage", () => { const user = userEvent.setup(); render(<PluginsPage />); await user.click(await screen.findByRole("button", { name: "Restart" })); - await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "active" })); + await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "inactive" })); }); it("uninstalls after confirmation", async () => { @@ -299,6 +461,140 @@ describe("PluginsPage", () => { }); }); +describe("secrets", () => { + it("stores and removes a declared secret without showing its value", async () => { + const user = userEvent.setup(); + const items = [{ pluginId: "ai", point: "secrets", key: "api-key", decl: { name: "api-key", label: "API key" } }]; + render(<SecretsDetails plugin={plugin({ id: "ai" })} items={items} />); + expect(await screen.findByText("Not set")).toBeInTheDocument(); + await user.type(screen.getByLabelText("Value of API key"), "sk-1{Enter}"); + await waitFor(() => expect(screen.getByText("Stored")).toBeInTheDocument()); + expect(screen.getByLabelText("Value of API key")).toHaveValue(""); + expect(await backend.secrets("ai")).toEqual([{ name: "api-key", set: true }]); + await user.click(screen.getByRole("button", { name: "Remove API key" })); + await waitFor(() => expect(screen.getByText("Not set")).toBeInTheDocument()); + expect(await backend.secrets("ai")).toEqual([]); + }); +}); + +describe("plugin line markers", () => { + it("places markers on existing lines and maps their icons", () => { + const doc = EditorState.create({ doc: "a\nbb\nccc" }).doc; + const marker = (line: number, icon: string | null) => ({ pluginId: "m", line, icon, tooltip: "t", target: null }); + expect(placeMarkers(doc, [marker(1, "run"), marker(7, null)]).map((m) => m.pos)).toEqual([2]); + expect(markerIcon("run")).toBe("gutter-run"); + expect(markerIcon("implemented")).toBe("gutter-implemented"); + expect(markerIcon(null)).toBe("gutter-implementing"); + expect(markerIcon("unknown")).toBe("gutter-implementing"); + }); +}); + +describe("marketplace", () => { + it("lists the index's plugins and installs one through the install page", async () => { + const asked: ConsentRequest[] = []; + setConsentPrompt(async (req) => { + asked.push(req); + return { optional: [] }; + }); + const base = createMemoryPluginsBackend([], sampleCatalog()); + const downloads: string[] = []; + setPluginsBackend({ + ...base, + marketUrl: async () => "https://plugins.example/index.json", + market: async () => [ + { + id: "hello", + name: "Hello", + version: "1.0.0", + description: "From the marketplace", + authors: [], + publisher: "acme", + download: "hello.oiplugin", + digest: "x", + capabilities: ["editor.write"], + installed: null, + }, + ], + // The memory backend installs its sample packages by path. + marketDownload: async (id) => { + downloads.push(id); + return "plugins/hello"; + }, + }); + const user = userEvent.setup(); + render(<PluginsPage />); + await user.click(screen.getByRole("tab", { name: /Available/ })); + const list = await screen.findByRole("list", { name: "Available plugins" }); + await waitFor(() => expect(within(list).getByText("Marketplace")).toBeInTheDocument()); + expect(within(list).queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument(); + await user.click(within(list).getAllByText("Hello")[0]!); + expect(screen.getByText("Signed by acme")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Install" })); + await waitFor(() => expect(usePlugins.getState().plugins.map((p) => p.id)).toEqual(["hello"])); + expect(downloads).toEqual(["hello"]); + expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install" }); + }); + + it("offers to roll back an update", async () => { + const base = createMemoryPluginsBackend([ + plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }), + ]); + const rolled: string[] = []; + setPluginsBackend({ + ...base, + rollBack: async (id) => { + rolled.push(id); + return plugin({ id, name: "P", version: "1.0.0" }); + }, + }); + const user = userEvent.setup(); + render(<PluginsPage />); + expect(await screen.findByText("Unsigned")).toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: "Roll Back to 1.0.0" })); + await waitFor(() => expect(rolled).toEqual(["p"])); + expect(notes.at(-1)).toMatchObject({ title: "Rolled back P to 1.0.0" }); + }); +}); + +describe("icon themes", () => { + it("resolves file names, then the longest extension, then the default icon", () => { + const theme = iconResolver( + { + file: "file", + fileNames: { "cargo.toml": "cargo" }, + fileExtensions: { "d.ts": "dts", ts: "ts", toml: "toml" }, + }, + (icon) => `ext:p/t/${icon}`, + (name) => name !== "ext:p/t/toml", + ); + expect(theme.file("Cargo.toml")).toBe("ext:p/t/cargo"); + expect(theme.file("a.d.ts")).toBe("ext:p/t/dts"); + expect(theme.file("a.ts")).toBe("ext:p/t/ts"); + expect(theme.file("x.toml")).toBe("ext:p/t/file"); + // No folder icons in this theme: folders keep the built-in ones. + expect(theme.folder("src", false)).toBeNull(); + }); + + it("resolves folders by name, then the default (expanded) folder icon", () => { + const theme = iconResolver( + { + file: null, + folder: "folder", + folderExpanded: "folder-open", + fileNames: {}, + fileExtensions: {}, + folderNames: { src: "folder-src" }, + }, + (icon) => `ext:p/t/${icon}`, + () => true, + ); + expect(theme.folder("SRC", true)).toBe("ext:p/t/folder-src"); + expect(theme.folder("lib", false)).toBe("ext:p/t/folder"); + expect(theme.folder("lib", true)).toBe("ext:p/t/folder-open"); + expect(theme.file("a.txt")).toBeNull(); + }); +}); + describe("catalog helpers", () => { it("compares versions numerically", () => { expect(compareVersions("1.10.0", "1.9.2")).toBe(1); diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx new file mode 100644 index 000000000..a78fce47e --- /dev/null +++ b/src/features/plugins/__tests__/views.test.tsx @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { View, ViewEvent } from "@/bindings"; +import { registerAction } from "@/platform/actions"; +import { bindingsFor, effectiveBindings, registerExtensionBindings, useKeymap } from "@/platform/keymap"; +import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend"; +import { defineBuiltinUiPoints } from "../points"; +import { clearUiContributions, syncUiContributions } from "../projection"; +import { pluginMenuItems, withPluginSetting } from "../uiPoints"; +import { matchesPattern } from "../views/customEditor"; +import { FRAME_CSP, frameDocument } from "../views/frame"; +import { PluginView, treeNodes, wantedViewport } from "../views/PluginView"; +import { bumpView } from "../views/viewStore"; + +let undefinePoints: () => void = () => {}; + +beforeEach(() => { + undefinePoints = defineBuiltinUiPoints(); +}); + +afterEach(() => { + clearUiContributions(); + undefinePoints(); + setPluginsBackend(null); +}); + +function backendWith(render: (view: string) => View, events: ViewEvent[] = []): PluginsBackend { + const base = createMemoryPluginsBackend([]); + return { + ...base, + renderView: vi.fn(async (_p: string, view: string) => render(view)), + viewEvent: vi.fn(async (_p: string, _v: string, e: ViewEvent) => void events.push(e)), + }; +} + +describe("view helpers", () => { + it("builds the tree from the flat item list", () => { + const item = (key: string, parent: string | null = null) => ({ + key, + parent, + label: key, + icon: null, + detail: null, + expandable: false, + expanded: false, + }); + const roots = treeNodes([item("a"), item("a/1", "a"), item("b"), item("x", "missing")]); + expect(roots.map((n) => n.item.key)).toEqual(["a", "b", "x"]); + expect(roots[0]!.children.map((n) => n.item.key)).toEqual(["a/1"]); + }); + + it("asks for the visible table rows plus some around them", () => { + expect(wantedViewport(0, 20, 10)).toEqual({ firstRow: 0, rows: 10 }); + expect(wantedViewport(1000, 20, 5000)).toEqual({ firstRow: 950, rows: 120 }); + }); + + it("confines HTML bodies: CSP without network, a message bridge", () => { + const doc = frameDocument("<p>hi</p>", true); + expect(doc).toContain(`content="${FRAME_CSP}"`); + expect(FRAME_CSP).toContain("default-src 'none'"); + expect(FRAME_CSP).not.toMatch(/https?:|connect-src/); + expect(doc).toContain("window.openidea = Object.freeze"); + expect(doc.indexOf("Content-Security-Policy")).toBeLessThan(doc.indexOf("<p>hi</p>")); + }); + + it("matches custom editor file name patterns", () => { + expect(matchesPattern("*.csv", "People.CSV")).toBe(true); + expect(matchesPattern("*.{csv,tsv}", "a.tsv")).toBe(true); + expect(matchesPattern("data?.csv", "data1.csv")).toBe(true); + expect(matchesPattern("*.csv", "a.csv.bak")).toBe(false); + }); + + it("stores plugin settings under the plugin's id, dropping defaults", () => { + const one = withPluginSetting(undefined, "p.x", "watch", false); + expect(one).toEqual({ "p.x": { watch: false } }); + expect(withPluginSetting(one, "p.x", "watch", null)).toEqual({}); + }); +}); + +describe("plugin keymaps", () => { + afterEach(() => useKeymap.setState({ extensions: {}, overrides: {} })); + + it("adds a plugin's shortcut below the IDE's, never taking a used keystroke", () => { + const taken = effectiveBindings()[0]!; + const offFree = registerExtensionBindings("plugin.p.free", ["Ctrl+Alt+Shift+F11"]); + const offTaken = registerExtensionBindings("plugin.p.taken", [taken[0]]); + expect(bindingsFor("plugin.p.free")).toEqual(["Ctrl+Alt+Shift+F11"]); + expect(bindingsFor("plugin.p.taken")).toEqual([]); + useKeymap.setState({ overrides: { "plugin.p.free": [] } }); + expect(bindingsFor("plugin.p.free")).toEqual([]); + offFree(); + offTaken(); + expect(useKeymap.getState().extensions).toEqual({}); + }); +}); + +describe("plugin menus", () => { + it("projects context menu entries for their command's action", () => { + const off = registerAction({ id: "plugin.p.check", title: "Check", run: () => {} }); + const plugin = { + id: "p", + name: "P", + status: "active", + enabled: true, + } as unknown as Parameters<typeof syncUiContributions>[1][number]; + syncUiContributions( + [ + { + pluginId: "p", + point: "menus", + key: "projectView.context/check", + decl: { menu: "projectView.context", command: "check" }, + }, + { + pluginId: "p", + point: "menus", + key: "editor.context/check", + decl: { menu: "editor.context", command: "check", when: "false" }, + }, + ], + [plugin], + ); + expect(pluginMenuItems("projectView", ["/p/a.java"])).toEqual([ + { kind: "action", action: "plugin.p.check", arg: "/p/a.java" }, + ]); + expect(pluginMenuItems("editor", ["/p/a.java"])).toEqual([]); + off(); + }); +}); + +describe("PluginView", () => { + it("renders a tree and sends expand / activate", async () => { + const events: ViewEvent[] = []; + const tree: View = { + toolbar: [{ id: "refresh", label: "Refresh", icon: "refresh", enabled: true }], + body: { + kind: "tree", + value: [ + { + key: "ns", + parent: null, + label: "default", + icon: null, + detail: "3 pods", + expandable: true, + expanded: false, + }, + ], + }, + status: "1 namespace", + }; + setPluginsBackend(backendWith(() => tree, events)); + const user = userEvent.setup(); + render(<PluginView pluginId="k8s" view="clusters" label="Kubernetes" />); + expect(await screen.findByText("default")).toBeInTheDocument(); + expect(screen.getByText("1 namespace")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(events).toContainEqual({ kind: "button", id: "refresh" })); + }); + + it("edits a table cell and renders again when the plugin refreshes", async () => { + const events: ViewEvent[] = []; + let city = "London"; + const table = (): View => ({ + toolbar: [], + body: { + kind: "table", + value: { columns: ["name", "city"], totalRows: 1, firstRow: 0, rows: [["Ada", city]], editable: true }, + }, + status: null, + }); + const backend = backendWith(table, events); + setPluginsBackend(backend); + const user = userEvent.setup(); + render(<PluginView pluginId="csv" view="table" doc={7} />); + await user.dblClick(await screen.findByText("London")); + const input = screen.getByLabelText("Cell value"); + await user.clear(input); + await user.type(input, "Paris{Enter}"); + await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" })); + expect(backend.viewEvent).toHaveBeenCalledWith("csv", "table", expect.anything(), 7); + city = "Paris"; + bumpView("csv", "table"); + expect(await screen.findByText("Paris")).toBeInTheDocument(); + }); + + it("shows a message when the plugin fails", async () => { + const base = createMemoryPluginsBackend([]); + setPluginsBackend({ ...base, renderView: () => Promise.reject(new Error("boom")) }); + render(<PluginView pluginId="x" view="v" />); + expect(await screen.findByText("boom")).toBeInTheDocument(); + }); +}); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 4030550d3..399225e48 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -4,26 +4,84 @@ * development and tests. */ import { Channel } from "@tauri-apps/api/core"; -import type { ActiveTheme, CatalogPlugin, PluginEvent, PluginInfo, PluginsSnapshot } from "@/bindings"; -import { tx } from "@/platform/i18n"; +import type { + ActiveTheme, + CapabilityInfo, + CatalogPlugin, + Consent, + EventKind, + InstallPreview, + MarketPlugin, + PluginEditor, + PluginEvent, + PluginInfo, + PluginsSnapshot, + PluginSecretState, + PluginStatusItem, + UiContribution, + View, + ViewEvent, + Viewport, + WorkbenchEvent, +} from "@/bindings"; import { call, commands, IpcError, isTauri } from "@/platform/ipc"; export interface PluginsBackend { list(): Promise<PluginsSnapshot>; setEnabled(id: string, enabled: boolean): Promise<void>; - installDir(path: string): Promise<PluginInfo>; + /** Validates a package for the install page; nothing is copied. */ + previewInstall(path: string): Promise<InstallPreview>; + installDir(path: string, consent: Consent): Promise<PluginInfo>; + /** Confirms a plugin waiting for consent. */ + grant(id: string, consent: Consent): Promise<PluginInfo>; + setCapability(id: string, capability: string, granted: boolean): Promise<PluginInfo>; + /** Validated assets (icon theme mapping and SVGs) whose id starts with `prefix`. */ + assets(pluginId: string, prefix: string): Promise<[string, string][]>; + /** `language` / `file` / `view` activation events. */ + activationEvent(kind: "language" | "file" | "view", value: string): Promise<void>; /** Plugin packages available locally (no network marketplace). */ catalog(): Promise<CatalogPlugin[]>; uninstall(id: string): Promise<void>; reload(id: string): Promise<PluginInfo>; - runCommand(pluginId: string, commandId: string, arg: string | null): Promise<string | null>; + /** Runs a command with the focused editor (path, language, selections) as its context. */ + runCommand( + pluginId: string, + commandId: string, + arg: string | null, + editor: PluginEditor | null, + ): Promise<string | null>; + /** Workbench events plugins subscribed to (see `eventKinds`). */ + postEvents(events: WorkbenchEvent[]): Promise<void>; + /** Kinds of workbench events plugins want; re-fetch on `subscriptionsChanged`. */ + eventKinds(): Promise<EventKind[]>; + /** Plugin status bar items (later changes arrive as `statusItemChanged`). */ + statusItems(): Promise<PluginStatusItem[]>; + cancelTask(taskId: string): Promise<void>; + /** Marketplace plugins: the index read again (`refresh`) or the last one read. */ + market(refresh: boolean): Promise<MarketPlugin[]>; + marketUrl(): Promise<string | null>; + setMarketUrl(url: string | null): Promise<void>; + /** Downloads and checks a marketplace plugin; the folder to install from. */ + marketDownload(id: string): Promise<string>; + /** Unpacks a `.oiplugin` package file; the folder to install from. */ + unpackPackage(path: string): Promise<string>; + /** Goes back to the version the last update replaced. */ + rollBack(id: string): Promise<PluginInfo>; + /** File picker for a package file; `null` when cancelled. */ + pickPackage(): Promise<string | null>; + /** Content of a plugin view (`doc`: the document of a custom editor). */ + renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>; + viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>; + /** Which of a plugin's declared secrets are stored (values never come back). */ + secrets(pluginId: string): Promise<PluginSecretState[]>; + /** Stores a secret (`null` removes it). */ + setSecret(pluginId: string, name: string, value: string | null): Promise<void>; setActiveFile(path: string | null): Promise<void>; setTheme(pluginId: string | null, themeId: string | null): Promise<ActiveTheme | null>; /** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */ setThemeFrame(color: string | null): Promise<void>; /** Folder picker; `null` when cancelled. */ - /** A package to install: a `.zip`, or the `plugin.toml` of a folder. */ - pickPackage(): Promise<string | null>; + pickFolder(): Promise<string | null>; /** Subscribes to backend plugin events (this window only). */ onEvent(handler: (e: PluginEvent) => void): () => void; } @@ -33,13 +91,53 @@ export const tauriPluginsBackend: PluginsBackend = { setEnabled: async (id, enabled) => { await call(commands.pluginSetEnabled(id, enabled)); }, - installDir: (path) => call(commands.pluginInstallDir(path)), + previewInstall: (path) => call(commands.pluginPreviewInstall(path)), + installDir: (path, consent) => call(commands.pluginInstallDir(path, consent)), + grant: (id, consent) => call(commands.pluginGrant(id, consent)), + setCapability: (id, capability, granted) => call(commands.pluginSetCapability(id, capability, granted)), + assets: (pluginId, prefix) => call(commands.pluginAssets(pluginId, prefix)), + activationEvent: async (kind, value) => { + await call(commands.pluginActivationEvent(kind, value)); + }, catalog: () => call(commands.pluginCatalog()), uninstall: async (id) => { await call(commands.pluginUninstall(id)); }, reload: (id) => call(commands.pluginReload(id)), - runCommand: (pluginId, commandId, arg) => call(commands.pluginRunCommand(pluginId, commandId, arg)), + runCommand: (pluginId, commandId, arg, editor) => call(commands.pluginRunCommand(pluginId, commandId, arg, editor)), + postEvents: async (events) => { + await commands.pluginPostEvents(events); + }, + eventKinds: () => commands.pluginEventKinds(), + statusItems: () => commands.pluginStatusItems(), + cancelTask: async (taskId) => { + await commands.pluginCancelTask(taskId); + }, + market: (refresh) => call(commands.pluginMarket(refresh)), + marketUrl: () => commands.pluginMarketUrl(), + setMarketUrl: async (url) => { + await commands.pluginSetMarketUrl(url); + }, + marketDownload: (id) => call(commands.pluginMarketDownload(id)), + unpackPackage: (path) => call(commands.pluginUnpackPackage(path)), + rollBack: (id) => call(commands.pluginRollBack(id)), + pickPackage: async () => { + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ + multiple: false, + title: "Install Plugin from Package File", + filters: [{ name: "OpenIDEA plugin", extensions: ["oiplugin"] }], + }); + return (Array.isArray(picked) ? picked[0] : picked) ?? null; + }, + renderView: (pluginId, view, viewport, doc) => call(commands.pluginViewRender(pluginId, view, viewport, doc)), + viewEvent: async (pluginId, view, event, doc) => { + await call(commands.pluginViewEvent(pluginId, view, event, doc)); + }, + secrets: (pluginId) => call(commands.pluginSecrets(pluginId)), + setSecret: async (pluginId, name, value) => { + await call(commands.pluginSetSecret(pluginId, name, value)); + }, setActiveFile: async (path) => { await commands.pluginSetActiveFile(path); }, @@ -47,13 +145,9 @@ export const tauriPluginsBackend: PluginsBackend = { setThemeFrame: async (color) => { await call(commands.pluginSetThemeFrame(color)); }, - pickPackage: async () => { + pickFolder: async () => { const { open } = await import("@tauri-apps/plugin-dialog"); - const picked = await open({ - multiple: false, - title: tx("Install Plugin from Disk"), - filters: [{ name: tx("Plugin package"), extensions: ["zip", "toml"] }], - }); + const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" }); return (Array.isArray(picked) ? picked[0] : picked) ?? null; }, onEvent: (handler) => { @@ -66,7 +160,7 @@ export const tauriPluginsBackend: PluginsBackend = { const tauriListeners = new Set<(e: PluginEvent) => void>(); let subscribed = false; -/** One channel per window; the backend addresses insert-text requests to the invoking window. */ +/** One channel per window; the backend addresses notifications and consent requests to the invoking window. */ function ensureSubscribed() { if (subscribed) return; subscribed = true; @@ -101,6 +195,27 @@ const SAMPLE_THEMES: ActiveTheme["theme"][] = [ }, ]; +const cap = (id: string, risk: CapabilityInfo["risk"], description: string): CapabilityInfo => ({ + id, + risk, + description, + optional: false, + granted: true, +}); + +const HELLO_COMMANDS = [ + { id: "insert-date", title: "Hello: Insert Date", category: "Hello" }, + { id: "word-count", title: "Hello: Word Count of Active File", category: "Hello" }, +]; + +/** UI contributions of the sample plugins (what the backend would project). */ +function sampleUi(id: string): UiContribution[] { + if (id === "hello") return HELLO_COMMANDS.map((c) => ({ pluginId: id, point: "commands", key: c.id, decl: c })); + if (id === "theme-harbor-night") + return SAMPLE_THEMES.map((t) => ({ pluginId: id, point: "themes", key: t.id, decl: t })); + return []; +} + function samplePlugins(): PluginInfo[] { const base = { authors: ["OpenIDEA contributors"], @@ -109,10 +224,23 @@ function samplePlugins(): PluginInfo[] { source: "installed" as const, enabled: true, error: null, - languages: [], - themes: [], - commands: [], - permissions: [], + capabilities: [], + contributions: [], + activation: [], + warnings: [], + stats: { + activations: 0, + activationMs: 0, + calls: 0, + errors: 0, + maxCallMs: 0, + p95CallMs: 0, + slowCalls: 0, + crashes: 0, + reclaims: 0, + }, + publisher: null, + previousVersion: null, }; return [ { @@ -122,13 +250,15 @@ function samplePlugins(): PluginInfo[] { version: "0.1.0", description: "Sample plugin: inserts today's date and counts words in the active file.", path: "~/Library/Application Support/ai.l0ng.openidea/plugins/hello", - status: "active", + status: "inactive", hasCode: true, - permissions: ["project-read", "editor-read", "editor-write"], - commands: [ - { id: "insert-date", title: "Hello: Insert Date", category: "Hello" }, - { id: "word-count", title: "Hello: Word Count of Active File", category: "Hello" }, + activation: ["on-startup-finished"], + capabilities: [ + cap("editor.write", "medium", "Change text in the editor"), + cap("project.read", "low", "Read files in the open project"), + cap("editor.read", "low", "Read the focused editor's file and selection"), ], + contributions: HELLO_COMMANDS.map((c) => ({ point: "commands", label: c.title })), }, { ...base, @@ -139,7 +269,7 @@ function samplePlugins(): PluginInfo[] { path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-harbor-night", status: "static", hasCode: false, - themes: SAMPLE_THEMES.map(({ id, name, kind }) => ({ id, name, kind })), + contributions: SAMPLE_THEMES.map((t) => ({ point: "themes", label: t.name })), }, { ...base, @@ -150,31 +280,35 @@ function samplePlugins(): PluginInfo[] { path: "~/Library/Application Support/ai.l0ng.openidea/plugins/language-toy", status: "static", hasCode: false, - languages: [{ id: "toy", name: "Toy", extensions: ["toy"] }], + contributions: [{ point: "languages", label: "Toy (.toy)" }], }, ]; } /** The sample packages as a local catalog (the desktop app scans `plugins/`). */ export function sampleCatalog(): CatalogPlugin[] { - return samplePlugins().map((p) => ({ - id: p.id, - name: p.name, - version: p.version, - description: p.description, - authors: p.authors, - homepage: p.homepage, - changeNotes: `${p.version} — first release.`, - path: `plugins/${p.id}`, - hasCode: p.hasCode, - permissions: p.permissions, - commands: p.commands, - languageCount: p.languages.length, - themeCount: p.themes.length, - problem: null, - })); + return samplePlugins().map((p) => { + const counts = new Map<string, number>(); + for (const c of p.contributions) counts.set(c.point, (counts.get(c.point) ?? 0) + 1); + return { + id: p.id, + name: p.name, + version: p.version, + description: p.description, + authors: p.authors, + homepage: p.homepage, + changeNotes: `${p.version} — first release.`, + path: `plugins/${p.id}`, + hasCode: p.hasCode, + capabilities: p.capabilities.map((c) => ({ ...c, granted: false })), + contributions: [...counts].map(([point, count]) => ({ point, count })), + problem: null, + }; + }); } +const live = (p: PluginInfo) => p.status === "active" || p.status === "inactive" || p.status === "static"; + export function createMemoryPluginsBackend( initial: PluginInfo[] = samplePlugins(), catalog: CatalogPlugin[] = sampleCatalog(), @@ -183,6 +317,8 @@ export function createMemoryPluginsBackend( const templates = samplePlugins(); let theme: { pluginId: string; themeId: string } | null = null; let activeFile: string | null = null; + const secrets = new Map<string, string>(); + let marketUrl: string | null = null; const listeners = new Set<(e: PluginEvent) => void>(); const emit = (e: PluginEvent) => listeners.forEach((l) => l(e)); const find = (id: string) => { @@ -197,16 +333,36 @@ export function createMemoryPluginsBackend( if (!p?.enabled || !t) return null; return { pluginId: p.id, theme: t }; }; + const ui = () => plugins.filter(live).flatMap((p) => sampleUi(p.id)); + const preview = (path: string): InstallPreview => { + const entry = catalog.find((c) => c.path === path); + if (!entry) throw new IpcError({ kind: "unavailable", message: `installing ${path} requires the desktop app` }); + return { + id: entry.id, + name: entry.name, + version: entry.version, + description: entry.description, + authors: entry.authors, + hasCode: entry.hasCode, + capabilities: entry.capabilities, + contributions: entry.contributions, + warnings: [], + replaces: plugins.find((p) => p.id === entry.id)?.version ?? null, + unsigned: true, + publisher: null, + }; + }; return { - list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme() }), + list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme(), ui: ui() }), setEnabled: async (id, enabled) => { const p = find(id); p.enabled = enabled; - p.status = enabled ? (p.hasCode ? "active" : "static") : "disabled"; + p.status = enabled ? (p.hasCode ? "inactive" : "static") : "disabled"; p.error = null; emit({ type: "commandsChanged" }); if (theme?.pluginId === id) emit({ type: "themeChanged", theme: activeTheme() }); }, + previewInstall: async (path) => preview(path), installDir: async (path) => { // Only catalog packages can be "installed" here; there are no files in the browser. const entry = catalog.find((c) => c.path === path); @@ -219,6 +375,22 @@ export function createMemoryPluginsBackend( emit({ type: "commandsChanged" }); return { ...info }; }, + grant: async (id) => { + const p = find(id); + p.status = p.hasCode ? "inactive" : "static"; + p.capabilities = p.capabilities.map((c) => ({ ...c, granted: true })); + emit({ type: "commandsChanged" }); + return { ...p }; + }, + setCapability: async (id, capability, granted) => { + const p = find(id); + p.capabilities = p.capabilities.map((c) => (c.id === capability ? { ...c, granted } : c)); + if (!granted && p.capabilities.some((c) => c.id === capability && !c.optional)) p.status = "needsConsent"; + emit({ type: "commandsChanged" }); + return { ...p }; + }, + assets: async () => [], + activationEvent: async () => {}, catalog: async () => catalog.map((c) => ({ ...c })), uninstall: async (id) => { find(id); @@ -228,13 +400,10 @@ export function createMemoryPluginsBackend( reload: async (id) => find(id), runCommand: async (pluginId, commandId) => { const p = find(pluginId); - if (p.status !== "active") + if (p.status !== "active" && p.status !== "inactive") throw new IpcError({ kind: "unavailable", message: `plugin "${pluginId}" is not running` }); - if (commandId === "insert-date") { - const date = new Date().toISOString().slice(0, 10); - emit({ type: "insertText", pluginId, text: date }); - return date; - } + p.status = "active"; + if (commandId === "insert-date") return new Date().toISOString().slice(0, 10); if (commandId === "word-count") { if (!activeFile) throw new IpcError({ kind: "internal", message: "no file is open in the editor" }); const name = activeFile.split("/").pop() ?? activeFile; @@ -243,6 +412,39 @@ export function createMemoryPluginsBackend( } throw new IpcError({ kind: "internal", message: `unknown command: ${commandId}` }); }, + postEvents: async () => {}, + eventKinds: async () => [], + statusItems: async () => [], + cancelTask: async () => {}, + market: async () => [], + marketUrl: async () => marketUrl, + setMarketUrl: async (url) => { + marketUrl = url; + }, + marketDownload: async (id) => { + throw new IpcError({ kind: "unavailable", message: `installing ${id} requires the desktop app` }); + }, + unpackPackage: async (path) => { + throw new IpcError({ kind: "unavailable", message: `unpacking ${path} requires the desktop app` }); + }, + rollBack: async (id) => { + throw new IpcError({ kind: "notFound", message: `${id} has no earlier version to roll back to` }); + }, + pickPackage: async () => null, + renderView: async (pluginId, view) => ({ + toolbar: [], + body: { kind: "message", value: `${pluginId} · ${view} (plugin views need the desktop app)` }, + status: null, + }), + viewEvent: async () => {}, + secrets: async (pluginId) => + [...secrets.entries()] + .filter(([k]) => k.startsWith(`${pluginId}/`)) + .map(([k]) => ({ name: k.slice(pluginId.length + 1), set: true })), + setSecret: async (pluginId, name, value) => { + if (value === null) secrets.delete(`${pluginId}/${name}`); + else secrets.set(`${pluginId}/${name}`, value); + }, setActiveFile: async (path) => { activeFile = path; }, @@ -253,7 +455,7 @@ export function createMemoryPluginsBackend( return t; }, setThemeFrame: async () => {}, - pickPackage: async () => null, + pickFolder: async () => null, onEvent: (handler) => { listeners.add(handler); return () => void listeners.delete(handler); diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index a5580d7eb..46b11b079 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -1,66 +1,39 @@ /** - * Glue between backend plugins and the workbench: plugin commands become - * actions, editor requests are applied to the active editor, the selected - * theme becomes a stylesheet of CSS variables, notifications become balloons. + * Glue between backend plugins and the workbench: UI contributions are + * projected (`projection.ts` + `points.tsx`), status items, tasks and event + * subscriptions of running code go through `runtimeUi.tsx`, the selected + * theme becomes a stylesheet of CSS + * variables, notifications become balloons, consent requests open the + * permission dialog, and opened files fire `on-language` / `on-file`. */ -import { EditorSelection } from "@codemirror/state"; -import type { ActiveTheme, PluginEvent, PluginInfo } from "@/bindings"; -import { activeEditor, useEditorRegistry } from "@/editor/api"; -import { registerAction, type Action } from "@/platform/actions"; +import type { ActiveTheme, PluginEvent } from "@/bindings"; +import { useEditorRegistry } from "@/editor/api"; +import { refreshLanguageInfos } from "@/editor/core/cm/languages"; import { notifications } from "@/workbench/contributions"; import { applyThemeAttributes, currentThemeId, syncWindowBackground, windowBackgroundHex } from "@/workbench/theme"; +import { useProject } from "@/platform/project"; import { pluginsBackend } from "./backend"; -import { refreshPlugins, runPluginCommand, usePlugins } from "./store"; -import { tx } from "@/platform/i18n"; +import { trackIconTheme } from "./iconThemes"; +import { defineBuiltinUiPoints } from "./points"; +import { clearUiContributions, syncUiContributions } from "./projection"; +import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi"; +import { grantPlugin, refreshPlugins, usePlugins } from "./store"; -export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`; +export { pluginActionId } from "./points"; -/** Actions for every command of every running plugin. */ -export function pluginActions(plugins: readonly PluginInfo[]): Action[] { - return plugins - .filter((p) => p.status === "active") - .flatMap((p) => - p.commands.map((c): Action => ({ - id: pluginActionId(p.id, c.id), - title: c.title, - category: c.category ?? p.name, - icon: "package", - keywords: ["plugin", p.name, p.id], - description: tx`From plugin ${p.name}`, - run: async ({ arg }) => { - await runPluginCommand(p.id, c.id, typeof arg === "string" ? arg : null); - }, - })), - ); -} - -/** Keeps the action registry in sync with the plugin list; re-registers only changed actions. */ -export function syncPluginActions(): () => void { - const registered = new Map<string, { key: string; dispose: () => void }>(); - const apply = (plugins: readonly PluginInfo[]) => { - const next = new Map(pluginActions(plugins).map((a) => [a.id, a])); - for (const [id, entry] of registered) { - if (!next.has(id)) { - entry.dispose(); - registered.delete(id); - } - } - for (const [id, action] of next) { - const key = `${action.title}\u0000${action.category}`; - const prev = registered.get(id); - if (prev?.key === key) continue; - prev?.dispose(); - registered.set(id, { key, dispose: registerAction(action) }); - } +/** Keeps the projected UI contributions in line with the plugin list. */ +export function syncProjection(): () => void { + const apply = () => { + const s = usePlugins.getState(); + syncUiContributions(s.ui, s.plugins); }; - apply(usePlugins.getState().plugins); + apply(); const unsubscribe = usePlugins.subscribe((s, prev) => { - if (s.plugins !== prev.plugins) apply(s.plugins); + if (s.ui !== prev.ui || s.plugins !== prev.plugins) apply(); }); return () => { unsubscribe(); - registered.forEach((e) => e.dispose()); - registered.clear(); + clearUiContributions(); }; } @@ -174,35 +147,7 @@ export function trackPluginTheme(): () => void { }; } -// --- editor ----------------------------------------------------------------- - -/** Inserts (`select: false`) or replaces-and-selects (`select: true`) at every selection range. */ -export function applyTextToActiveEditor(text: string, select: boolean, pluginId: string): boolean { - const editor = activeEditor(); - const name = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId; - if (!editor || editor.ctx.readOnly) { - notifications().notify({ - group: "plugins", - level: "warning", - title: `${name}: ${editor ? "the editor is read-only" : "no editor to insert into"}`, - }); - return false; - } - const { view } = editor; - view.dispatch( - view.state.changeByRange((range) => ({ - changes: { from: range.from, to: range.to, insert: text }, - range: select - ? EditorSelection.range(range.from, range.from + text.length) - : EditorSelection.cursor(range.from + text.length), - })), - { scrollIntoView: true, userEvent: "input.plugin" }, - ); - view.focus(); - return true; -} - -/** Reports the focused editor's file to the backend (`active-file` for plugins). */ +/** Reports the focused editor's file to the backend (`editor-focused` events). */ function trackActiveFile(): () => void { let last: string | null | undefined; const report = (path: string | null) => { @@ -228,15 +173,17 @@ export function handlePluginEvent(e: PluginEvent) { notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined }); break; } - case "insertText": - applyTextToActiveEditor(e.text, false, e.pluginId); - break; - case "replaceSelection": - applyTextToActiveEditor(e.text, true, e.pluginId); - break; case "themeChanged": usePlugins.setState({ activeTheme: e.theme }); break; + case "consentRequested": + void refreshPlugins().then(() => grantPlugin(e.pluginId, e.capabilities)); + break; + case "languagesChanged": + void refreshLanguageInfos(); + break; + default: + handleRuntimeEvent(e); } } @@ -247,15 +194,47 @@ function scheduleRefresh() { refreshTimer = setTimeout(() => { refreshTimer = null; void refreshPlugins(); + refreshEventKinds(); }, 30); } +/** + * Fires `on-language:<id>` and `on-file:<project-relative path>` for files + * opened in the editor; the backend activates the plugins waiting for them. + */ +function trackActivationEvents(): () => void { + const seenLanguages = new Set<string>(); + const report = (ctx: { path: string | null; language: string } | undefined) => { + if (!ctx?.path) return; + if (!seenLanguages.has(ctx.language)) { + seenLanguages.add(ctx.language); + void pluginsBackend() + .activationEvent("language", ctx.language) + .catch(() => seenLanguages.delete(ctx.language)); + } + const root = useProject.getState().project?.root; + if (root && ctx.path.startsWith(`${root}/`)) { + void pluginsBackend() + .activationEvent("file", ctx.path.slice(root.length + 1)) + .catch(() => {}); + } + }; + report(useEditorRegistry.getState().active?.ctx); + return useEditorRegistry.subscribe((s, prev) => { + if (s.active?.ctx.path !== prev.active?.ctx.path) report(s.active?.ctx); + }); +} + export function startPluginBridge(): () => void { const disposers = [ + defineBuiltinUiPoints(), pluginsBackend().onEvent(handlePluginEvent), - syncPluginActions(), + syncProjection(), trackPluginTheme(), + trackIconTheme(), trackActiveFile(), + trackActivationEvents(), + startRuntimeUi(), ]; return () => { if (refreshTimer) clearTimeout(refreshTimer); diff --git a/src/features/plugins/consent.tsx b/src/features/plugins/consent.tsx new file mode 100644 index 000000000..f978e05a0 --- /dev/null +++ b/src/features/plugins/consent.tsx @@ -0,0 +1,141 @@ +/** + * The install / permission dialog (docs/plugin_plan.md 4.2, 4.6): every + * capability with its risk; optional ones can be refused; high-risk ones are + * called out as running code on this machine. + */ +import { useState } from "react"; +import { create } from "zustand"; +import type { CapabilityInfo, Consent } from "@/bindings"; +import { tx } from "@/platform/i18n"; +import { Badge, Checkbox, Dialog, T } from "@/ui"; +import { setConsentPrompt, type ConsentRequest } from "./store"; +import styles from "./PluginsPage.module.css"; + +interface Pending { + req: ConsentRequest; + resolve: (c: Consent | null) => void; +} + +const usePending = create<{ queue: Pending[] }>(() => ({ queue: [] })); + +export const RISK_LABEL: Record<CapabilityInfo["risk"], { label: string; tone: "neutral" | "warning" | "danger" }> = { + low: { label: "Low", tone: "neutral" }, + medium: { label: "Medium", tone: "warning" }, + high: { label: "High", tone: "danger" }, +}; + +const RISK_ORDER = { high: 0, medium: 1, low: 2 } as const; + +export function sortByRisk(caps: readonly CapabilityInfo[]): CapabilityInfo[] { + return [...caps].sort((a, b) => RISK_ORDER[a.risk] - RISK_ORDER[b.risk] || a.id.localeCompare(b.id)); +} + +function ask(req: ConsentRequest): Promise<Consent | null> { + return new Promise((resolve) => usePending.setState((s) => ({ queue: [...s.queue, { req, resolve }] }))); +} + +function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => void }) { + const { req } = pending; + const caps = sortByRisk(req.capabilities); + const optional = caps.filter((c) => c.optional); + const [accepted, setAccepted] = useState<Set<string>>( + () => new Set(optional.filter((c) => c.granted || c.risk === "low").map((c) => c.id)), + ); + const high = caps.some((c) => c.risk === "high" && (!c.optional || accepted.has(c.id))); + const finish = (consent: Consent | null) => { + pending.resolve(consent); + onDone(); + }; + const title = req.kind === "install" ? tx`Install ${req.name} ${req.version}?` : tx`Allow ${req.name} to run?`; + return ( + <Dialog + open + title={title} + icon={high ? "warning" : "question"} + width={520} + onClose={() => finish(null)} + buttons={[ + { label: tx("Cancel"), role: "cancel", onClick: () => finish(null) }, + { + label: req.kind === "install" ? tx("Install") : tx("Allow"), + role: "default", + onClick: () => finish({ optional: [...accepted] }), + }, + ]} + > + <div className={styles.consent}> + {req.unsigned && ( + <p className={styles.consentWarning} role="note"> + <T>This package is not signed. Only continue if you trust where it came from.</T> + </p> + )} + {caps.length === 0 ? ( + <p> + <T>This plugin needs no special permissions.</T> + </p> + ) : ( + <ul className={styles.items} aria-label={tx("Requested permissions")}> + {caps.map((c) => ( + <li key={c.id} className={styles.item}> + {c.optional ? ( + <Checkbox + checked={accepted.has(c.id)} + onChange={(on) => + setAccepted((s) => { + const next = new Set(s); + if (on) next.add(c.id); + else next.delete(c.id); + return next; + }) + } + aria-label={tx`Allow ${c.id}`} + /> + ) : ( + <span className={styles.consentRequired} title={tx("Required")}> + • + </span> + )} + <span className={styles.itemMain}>{c.description}</span> + <span className={styles.mono}>{c.id}</span> + <Badge tone={RISK_LABEL[c.risk].tone}>{RISK_LABEL[c.risk].label}</Badge> + </li> + ))} + </ul> + )} + {high && ( + <p className={styles.consentWarning} role="alert"> + <T>High-risk permissions are equivalent to running arbitrary code on this machine.</T> + </p> + )} + {req.warnings.map((w) => ( + <p key={w} className={styles.muted}> + {w} + </p> + ))} + </div> + </Dialog> + ); +} + +/** Overlay rendering the queued consent dialogs one at a time. */ +export function ConsentDialogHost() { + const first = usePending((s) => s.queue[0]); + if (!first) return null; + return ( + <ConsentDialog + key={first.req.pluginId + first.req.kind} + pending={first} + onDone={() => usePending.setState((s) => ({ queue: s.queue.slice(1) }))} + /> + ); +} + +/** Routes the store's consent requests to the dialog. */ +export function provideConsentDialog(): () => void { + setConsentPrompt(ask); + return () => { + setConsentPrompt(null); + for (const p of usePending.getState().queue) p.resolve(null); + usePending.setState({ queue: [] }); + }; +} diff --git a/src/features/plugins/iconThemes.ts b/src/features/plugins/iconThemes.ts new file mode 100644 index 000000000..198297f02 --- /dev/null +++ b/src/features/plugins/iconThemes.ts @@ -0,0 +1,170 @@ +/** + * File icon themes contributed by plugins (`iconThemes`). The selection is a + * per-user preference kept in this browser profile; the mapping and the + * sanitised SVGs are fetched from the backend only when a theme is selected, + * so installed icon themes cost nothing at start-up. + */ +import { create } from "zustand"; +import { registerExternalIcons, setFileIconTheme, type FileIconTheme } from "@/ui"; +import { pluginsBackend } from "./backend"; + +export const ICON_THEME_KEY = "openidea.fileIconTheme"; + +export interface IconThemeRef { + pluginId: string; + id: string; + label: string; +} + +export interface IconThemeMap { + file: string | null; + folder?: string | null; + folderExpanded?: string | null; + fileExtensions: Record<string, string>; + fileNames: Record<string, string>; + folderNames?: Record<string, string>; +} + +interface IconThemesState { + /** `<pluginId>/<themeId>` → theme. */ + available: ReadonlyMap<string, IconThemeRef>; + /** `<pluginId>/<themeId>` or null for the built-in icons. */ + selected: string | null; + /** The theme whose icons are registered. */ + applied: string | null; +} + +const readSelection = (): string | null => { + try { + return localStorage.getItem(ICON_THEME_KEY); + } catch { + return null; + } +}; + +export const useIconThemes = create<IconThemesState>(() => ({ + available: new Map(), + selected: readSelection(), + applied: null, +})); + +export const iconThemeKey = (pluginId: string, id: string) => `${pluginId}/${id}`; + +/** Called by the `iconThemes` projection: the theme can be selected while its plugin is live. */ +export function addIconTheme(theme: IconThemeRef): () => void { + const key = iconThemeKey(theme.pluginId, theme.id); + useIconThemes.setState((s) => ({ available: new Map(s.available).set(key, theme) })); + return () => + useIconThemes.setState((s) => { + const available = new Map(s.available); + available.delete(key); + return { available }; + }); +} + +export function selectIconTheme(key: string | null) { + try { + if (key) localStorage.setItem(ICON_THEME_KEY, key); + else localStorage.removeItem(ICON_THEME_KEY); + } catch { + /* storage unavailable: the choice lasts for this window */ + } + useIconThemes.setState({ selected: key }); +} + +/** + * The theme's resolver. Files: exact name, then the longest matching + * extension, then its default file icon. Folders: exact name, then its + * default (expanded) folder icon. + */ +export function iconResolver( + map: IconThemeMap, + iconName: (icon: string) => string, + has: (name: string) => boolean, +): FileIconTheme { + const pick = (icon: string | undefined | null) => (icon && has(iconName(icon)) ? iconName(icon) : null); + return { + file(fileName) { + const lower = fileName.toLowerCase(); + const named = pick(map.fileNames[lower]); + if (named) return named; + let rest = lower; + for (let dot = rest.indexOf("."); dot >= 0; dot = rest.indexOf(".")) { + rest = rest.slice(dot + 1); + const byExt = pick(map.fileExtensions[rest]); + if (byExt) return byExt; + } + return pick(map.file); + }, + folder(folderName, expanded) { + const named = pick(map.folderNames?.[folderName.toLowerCase()]); + if (named) return named; + return (expanded && pick(map.folderExpanded)) || pick(map.folder); + }, + }; +} + +let undo: (() => void) | null = null; +let loading: string | null = null; + +async function apply(key: string | null) { + const theme = key ? useIconThemes.getState().available.get(key) : undefined; + if (!theme) { + undo?.(); + undo = null; + useIconThemes.setState({ applied: null }); + return; + } + if (useIconThemes.getState().applied === key || loading === key) return; + loading = key; + try { + const prefix = `iconTheme/${theme.id}/`; + const assets = await pluginsBackend().assets(theme.pluginId, prefix); + if (loading !== key) return; + const mapJson = assets.find(([id]) => id === `${prefix}map`)?.[1]; + if (!mapJson) throw new Error(`icon theme ${key} has no mapping`); + const map = JSON.parse(mapJson) as IconThemeMap; + const svgPrefix = `${prefix}svg/`; + const iconName = (icon: string) => `ext:${key}/${icon}`; + const icons = assets + .filter(([id]) => id.startsWith(svgPrefix)) + .map(([id, svg]) => [iconName(id.slice(svgPrefix.length)), svg] as const); + const names = new Set(icons.map(([n]) => n)); + undo?.(); + const unregister = registerExternalIcons(icons); + setFileIconTheme(iconResolver(map, iconName, (n) => names.has(n))); + undo = () => { + setFileIconTheme(null); + unregister(); + }; + useIconThemes.setState({ applied: key }); + } catch (e) { + console.warn("could not load the file icon theme", e); + } finally { + if (loading === key) loading = null; + } +} + +/** Keeps the registered icons in line with the selection and the available themes. */ +export function trackIconTheme(): () => void { + const sync = () => { + const { selected, available } = useIconThemes.getState(); + void apply(selected && available.has(selected) ? selected : null); + }; + sync(); + const unsubscribe = useIconThemes.subscribe((s, prev) => { + if (s.selected !== prev.selected || s.available !== prev.available) sync(); + }); + // Another window (Settings) changed the selection. + const onStorage = (e: StorageEvent) => { + if (e.key === ICON_THEME_KEY) useIconThemes.setState({ selected: e.newValue }); + }; + window.addEventListener("storage", onStorage); + return () => { + unsubscribe(); + window.removeEventListener("storage", onStorage); + undo?.(); + undo = null; + useIconThemes.setState({ applied: null }); + }; +} diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index a57767231..4a9fa4e19 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -1,18 +1,28 @@ /** * Plugins: Settings › Plugins (installed list, enable/disable, install from - * disk, uninstall, theme selection) and the runtime bridge to the WASM plugin - * host (`ide-plugin`). + * disk with a permission page, uninstall, theme selection) and the runtime + * bridge to the plugin host (`ide_host::plugins`): UI contributions are + * projected by `projection.ts` / `points.tsx`. * * Actions: `plugins.settings`, `plugins.installFromDisk`, `plugins.reloadAll`, * plus one action per plugin command: `plugin.<pluginId>.<commandId>` * (searchable in Search Everywhere › Actions and bindable in the keymap editor; * a string arg is forwarded to the plugin). + * + * Editor contribution `plugins.lineMarkers`: gutter icons from plugin + * `line-markers` providers (`lineMarkerGutter.ts`). */ +import { registerEditorContribution } from "@/editor/api"; import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; -import { registerSettingsPage } from "@/workbench/contributions"; -import { startPluginBridge, trackPluginTheme } from "./bridge"; +import { editorService, registerOverlay, registerSettingsPage } from "@/workbench/contributions"; +import { startPluginBridge, syncProjection, trackPluginTheme } from "./bridge"; +import { ConsentDialogHost, provideConsentDialog } from "./consent"; +import { trackIconTheme } from "./iconThemes"; +import { pluginLineMarkerGutter } from "./lineMarkerGutter"; +import { defineBuiltinUiPoints } from "./points"; +import { startViewUi } from "./uiPoints"; import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store"; function registerPluginsPage(): () => void { @@ -28,7 +38,15 @@ function registerPluginsPage(): () => void { /** Settings › Plugins for the Settings window: the page plus the plugin theme. */ export function activateSettingsPages(): () => void { - const disposers = [registerPluginsPage(), trackPluginTheme()]; + const disposers = [ + registerPluginsPage(), + registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }), + provideConsentDialog(), + defineBuiltinUiPoints(), + syncProjection(), + trackPluginTheme(), + trackIconTheme(), + ]; void refreshPlugins(); return () => disposers.reverse().forEach((d) => d()); } @@ -36,6 +54,8 @@ export function activateSettingsPages(): () => void { export function activate(): () => void { const disposers = [ registerPluginsPage(), + registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }), + provideConsentDialog(), registerActions([ { id: "plugins.settings", @@ -68,6 +88,10 @@ export function activate(): () => void { }, ]), startPluginBridge(), + // Plugin custom editors (tab kind) and context menu entries. + startViewUi((input) => void editorService().openInput(input)), + // Gutter icons from plugins' `line-markers` providers (next to the LSP ones). + registerEditorContribution({ id: "plugins.lineMarkers", order: 57, extensions: pluginLineMarkerGutter }), // Dev plugins (`<project>/.openidea/plugins`) depend on the open project. useProject.subscribe((s, prev) => { if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) void refreshPlugins(); diff --git a/src/features/plugins/lineMarkerGutter.ts b/src/features/plugins/lineMarkerGutter.ts new file mode 100644 index 000000000..23e3149fc --- /dev/null +++ b/src/features/plugins/lineMarkerGutter.ts @@ -0,0 +1,149 @@ +/** + * Gutter icons from plugins' `line-markers` providers (MyBatis' arrows + * between a mapper interface and its XML). Asked once per document version + * (debounced); markers follow their lines in between; a click opens the + * marker's target. The gutter column exists only while there are markers, + * so editors without plugin providers keep their usual width. + */ +import { Compartment, RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import { navigateTo } from "@/editor/lsp/navigation"; +import { commands, type LineMarkerInfo } from "@/platform/ipc"; +import { gutterIconMarkup } from "@/ui"; +import styles from "@/editor/navigation/LineMarkers.module.css"; + +const DEBOUNCE_MS = 400; + +const ICONS: Record<string, string> = { + implemented: "gutter-implemented", + implementing: "gutter-implementing", + overridden: "gutter-overridden", + overriding: "gutter-overriding", + run: "gutter-run", + navigate: "gutter-implementing", +}; + +/** Icon name for a plugin marker (`navigate` when unknown). */ +export function markerIcon(icon: string | null): string { + return ICONS[icon ?? "navigate"] ?? ICONS.navigate!; +} + +interface Placed { + marker: LineMarkerInfo; + pos: number; +} + +const setMarkers = StateEffect.define<Placed[]>(); + +const markersField = StateField.define<Placed[]>({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(setMarkers)) return e.value; + if (!tr.docChanged || !value.length) return value; + return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from })); + }, +}); + +/** Markers on existing lines, at their line starts. */ +export function placeMarkers(doc: Text, markers: readonly LineMarkerInfo[]): Placed[] { + return markers.filter((m) => m.line < doc.lines).map((marker) => ({ marker, pos: doc.line(marker.line + 1).from })); +} + +class PluginMarker extends GutterMarker { + constructor(readonly marker: LineMarkerInfo) { + super(); + } + override eq(other: GutterMarker): boolean { + return ( + other instanceof PluginMarker && + other.marker.tooltip === this.marker.tooltip && + other.marker.icon === this.marker.icon + ); + } + override toDOM(): Node { + const el = document.createElement("div"); + el.className = styles.marker!; + el.title = this.marker.tooltip; + el.setAttribute("role", "button"); + el.setAttribute("aria-label", this.marker.tooltip); + el.innerHTML = gutterIconMarkup(markerIcon(this.marker.icon)) ?? ""; + return el; + } +} + +class MarkersPlugin { + private timer: ReturnType<typeof setTimeout> | null = null; + private generation = 0; + private shown = false; + + constructor( + private view: EditorView, + private doc: number, + private column: Compartment, + ) { + this.schedule(); + } + + update(u: ViewUpdate) { + if (u.docChanged) this.schedule(); + } + + private schedule() { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => void this.run(), DEBOUNCE_MS); + } + + private async run() { + this.timer = null; + const gen = ++this.generation; + const text = this.view.state.doc; + try { + const markers = await commands.pluginLineMarkers(this.doc); + if (gen !== this.generation || this.view.state.doc !== text || !this.view.dom.isConnected) return; + if (markers.status !== "ok") return; + const placed = placeMarkers(text, markers.data); + if (!placed.length && !this.view.state.field(markersField).length) return; + const effects: StateEffect<unknown>[] = [setMarkers.of(placed)]; + if (placed.length > 0 !== this.shown) { + this.shown = placed.length > 0; + effects.push(this.column.reconfigure(this.shown ? markerColumn : [])); + } + this.view.dispatch({ effects }); + } catch (e) { + console.debug("[plugin line markers]", e); + } + } + + destroy() { + if (this.timer) clearTimeout(this.timer); + this.generation++; + } +} + +const markerColumn: Extension = gutter({ + class: `${styles.gutter} cm-openidea-gutter-pluginMarkers`, + markers: (view) => + RangeSet.of( + view.state.field(markersField).map((m) => new PluginMarker(m.marker).range(m.pos)), + true, + ), + domEventHandlers: { + mousedown: (view, block, event) => { + const e = event as MouseEvent; + if (e.button !== 0) return false; + const target = view.state.field(markersField).find((m) => m.pos === block.from && m.marker.target)?.marker.target; + if (!target) return false; + e.preventDefault(); + void navigateTo(target); + return true; + }, + }, +}); + +export function pluginLineMarkerGutter(ctx: EditorDocContext): Extension { + if (ctx.doc < 0) return []; + const doc = ctx.doc; + const column = new Compartment(); + return [markersField, column.of([]), ViewPlugin.define((view) => new MarkersPlugin(view, doc, column))]; +} diff --git a/src/features/plugins/points.tsx b/src/features/plugins/points.tsx new file mode 100644 index 000000000..54e805ec4 --- /dev/null +++ b/src/features/plugins/points.tsx @@ -0,0 +1,158 @@ +/** + * Client side of the first-party UI extension points: how each validated + * declaration is projected onto the workbench, and how its items appear on + * the plugin details page. One `defineUiPoint` per contribution type. + */ +import type { PluginInfo, ThemeDef } from "@/bindings"; +import { registerAction } from "@/platform/actions"; +import { tx } from "@/platform/i18n"; +import { Button, Kbd, T } from "@/ui"; +import { addIconTheme, iconThemeKey, selectIconTheme, useIconThemes } from "./iconThemes"; +import { defineUiPoint, type UiItem } from "./projection"; +import { SecretsDetails, type SecretDecl } from "./secrets"; +import { defineViewUiPoints } from "./uiPoints"; +import { isLive, runPluginCommand, selectPluginTheme, usePlugins } from "./store"; +import styles from "./PluginsPage.module.css"; + +export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`; + +interface CommandDecl { + id: string; + title: string; + category?: string | null; + when?: string | null; +} + +function CommandsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<CommandDecl>[] }) { + const runnable = plugin.status === "active" || plugin.status === "inactive"; + return ( + <ul className={styles.items}> + {items.map(({ decl: c }) => { + const actionId = pluginActionId(plugin.id, c.id); + return ( + <li key={c.id} className={styles.item}> + <span className={styles.itemMain}>{c.title}</span> + <Kbd action={actionId} /> + <span className={styles.mono} title={tx("Action id (bind it in Keymap)")}> + {actionId} + </span> + <Button + size="small" + variant="ghost" + icon="run" + disabled={!runnable} + onClick={() => void runPluginCommand(plugin.id, c.id)} + aria-label={tx`Run ${c.title}`} + > + <T>Run</T> + </Button> + </li> + ); + })} + </ul> + ); +} + +function ThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<ThemeDef>[] }) { + const activeTheme = usePlugins((s) => s.activeTheme); + const live = isLive(plugin.status); + return ( + <ul className={styles.items}> + {items.map(({ decl: t }) => { + const isActive = activeTheme?.pluginId === plugin.id && activeTheme.theme.id === t.id; + return ( + <li key={t.id} className={styles.item}> + <span className={styles.itemMain}>{t.name}</span> + <span className={styles.muted}>{t.kind === "dark" ? tx("Dark") : tx("Light")}</span> + {isActive ? ( + <Button size="small" variant="secondary" onClick={() => void selectPluginTheme(null, null)}> + <T>Use Default Theme</T> + </Button> + ) : ( + <Button + size="small" + variant="ghost" + disabled={!live} + onClick={() => void selectPluginTheme(plugin.id, t.id)} + aria-label={tx`Apply theme ${t.name}`} + > + <T>Apply</T> + </Button> + )} + </li> + ); + })} + </ul> + ); +} + +interface IconThemeDecl { + id: string; + label: string; + icons: number; +} + +function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<IconThemeDecl>[] }) { + const selected = useIconThemes((s) => s.selected); + return ( + <ul className={styles.items}> + {items.map(({ decl: t }) => { + const key = iconThemeKey(plugin.id, t.id); + return ( + <li key={t.id} className={styles.item}> + <span className={styles.itemMain}>{t.label}</span> + <span className={styles.muted}>{tx`${t.icons} icons`}</span> + {selected === key ? ( + <Button size="small" variant="secondary" onClick={() => selectIconTheme(null)}> + <T>Use Default Icons</T> + </Button> + ) : ( + <Button + size="small" + variant="ghost" + onClick={() => selectIconTheme(key)} + aria-label={tx`Apply icon theme ${t.label}`} + > + <T>Apply</T> + </Button> + )} + </li> + ); + })} + </ul> + ); +} + +/** Registers the client side of every first-party UI point. */ +export function defineBuiltinUiPoints(): () => void { + const disposers = [ + defineUiPoint<CommandDecl>("commands", { + title: "Commands", + project: ({ pluginId, decl }, { plugin }) => + registerAction({ + id: pluginActionId(pluginId, decl.id), + title: decl.title, + category: decl.category ?? plugin.name, + icon: "package", + when: decl.when ?? undefined, + keywords: ["plugin", plugin.name, pluginId], + description: tx`From plugin ${plugin.name}`, + run: async ({ arg }) => { + await runPluginCommand(pluginId, decl.id, typeof arg === "string" ? arg : null); + }, + }), + Details: CommandsDetails, + }), + // Applied through `activeTheme` (the backend owns the selection); listed here. + defineUiPoint<ThemeDef>("themes", { title: "Themes", Details: ThemesDetails }), + defineUiPoint<IconThemeDecl>("iconThemes", { + title: "File Icon Themes", + project: ({ pluginId, decl }) => addIconTheme({ pluginId, id: decl.id, label: decl.label }), + Details: IconThemesDetails, + }), + // Stored by the backend; listed here with a field per secret. + defineUiPoint<SecretDecl>("secrets", { title: "Secrets", Details: SecretsDetails }), + defineViewUiPoints(), + ]; + return () => disposers.reverse().forEach((d) => d()); +} diff --git a/src/features/plugins/projection.ts b/src/features/plugins/projection.ts new file mode 100644 index 000000000..b447a2f49 --- /dev/null +++ b/src/features/plugins/projection.ts @@ -0,0 +1,129 @@ +/** + * Projection of plugin UI contributions onto the workbench's own `register*` + * functions (docs/plugin_plan.md 4.1). + * + * The backend validated every declaration completely (Rust extension points); + * a projection maps one onto an existing registration and returns its + * disposer. Projections must not fail: an exception is a defect, logged, and + * that one item is skipped — nothing is half-registered. + * + * Adding a UI contribution type = a Rust extension point + one + * `defineUiPoint` here; the kernel (this file, the store, the bridge) is not + * touched. + */ +import type { ComponentType } from "react"; +import type { PluginInfo, UiContribution } from "@/bindings"; + +export interface UiItem<T = Record<string, unknown>> { + pluginId: string; + point: string; + key: string; + decl: T; +} + +export interface ProjectionContext { + plugin: PluginInfo; +} + +export interface UiPointDefinition<T = Record<string, unknown>> { + /** Makes one item take effect; returns its disposer. */ + project?: (item: UiItem<T>, ctx: ProjectionContext) => (() => void) | void; + /** Optional section in Settings › Plugins › details for this point's items. */ + Details?: ComponentType<{ plugin: PluginInfo; items: UiItem<T>[] }>; + /** Section title for the details page (defaults to the point id). */ + title?: string; +} + +const points = new Map<string, UiPointDefinition<never>>(); +const live = new Map<string, { sig: string; dispose: () => void }>(); +const warned = new Set<string>(); +let lastItems: readonly UiContribution[] = []; +let lastPlugins: readonly PluginInfo[] = []; + +const itemKey = (c: UiContribution) => `${c.pluginId}\u0000${c.point}\u0000${c.key}`; + +/** Registers the client side of an extension point. */ +export function defineUiPoint<T>(point: string, def: UiPointDefinition<T>): () => void { + if (points.has(point)) throw new Error(`UI point "${point}" is already defined`); + points.set(point, def as UiPointDefinition<never>); + // Items that arrived before the definition (lazy features) are projected now. + syncUiContributions(lastItems, lastPlugins); + return () => { + for (const [key, entry] of live) { + if (key.split("\u0000")[1] === point) { + safeDispose(entry.dispose); + live.delete(key); + } + } + points.delete(point); + }; +} + +export function uiPoint(point: string): UiPointDefinition<never> | undefined { + return points.get(point); +} + +function safeDispose(dispose: () => void) { + try { + dispose(); + } catch (e) { + console.error("plugin contribution disposer failed", e); + } +} + +/** + * Brings the registrations in line with `items` (the backend's snapshot): + * removed or changed items are disposed, new or changed ones projected. + */ +export function syncUiContributions(items: readonly UiContribution[], plugins: readonly PluginInfo[]) { + lastItems = items; + lastPlugins = plugins; + const byId = new Map(plugins.map((p) => [p.id, p])); + const next = new Map<string, { item: UiContribution; sig: string }>(); + for (const item of items) { + if (!points.get(item.point)?.project) { + if (!points.has(item.point) && !warned.has(item.point)) { + warned.add(item.point); + console.warn(`plugin UI point "${item.point}" has no client projection`); + } + continue; + } + if (!byId.has(item.pluginId)) continue; + next.set(itemKey(item), { item, sig: JSON.stringify(item.decl) }); + } + for (const [key, entry] of live) { + if (next.get(key)?.sig !== entry.sig) { + safeDispose(entry.dispose); + live.delete(key); + } + } + for (const [key, { item, sig }] of next) { + if (live.has(key)) continue; + const def = points.get(item.point)!; + const plugin = byId.get(item.pluginId)!; + try { + const dispose = def.project!(item as UiItem<never>, { plugin }) ?? (() => {}); + live.set(key, { sig, dispose }); + } catch (e) { + // The backend validated the declaration: a failing projection is a defect. + console.error(`projecting ${item.point} "${item.key}" of plugin ${item.pluginId} failed`, e); + } + } +} + +/** Disposes every projected registration (shutdown, tests). */ +export function clearUiContributions() { + for (const entry of live.values()) safeDispose(entry.dispose); + live.clear(); + lastItems = []; + lastPlugins = []; +} + +/** Items of one point, optionally of one plugin. */ +export function itemsOf<T = Record<string, unknown>>( + items: readonly UiContribution[], + point: string, + pluginId?: string, +): UiItem<T>[] { + return items.filter((c) => c.point === point && (!pluginId || c.pluginId === pluginId)) as UiItem<T>[]; +} diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx new file mode 100644 index 000000000..136bbcf93 --- /dev/null +++ b/src/features/plugins/runtimeUi.tsx @@ -0,0 +1,273 @@ +/** + * What running plugin code shows outside its declarations: status bar items + * (`ui.set-status-item`), background tasks (`tasks.begin`), and the + * workbench events it subscribed to (`events.subscribe`), reported in + * batches. + */ +import { create } from "zustand"; +import type { EventKind, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindings"; +import { onEditorEvent } from "@/editor/api"; +import { executeAction, getAction, onActionExecuted } from "@/platform/actions"; +import { onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { shortcutLabel } from "@/platform/keymap"; +import { BarButton } from "@/ui/BarButton"; +import { registerStatusItem } from "@/workbench/contributions"; +import { removeTask, upsertTask, useTasks } from "@/workbench/tasks"; +import { pluginsBackend } from "./backend"; +import { bumpView, deliverViewMessage } from "./views/viewStore"; +import { runPluginCommand } from "./store"; + +// --- status bar items --------------------------------------------------------- + +type ItemKey = `${string}/${string}`; + +export const usePluginStatusItems = create<{ items: Map<ItemKey, { pluginId: string; item: StatusItem }> }>(() => ({ + items: new Map(), +})); + +function setStatusItem(pluginId: string, id: string, item: StatusItem | null) { + usePluginStatusItems.setState((s) => { + const items = new Map(s.items); + if (item) items.set(`${pluginId}/${id}`, { pluginId, item }); + else items.delete(`${pluginId}/${id}`); + return { items }; + }); +} + +/** Items declared under `[[contributes.statusBar]]`: shown until (and unless) the code sets its own. */ +interface Declared { + item: StatusItem; + align: "left" | "right"; + priority: number; +} + +export const useDeclaredStatusItems = create<{ items: Map<ItemKey, { pluginId: string } & Declared> }>(() => ({ + items: new Map(), +})); + +export function declareStatusItem(pluginId: string, id: string, declared: Declared): () => void { + const key: ItemKey = `${pluginId}/${id}`; + const entry = { pluginId, ...declared }; + useDeclaredStatusItems.setState((s) => ({ items: new Map(s.items).set(key, entry) })); + return () => + useDeclaredStatusItems.setState((s) => { + if (s.items.get(key) !== entry) return s; + const items = new Map(s.items); + items.delete(key); + return { items }; + }); +} + +function PluginStatusItemView({ itemKey }: { itemKey: ItemKey }) { + const running = usePluginStatusItems((s) => s.items.get(itemKey)); + const declared = useDeclaredStatusItems((s) => s.items.get(itemKey)); + const entry = running ?? declared; + if (!entry) return null; + const { pluginId, item } = entry; + const command = item.command; + return ( + <BarButton + tooltip={item.tooltip ?? undefined} + aria-label={item.tooltip ?? item.text} + onClick={command ? () => void runPluginCommand(pluginId, command) : undefined} + > + {item.text} + </BarButton> + ); +} + +/** One status bar contribution per plugin item (declared or set by code), registered while it exists. */ +function projectStatusItems(): () => void { + const registered = new Map<ItemKey, { dispose: () => void; sig: string }>(); + const sync = () => { + const running = usePluginStatusItems.getState().items; + const declared = useDeclaredStatusItems.getState().items; + const live = new Set<ItemKey>([...running.keys(), ...declared.keys()]); + for (const [key, r] of registered) { + const d = declared.get(key); + const sig = d ? `${d.align}/${d.priority}` : ""; + if (!live.has(key) || r.sig !== sig) { + r.dispose(); + registered.delete(key); + } + } + for (const key of live) + if (!registered.has(key)) { + const d = declared.get(key); + const View = () => <PluginStatusItemView itemKey={key} />; + const dispose = registerStatusItem({ + id: `plugin.${key}`, + align: d?.align ?? "right", + priority: d?.priority ?? 2, + component: View, + }); + registered.set(key, { dispose, sig: d ? `${d.align}/${d.priority}` : "" }); + } + }; + sync(); + const unsubscribe = [ + usePluginStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()), + useDeclaredStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()), + ]; + refreshStatusItems(); + return () => { + unsubscribe.forEach((u) => u()); + for (const r of registered.values()) r.dispose(); + registered.clear(); + }; +} + +/** + * Re-reads the status items of running plugin code (later changes arrive as + * `statusItemChanged`). Like the event kinds, the backend answers once this + * window's project is open and trusted, so call it again when that happens. + */ +function refreshStatusItems() { + void pluginsBackend() + .statusItems() + .then((items) => items.forEach((i) => setStatusItem(i.pluginId, i.id, i.item))) + .catch(() => {}); +} + +// --- tasks -------------------------------------------------------------------- + +const taskId = (id: string) => `plugin:${id}`; + +function onTaskEvent(e: Extract<PluginEvent, { type: "taskStarted" | "taskProgress" | "taskEnded" }>) { + const id = taskId(e.taskId); + const current = useTasks.getState().tasks.find((t) => t.id === id); + switch (e.type) { + case "taskStarted": + upsertTask({ + id, + title: e.title, + message: null, + fraction: null, + done: false, + cancel: e.cancellable ? () => void pluginsBackend().cancelTask(e.taskId) : undefined, + }); + break; + case "taskProgress": + if (current) + upsertTask({ + ...current, + message: e.message, + fraction: e.total ? Math.min(1, e.done / e.total) : null, + }); + break; + case "taskEnded": + if (current) upsertTask({ ...current, done: true }); + else removeTask(id); + break; + } +} + +// --- workbench events ----------------------------------------------------------- + +/** Batches are sent at most this often; the backend batches again per plugin. */ +const POST_INTERVAL_MS = 500; + +let wanted = new Set<EventKind>(); +let queue: WorkbenchEvent[] = []; +let postTimer: ReturnType<typeof setTimeout> | null = null; + +/** + * Re-reads the event kinds plugins want. Besides `subscriptionsChanged` + * (run-time subscriptions), declared `on-event:` kinds change whenever plugins + * load, install, toggle or get grants, which the backend reports as `commandsChanged`. + */ +export function refreshEventKinds() { + void pluginsBackend() + .eventKinds() + .then((kinds) => (wanted = new Set(kinds))) + .catch(() => {}); +} + +function post(event: WorkbenchEvent) { + queue.push(event); + if (postTimer) return; + postTimer = setTimeout(() => { + postTimer = null; + const batch = queue; + queue = []; + void pluginsBackend() + .postEvents(batch) + .catch(() => {}); + }, POST_INTERVAL_MS); +} + +/** Reports the events plugins subscribed to: files opened and saved, actions run, repositories changed. */ +function trackWorkbenchEvents(): () => void { + refreshEventKinds(); + const offEditor = onEditorEvent((e) => { + if (e.type === "docOpened" && wanted.has("file-opened")) post({ kind: "fileOpened", value: e.path }); + if (e.type === "docSaved" && wanted.has("file-saved")) post({ kind: "fileSaved", value: e.path }); + }); + const offActions = onActionExecuted((id, _arg, source) => { + if (!wanted.has("action-performed")) return; + const action = getAction(id); + post({ + kind: "actionPerformed", + value: { action: id, title: action?.title ?? id, source, shortcut: shortcutLabel(id) ?? null }, + }); + }); + const offVcs = onIdeEvent("vcsChanged", (e) => { + if (wanted.has("vcs-changed")) post({ kind: "vcsChanged", value: e.root }); + }); + return () => { + offEditor(); + offActions(); + offVcs(); + if (postTimer) clearTimeout(postTimer); + postTimer = null; + queue = []; + }; +} + +/** Handles the runtime UI events; returns whether `e` was one. */ +export function handleRuntimeEvent(e: PluginEvent): boolean { + switch (e.type) { + case "statusItemChanged": + setStatusItem(e.pluginId, e.id, e.item); + return true; + case "taskStarted": + case "taskProgress": + case "taskEnded": + onTaskEvent(e); + return true; + case "subscriptionsChanged": + refreshEventKinds(); + return true; + case "viewChanged": + bumpView(e.pluginId, e.view); + return true; + case "viewMessage": + deliverViewMessage(e.pluginId, e.view, e.message); + return true; + case "openTerminal": + // The program was approved (`process:`) and verified by the host. + void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd }); + return true; + default: + return false; + } +} + +/** + * The backend refuses the event kinds and status items until this window's + * project is open and trusted (`authorize_command`), but startup asks before + * that: ask again when it changes. + */ +function refreshOnProjectChange(): () => void { + return useProject.subscribe((s, prev) => { + if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return; + refreshEventKinds(); + refreshStatusItems(); + }); +} + +export function startRuntimeUi(): () => void { + const disposers = [projectStatusItems(), trackWorkbenchEvents(), refreshOnProjectChange()]; + return () => disposers.reverse().forEach((d) => d()); +} diff --git a/src/features/plugins/secrets.tsx b/src/features/plugins/secrets.tsx new file mode 100644 index 000000000..f6838a85a --- /dev/null +++ b/src/features/plugins/secrets.tsx @@ -0,0 +1,114 @@ +/** + * `[[contributes.secrets]]` on the plugin details page: a password field per + * declared secret. Values go to the backend's secret store (the macOS + * Keychain in the desktop app) and never come back to the UI; plugins use + * them only as `{{secret:<name>}}` placeholders in `net` request headers. + */ +import { useCallback, useEffect, useState } from "react"; +import type { PluginInfo, PluginSecretState } from "@/bindings"; +import { tx } from "@/platform/i18n"; +import { Badge, Button, Input, T } from "@/ui"; +import { pluginsBackend } from "./backend"; +import type { UiItem } from "./projection"; +import styles from "./PluginsPage.module.css"; + +export interface SecretDecl { + name: string; + label: string; + description?: string | null; +} + +function SecretRow({ + plugin, + decl, + isSet, + onChanged, +}: { + plugin: PluginInfo; + decl: SecretDecl; + isSet: boolean; + onChanged: () => void; +}) { + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const save = async (v: string | null) => { + setBusy(true); + setError(null); + try { + await pluginsBackend().setSecret(plugin.id, decl.name, v); + setValue(""); + onChanged(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + return ( + <li className={styles.item}> + <span className={styles.itemMain} title={decl.description ?? undefined}> + {decl.label} + </span> + {isSet ? <Badge tone="success">{tx("Stored")}</Badge> : <Badge tone="neutral">{tx("Not set")}</Badge>} + <Input + type="password" + value={value} + disabled={busy} + placeholder={isSet ? "Replace…" : "Value"} + aria-label={tx`Value of ${decl.label}`} + onChange={(e) => setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && value) void save(value); + }} + /> + <Button size="small" variant="ghost" disabled={busy || !value} onClick={() => void save(value)}> + <T>Save</T> + </Button> + {isSet && ( + <Button + size="small" + variant="ghost" + disabled={busy} + onClick={() => void save(null)} + aria-label={tx`Remove ${decl.label}`} + > + <T>Remove</T> + </Button> + )} + {error && ( + <span className={styles.error} role="alert"> + {error} + </span> + )} + </li> + ); +} + +export function SecretsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<SecretDecl>[] }) { + const [state, setState] = useState<PluginSecretState[]>([]); + const refresh = useCallback(() => { + pluginsBackend() + .secrets(plugin.id) + .then(setState, () => setState([])); + }, [plugin.id]); + useEffect(refresh, [refresh]); + return ( + <> + <ul className={styles.items} aria-label={tx("Secrets")}> + {items.map(({ decl }) => ( + <SecretRow + key={decl.name} + plugin={plugin} + decl={decl} + isSet={state.some((s) => s.name === decl.name && s.set)} + onChanged={refresh} + /> + ))} + </ul> + <div className={styles.muted}> + <T>The plugin can only send these with its network requests; it cannot read them.</T> + </div> + </> + ); +} diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts index 08ec18a63..9d3dd0c63 100644 --- a/src/features/plugins/store.ts +++ b/src/features/plugins/store.ts @@ -1,6 +1,17 @@ -/** Installed plugins, the selected plugin theme, and the operations on them. */ +/** Installed plugins, their UI contributions, the selected plugin theme, and the operations on them. */ import { create } from "zustand"; -import type { ActiveTheme, CatalogPlugin, PluginInfo } from "@/bindings"; +import type { + ActiveTheme, + CapabilityInfo, + CatalogPlugin, + Consent, + MarketPlugin, + PluginEditor, + PluginInfo, + PluginStatus, + UiContribution, +} from "@/bindings"; +import { activeEditor } from "@/editor/api"; import { notifications } from "@/workbench/contributions"; import { pluginsBackend } from "./backend"; @@ -8,6 +19,8 @@ export const SETTINGS_PAGE_ID = "plugins"; interface PluginsState { plugins: PluginInfo[]; + /** UI contributions of live plugins, projected by `projection.ts`. */ + ui: UiContribution[]; activeTheme: ActiveTheme | null; loaded: boolean; loading: boolean; @@ -18,10 +31,16 @@ interface PluginsState { /** Locally available packages (Marketplace tab); `null` until loaded. */ catalog: CatalogPlugin[] | null; catalogError: string | null; + /** The marketplace index URL; `null` when none is configured. */ + marketUrl: string | null; + /** Plugins of the marketplace index; `null` until read. */ + market: MarketPlugin[] | null; + marketError: string | null; } export const usePlugins = create<PluginsState>(() => ({ plugins: [], + ui: [], activeTheme: null, loaded: false, loading: false, @@ -30,10 +49,48 @@ export const usePlugins = create<PluginsState>(() => ({ installing: false, catalog: null, catalogError: null, + marketUrl: null, + market: null, + marketError: null, })); export const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +/** Statuses whose contributions are in effect. */ +export const isLive = (status: PluginStatus) => status === "active" || status === "inactive" || status === "static"; + +// --- consent ------------------------------------------------------------------ + +/** What a consent prompt shows: an install preview or a plugin waiting for permission. */ +export interface ConsentRequest { + pluginId: string; + name: string; + version: string; + /** Install page (new or update) or a permission prompt for an installed/project plugin. */ + kind: "install" | "grant"; + capabilities: CapabilityInfo[]; + /** Unsigned package (from disk or a project folder). */ + unsigned: boolean; + warnings: string[]; +} + +type ConsentPrompt = (req: ConsentRequest) => Promise<Consent | null>; + +let consentPrompt: ConsentPrompt | null = null; + +/** The dialog that asks (registered by `consent.tsx`; tests substitute their own). */ +export function setConsentPrompt(prompt: ConsentPrompt | null) { + consentPrompt = prompt; +} + +/** Without a prompt only plugins asking for nothing are accepted. */ +function askConsent(req: ConsentRequest): Promise<Consent | null> { + if (consentPrompt) return consentPrompt(req); + return Promise.resolve(req.capabilities.length === 0 ? { optional: [] } : null); +} + +// --- loading ------------------------------------------------------------------ + let generation = 0; export async function refreshPlugins(): Promise<void> { @@ -44,6 +101,7 @@ export async function refreshPlugins(): Promise<void> { if (gen !== generation) return; usePlugins.setState({ plugins: snapshot.plugins, + ui: snapshot.ui, activeTheme: snapshot.activeTheme, loaded: true, loading: false, @@ -93,6 +151,29 @@ export function uninstallPlugin(id: string): Promise<boolean> { }); } +/** Asks for (and records) the permissions of a plugin waiting for consent. */ +export async function grantPlugin(id: string, capabilities?: CapabilityInfo[]): Promise<boolean> { + const plugin = usePlugins.getState().plugins.find((p) => p.id === id); + if (!plugin) return false; + const consent = await askConsent({ + pluginId: id, + name: plugin.name, + version: plugin.version, + kind: "grant", + capabilities: capabilities ?? plugin.capabilities, + unsigned: plugin.source !== "installed", + warnings: plugin.warnings, + }); + if (!consent) return false; + return withBusy(id, `Could not enable ${plugin.name}`, () => pluginsBackend().grant(id, consent)); +} + +export function setPluginCapability(id: string, capability: string, granted: boolean): Promise<boolean> { + return withBusy(id, `Could not change ${capability} of ${nameOf(id)}`, () => + pluginsBackend().setCapability(id, capability, granted), + ); +} + export async function refreshCatalog(): Promise<void> { try { usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null }); @@ -101,6 +182,57 @@ export async function refreshCatalog(): Promise<void> { } } +/** The marketplace's plugins: the index read again with `refresh`. */ +export async function refreshMarket(refresh = true): Promise<void> { + const backend = pluginsBackend(); + try { + const marketUrl = await backend.marketUrl(); + usePlugins.setState({ marketUrl }); + if (!marketUrl) return usePlugins.setState({ market: null, marketError: null }); + usePlugins.setState({ market: await backend.market(refresh), marketError: null }); + } catch (e) { + usePlugins.setState({ marketError: errorText(e) }); + } +} + +export async function setMarketUrl(url: string | null): Promise<void> { + await pluginsBackend().setMarketUrl(url?.trim() || null); + await refreshMarket(true); +} + +/** Downloads (and checks) a marketplace plugin, then the install page as from disk. */ +export async function installFromMarket(id: string): Promise<string | null> { + usePlugins.setState({ installing: true }); + let dir: string; + try { + dir = await pluginsBackend().marketDownload(id); + } catch (e) { + usePlugins.setState({ installing: false }); + notifications().notify({ + group: "plugins", + level: "error", + title: "Could not download plugin", + body: errorText(e), + }); + return null; + } + const installed = await installFromDisk(dir); + void refreshMarket(false); + return installed; +} + +/** Settings › Plugins › Roll Back: the version before the last update. */ +export async function rollBackPlugin(id: string): Promise<void> { + try { + const info = await pluginsBackend().rollBack(id); + notifications().notify({ group: "plugins", level: "info", title: `Rolled back ${info.name} to ${info.version}` }); + } catch (e) { + notifications().notify({ group: "plugins", level: "error", title: "Could not roll back", body: errorText(e) }); + } finally { + await refreshPlugins(); + } +} + /** Enables or disables every installed (non-dev) plugin; returns how many changed. */ export async function setAllInstalledEnabled(enabled: boolean): Promise<number> { const targets = usePlugins.getState().plugins.filter((p) => p.source === "installed" && p.enabled !== enabled); @@ -124,25 +256,41 @@ export function compareVersions(a: string, b: string): number { return 0; } -/** Asks for a plugin package (a `.zip` or a `plugin.toml`) and installs it. Returns the new plugin id. */ -export async function installFromDisk(path?: string): Promise<string | null> { +/** + * Asks for a plugin folder (containing plugin.toml), shows what it may do, + * and installs it with the user's consent. Returns the new plugin id. + */ +export async function installFromDisk(path?: string, packageFile = false): Promise<string | null> { const backend = pluginsBackend(); - let source: string | null = path ?? null; + let dir: string | null = path ?? null; try { - source ??= await backend.pickPackage(); + dir ??= packageFile ? await backend.pickPackage() : await backend.pickFolder(); + // A package file (`.oiplugin`) is unpacked first; signed ones are checked on the install page. + if (dir?.endsWith(".oiplugin")) dir = await backend.unpackPackage(dir); } catch (e) { notifications().notify({ group: "plugins", level: "error", - title: "Could not open the file picker", + title: packageFile ? "Could not open the package" : "Could not open the folder picker", body: errorText(e), }); return null; } - if (!source) return null; + if (!dir) return null; usePlugins.setState({ installing: true }); try { - const info = await backend.installDir(source); + const preview = await backend.previewInstall(dir); + const consent = await askConsent({ + pluginId: preview.id, + name: preview.name, + version: preview.version, + kind: "install", + capabilities: preview.capabilities, + unsigned: preview.unsigned, + warnings: preview.warnings, + }); + if (!consent) return null; + const info = await backend.installDir(dir, consent); notifications().notify({ group: "plugins", level: info.status === "failed" ? "warning" : "info", @@ -168,17 +316,43 @@ export async function selectPluginTheme(pluginId: string | null, themeId: string } } +/** Title of a plugin command from its UI contribution. */ +export function commandTitle(pluginId: string, commandId: string): string { + const item = usePlugins + .getState() + .ui.find((c) => c.point === "commands" && c.pluginId === pluginId && c.key === commandId); + const title = (item?.decl as { title?: unknown } | undefined)?.title; + return typeof title === "string" ? title : commandId; +} + +/** The focused text editor as a command's context: path, language, selections (line / UTF-16 column). */ +function focusedEditor(): PluginEditor | null { + const editor = activeEditor(); + if (!editor?.ctx.path) return null; + const { doc, selection } = editor.view.state; + const pos = (offset: number) => { + const line = doc.lineAt(offset); + return { line: line.number - 1, col: offset - line.from }; + }; + return { + path: editor.ctx.path, + language: editor.ctx.language, + selections: selection.ranges.map((r) => ({ start: pos(r.from), end: pos(r.to) })), + }; +} + /** Runs a plugin command from an action; failures become notifications. */ export async function runPluginCommand(pluginId: string, commandId: string, arg: string | null = null) { - const plugin = usePlugins.getState().plugins.find((p) => p.id === pluginId); - const title = plugin?.commands.find((c) => c.id === commandId)?.title ?? commandId; + const title = commandTitle(pluginId, commandId); try { - return await pluginsBackend().runCommand(pluginId, commandId, arg); + return await pluginsBackend().runCommand(pluginId, commandId, arg, focusedEditor()); } catch (e) { await refreshPlugins(); - // A trap disables the plugin and the backend already announced why. - const failed = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status === "failed"; - if (!failed) + const status = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status; + // A trap disables the plugin and the backend already announced why; a + // consent request opens its own dialog. + const permission = (e as { kind?: string }).kind === "permissionDenied"; + if (status !== "failed" && !permission) notifications().notify({ group: "plugins", level: "error", title: `${title} failed`, body: errorText(e) }); return null; } diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx new file mode 100644 index 000000000..a65172b32 --- /dev/null +++ b/src/features/plugins/uiPoints.tsx @@ -0,0 +1,318 @@ +/** + * Client side of the P7 UI points (docs/plugin_plan.md P7): declarations the + * backend validated, projected onto the workbench's own registries. + * + * - `menus`: editor / project view / editor tab context menus, and Tools in the main menu; + * - `keymaps`: default shortcuts below the IDE's (a taken keystroke is dropped); + * - `statusBar`: items shown before the code runs (its `set-status-item` with the same id updates them); + * - `settings`: a page under Settings › Plugins generated from the schema; + * - `views`: tool windows the plugin renders; + * - `customEditors`: files opening in a plugin-rendered editor sharing the text document. + */ +import { useMemo } from "react"; +import type { PluginInfo } from "@/bindings"; +import { getAction } from "@/platform/actions"; +import { evaluateWhen } from "@/platform/context"; +import { tx } from "@/platform/i18n"; +import { isMacHost, registerExtensionBindings } from "@/platform/keymap"; +import { Checkbox, Input, Select, T, type MenuItem } from "@/ui"; +import { + registerEditorKind, + registerFileEditorProvider, + registerMenuGroup, + registerMenuSlotItem, + registerSettingsPage, + registerToolWindow, + useSettingsForm, + type ContextMenuId, + type ToolWindowAnchor, +} from "@/workbench/contributions"; +import { pluginActionId } from "./points"; +import { defineUiPoint } from "./projection"; +import { declareStatusItem } from "./runtimeUi"; +import { + CUSTOM_EDITOR_KIND, + CustomEditor, + basename, + customEditorDocument, + customEditorInput, + matchesPattern, + type CustomEditorDecl, +} from "./views/customEditor"; +import { PluginView } from "./views/PluginView"; +import styles from "./PluginsPage.module.css"; + +// --- menus ------------------------------------------------------------------------ + +interface MenuDecl { + menu: "editor.context" | "projectView.context" | "editorTab.context" | "tools"; + command: string; + when?: string | null; +} + +const CONTEXT_MENUS: Record<string, ContextMenuId> = { + "editor.context": "editor", + "projectView.context": "projectView", + "editorTab.context": "editorTab", +}; + +const contextItems = new Map<string, { pluginId: string; decl: MenuDecl }>(); +let menuSeq = 0; + +/** Plugin items of a context menu: their command's action with the menu's file as argument. */ +export function pluginMenuItems(menu: ContextMenuId, paths: readonly string[]): MenuItem[] { + const out: MenuItem[] = []; + for (const { pluginId, decl } of contextItems.values()) { + if (CONTEXT_MENUS[decl.menu] !== menu || !evaluateWhen(decl.when ?? undefined)) continue; + const action = pluginActionId(pluginId, decl.command); + if (!getAction(action)) continue; + // The editor menu runs with the focused editor; the others name their file. + out.push({ kind: "action", action, arg: menu === "editor" ? undefined : paths[0] }); + } + return out; +} + +function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () => void { + if (decl.menu === "tools") { + const action = pluginActionId(pluginId, decl.command); + const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`; + return registerMenuSlotItem("pluginTools", { action, label: title }); + } + const key = `${++menuSeq}`; + contextItems.set(key, { pluginId, decl }); + return () => void contextItems.delete(key); +} + +// --- keymaps ---------------------------------------------------------------------- + +interface KeymapDecl { + command: string; + key: string; + mac?: string | null; +} + +// --- status bar ------------------------------------------------------------------- + +interface StatusDecl { + id: string; + text: string; + tooltip?: string | null; + command?: string | null; + align: "left" | "right"; + priority: number; +} + +// --- settings --------------------------------------------------------------------- + +export interface SettingSchema { + key: string; + type: "boolean" | "string" | "number" | "integer"; + title: string; + description?: string | null; + default: unknown; + options?: string[] | null; + multiline?: boolean; +} + +interface SettingsDecl { + title: string; + properties: SettingSchema[]; +} + +type PluginSettings = Record<string, Record<string, unknown>>; + +/** `settings` with `key` of `pluginId` set (`null`: back to the default). */ +export function withPluginSetting( + settings: PluginSettings | undefined, + pluginId: string, + key: string, + value: unknown, +): PluginSettings { + const own = { ...(settings?.[pluginId] ?? {}) }; + if (value === null) delete own[key]; + else own[key] = value; + const next = { ...(settings ?? {}) }; + if (Object.keys(own).length) next[pluginId] = own; + else delete next[pluginId]; + return next; +} + +function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema }) { + const form = useSettingsForm(); + const all = form.useValue("plugins") as PluginSettings | undefined; + const value = all?.[pluginId]?.[s.key] ?? s.default; + const set = (v: unknown) => form.set("plugins", withPluginSetting(all, pluginId, s.key, v === s.default ? null : v)); + const id = `plugin-setting-${pluginId}-${s.key}`; + const description = s.description ? <div className={styles.muted}>{s.description}</div> : null; + if (s.type === "boolean") + return ( + <div className={styles.settingRow}> + <Checkbox checked={value === true} label={s.title} onChange={set} /> + {description} + </div> + ); + return ( + <div className={styles.settingRow}> + <label htmlFor={id}>{s.title}</label> + {s.options ? ( + <Select + aria-label={s.title} + value={String(value)} + options={s.options.map((o) => ({ value: o, label: o }))} + onChange={set} + /> + ) : ( + <Input + id={id} + type={s.type === "string" ? "text" : "number"} + value={String(value ?? "")} + onChange={(e) => { + const raw = e.target.value; + if (s.type === "string") set(raw); + else if (raw.trim() !== "" && Number.isFinite(Number(raw))) + set(s.type === "integer" ? Math.trunc(Number(raw)) : Number(raw)); + }} + /> + )} + {description} + </div> + ); +} + +function settingsPage(pluginId: string, decl: SettingsDecl) { + return function PluginSettingsPage() { + const properties = useMemo(() => decl.properties, []); + return ( + <div className={styles.settingsPage}> + <p className={styles.muted}> + <T>Settings of the plugin; projects can override them.</T> + </p> + {properties.map((s) => ( + <SettingControl key={s.key} pluginId={pluginId} s={s} /> + ))} + </div> + ); + }; +} + +/** Settings page id of a plugin (dots nest pages, so they become dashes). */ +export const pluginSettingsPageId = (pluginId: string) => `plugins.${pluginId.replace(/\./g, "-")}`; + +// --- views and custom editors ----------------------------------------------------- + +interface ViewDecl { + id: string; + title: string; + icon?: string | null; + location: ToolWindowAnchor; +} + +export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`; + +/** Registers the client side of the P7 points; returns their disposer. */ +export function defineViewUiPoints(): () => void { + const disposers = [ + defineUiPoint<MenuDecl>("menus", { + title: "Menus", + project: ({ pluginId, decl }, { plugin }) => projectMenu(pluginId, decl, plugin), + }), + defineUiPoint<KeymapDecl>("keymaps", { + title: "Shortcuts", + project: ({ pluginId, decl }) => + registerExtensionBindings(pluginActionId(pluginId, decl.command), [ + isMacHost() ? (decl.mac ?? decl.key) : decl.key, + ]), + }), + defineUiPoint<StatusDecl>("statusBar", { + title: "Status Bar Items", + project: ({ pluginId, decl }) => + declareStatusItem(pluginId, decl.id, { + item: { text: decl.text, tooltip: decl.tooltip ?? null, command: decl.command ?? null }, + align: decl.align, + priority: decl.priority, + }), + }), + defineUiPoint<SettingsDecl>("settings", { + title: "Settings", + project: ({ pluginId, decl }) => + registerSettingsPage({ + id: pluginSettingsPageId(pluginId), + title: decl.title, + projectScope: true, + keywords: ["plugin", pluginId, ...decl.properties.flatMap((p) => [p.key, p.title])], + component: settingsPage(pluginId, decl), + }), + }), + defineUiPoint<ViewDecl>("views", { + title: "Tool Windows", + project: ({ pluginId, decl }) => + registerToolWindow({ + id: pluginToolWindowId(pluginId, decl.id), + title: decl.title, + icon: decl.icon ?? "plugin", + anchor: decl.location, + order: 900, + component: () => <PluginView pluginId={pluginId} view={decl.id} label={decl.title} />, + }), + }), + defineUiPoint<CustomEditorDecl>("customEditors", { + title: "Editors", + project: ({ pluginId, decl }) => { + const matches = (path: string) => decl.patterns.some((p) => matchesPattern(p, basename(path))); + const provider = + decl.priority === "default" + ? registerFileEditorProvider({ + id: `plugin.${pluginId}.${decl.id}`, + order: 40, + resolve: (path) => (matches(path) ? customEditorInput(pluginId, decl, path) : null), + }) + : () => {}; + openWith.set(`${pluginId}/${decl.id}`, { pluginId, decl, matches }); + return () => { + provider(); + openWith.delete(`${pluginId}/${decl.id}`); + }; + }, + }), + ]; + return () => disposers.reverse().forEach((d) => d()); +} + +/** Custom editors by `plugin/id`, for Open With. */ +const openWith = new Map<string, { pluginId: string; decl: CustomEditorDecl; matches: (path: string) => boolean }>(); + +/** One-time registrations behind the points: the editor kind and the context menu group. */ +export function startViewUi(openInput: (input: ReturnType<typeof customEditorInput>) => void): () => void { + const disposers = [ + registerEditorKind({ + kind: CUSTOM_EDITOR_KIND, + component: CustomEditor, + title: (input) => basename(input.path ?? input.key), + icon: () => "db-table", + restorable: true, + document: customEditorDocument, + }), + registerMenuGroup({ + id: "plugins", + menus: ["editor", "projectView", "editorTab"], + order: 90, + items: (menu, ctx) => { + const items = pluginMenuItems(menu, ctx.paths); + const path = ctx.paths[0]; + if (menu !== "editor" && path && !ctx.dirs?.[0]) { + const editors = [...openWith.values()].filter((e) => e.matches(path)); + if (editors.length) + items.push({ + label: tx("Open With"), + submenu: editors.map((e) => ({ + label: e.decl.title, + run: () => openInput(customEditorInput(e.pluginId, e.decl, path)), + })), + }); + } + return items; + }, + }), + ]; + return () => disposers.reverse().forEach((d) => d()); +} diff --git a/src/features/plugins/views/PluginView.module.css b/src/features/plugins/views/PluginView.module.css new file mode 100644 index 000000000..bf9fe66e1 --- /dev/null +++ b/src/features/plugins/views/PluginView.module.css @@ -0,0 +1,175 @@ +.view { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 2px 6px; + border-bottom: 1px solid var(--border); +} + +.textButton { + border: none; + background: none; + color: inherit; + font: inherit; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; +} + +.textButton:hover:not(:disabled) { + background: var(--bg-toolbar-hover); +} + +.textButton:disabled { + opacity: 0.5; + cursor: default; +} + +.body { + flex: 1; + min-height: 0; + overflow: auto; + position: relative; +} + +.status { + padding: 2px 8px; + border-top: 1px solid var(--border); + color: var(--fg-muted); + font-size: 12px; +} + +.error { + padding: 4px 8px; + color: var(--danger); + font-size: 12px; +} + +.empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.list { + list-style: none; + margin: 0; + padding: 0; +} + +.listItem { + display: flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 8px; + cursor: default; +} + +.listItem:hover, +.listItem:focus-visible { + background: var(--bg-toolbar-hover); + outline: none; +} + +.detail { + color: var(--fg-muted); +} + +.form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 12px; + max-width: 520px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.fieldLabel { + color: var(--fg-muted); +} + +.textarea { + min-height: 80px; + font: inherit; + font-family: var(--font-mono, monospace); + resize: vertical; +} + +.tableScroller { + height: 100%; + overflow: auto; + position: relative; + font-variant-numeric: tabular-nums; +} + +.tableCanvas { + position: relative; + min-width: 100%; + width: max-content; +} + +.tableRow { + position: absolute; + left: 0; + display: flex; + height: 24px; + min-width: 100%; + border-bottom: 1px solid var(--border); +} + +.tableRow[aria-selected="true"] { + background: var(--bg-tree-selected); +} + +.tableHeader { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-island); + font-weight: 600; +} + +.rowNumber { + flex: 0 0 48px; + padding: 0 6px; + text-align: right; + color: var(--fg-muted); + line-height: 24px; + border-right: 1px solid var(--border); +} + +.cell { + flex: 0 0 160px; + padding: 0 6px; + line-height: 24px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + border-right: 1px solid var(--border); +} + +.tableEmpty { + padding: 32px 12px; + color: var(--fg-muted); +} + +.frame { + border: none; + width: 100%; + height: 100%; + background: transparent; +} diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx new file mode 100644 index 000000000..343c5005a --- /dev/null +++ b/src/features/plugins/views/PluginView.tsx @@ -0,0 +1,480 @@ +/** + * Renders a plugin's declarative view (docs/plugin_plan.md 4.2, L2): the + * plugin returns data (`View`), this draws it with the workbench's own + * components and sends the user's actions back (`ViewEvent`). Used by plugin + * tool windows and custom editors. A refresh comes from the plugin + * (`viewChanged`), from `revision` (a custom editor's document changed), or + * from the table scrolling to rows it hasn't got. + */ +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings"; +import { tx } from "@/platform/i18n"; +import { + Checkbox, + EmptyState, + Icon, + IconButton, + Input, + Select, + Spinner, + Tree, + showContextMenu, + type MenuItem, +} from "@/ui"; +import { pluginsBackend } from "../backend"; +import { FRAME_TAG, frameDocument } from "./frame"; +import { onViewMessage, useViewRevision } from "./viewStore"; +import styles from "./PluginView.module.css"; + +export interface PluginViewProps { + pluginId: string; + view: string; + /** The document of a custom editor. */ + doc?: number | null; + /** Bumped by the owner when the view must render again (document edits). */ + revision?: number; + /** Called after an event was handled (custom editors re-render on the document instead). */ + label?: string; +} + +/** Table rows asked for beyond the visible ones (each side). */ +const OVERSCAN = 50; +const ROW_HEIGHT = 24; + +export function PluginView({ pluginId, view, doc = null, revision = 0, label }: PluginViewProps) { + const [content, setContent] = useState<View | null>(null); + const [error, setError] = useState<string | null>(null); + const [viewport, setViewport] = useState<Viewport | null>(null); + const pluginRevision = useViewRevision(pluginId, view); + const seq = useRef(0); + + useEffect(() => { + const mine = ++seq.current; + pluginsBackend() + .renderView(pluginId, view, viewport, doc) + .then( + (v) => { + if (mine !== seq.current) return; + setContent(v); + setError(null); + }, + (e: unknown) => { + if (mine !== seq.current) return; + setError(e instanceof Error ? e.message : String(e)); + }, + ); + }, [pluginId, view, doc, viewport, revision, pluginRevision]); + + const send = useCallback( + (event: ViewEvent) => { + pluginsBackend() + .viewEvent(pluginId, view, event, doc) + .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))); + }, + [pluginId, view, doc], + ); + + if (!content) + return error ? ( + <EmptyState className={styles.empty} title={tx("The plugin could not show this view")}> + {error} + </EmptyState> + ) : ( + <div className={styles.empty}> + <Spinner label={tx("Loading")} /> + </div> + ); + + return ( + <div className={styles.view} aria-label={label}> + {content.toolbar.length > 0 && ( + <div className={styles.toolbar} role="toolbar"> + {content.toolbar.map((b) => + b.icon ? ( + <IconButton + key={b.id} + icon={b.icon} + title={b.label} + disabled={!b.enabled} + onClick={() => send({ kind: "button", id: b.id })} + /> + ) : ( + <button + key={b.id} + type="button" + className={styles.textButton} + disabled={!b.enabled} + onClick={() => send({ kind: "button", id: b.id })} + > + {b.label} + </button> + ), + )} + </div> + )} + {error && ( + <div className={styles.error} role="alert"> + {error} + </div> + )} + <div className={styles.body}> + <Body content={content} send={send} pluginId={pluginId} view={view} onViewport={setViewport} /> + </div> + {content.status && <div className={styles.status}>{content.status}</div>} + </div> + ); +} + +interface BodyProps { + content: View; + send: (e: ViewEvent) => void; + pluginId: string; + view: string; + onViewport: (v: Viewport) => void; +} + +function Body({ content, send, pluginId, view, onViewport }: BodyProps) { + const body = content.body; + switch (body.kind) { + case "message": + return <EmptyState className={styles.empty} title={body.value} />; + case "tree": + return <TreeBody items={body.value} send={send} />; + case "list": + return ( + <ul className={styles.list} role="listbox"> + {body.value.map((i) => ( + <li + key={i.key} + role="option" + aria-selected={false} + tabIndex={0} + className={styles.listItem} + onClick={() => send({ kind: "select", key: i.key })} + onDoubleClick={() => send({ kind: "activate", key: i.key })} + onKeyDown={(e) => e.key === "Enter" && send({ kind: "activate", key: i.key })} + > + {i.icon && <Icon name={i.icon} size={14} />} + <span>{i.label}</span> + {i.detail && <span className={styles.detail}>{i.detail}</span>} + </li> + ))} + </ul> + ); + case "form": + return ( + <div className={styles.form}> + {body.value.map((f) => ( + <FormField key={f.key} field={f} send={send} /> + ))} + </div> + ); + case "table": + return <TableBody table={body.value} send={send} onViewport={onViewport} />; + case "html": + return <HtmlBody html={body.value} send={send} pluginId={pluginId} view={view} />; + } +} + +// --- tree ------------------------------------------------------------------------- + +interface Node { + item: ViewTreeItem; + children: Node[]; +} + +/** The flat item list as nodes (items naming an unknown parent are roots). */ +export function treeNodes(items: readonly ViewTreeItem[]): Node[] { + const byKey = new Map<string, Node>(); + const roots: Node[] = []; + for (const item of items) { + const node: Node = { item, children: [] }; + if (!byKey.has(item.key)) byKey.set(item.key, node); + const parent = item.parent != null ? byKey.get(item.parent) : undefined; + (parent ? parent.children : roots).push(node); + } + return roots; +} + +function TreeBody({ items, send }: { items: ViewTreeItem[]; send: (e: ViewEvent) => void }) { + const roots = useMemo(() => treeNodes(items), [items]); + const expanded = useMemo(() => new Set(items.filter((i) => i.expanded).map((i) => i.key)), [items]); + return ( + <Tree<Node> + roots={roots} + getKey={(n) => n.item.key} + getLabel={(n) => n.item.label} + getChildren={(n) => (n.children.length ? n.children : n.item.expandable ? undefined : [])} + isExpandable={(n) => n.item.expandable || n.children.length > 0} + getIcon={(n) => n.item.icon ?? undefined} + getDetail={(n) => n.item.detail ?? undefined} + expanded={expanded} + onExpandedChange={(_, node, isExpanded) => + send(isExpanded ? { kind: "expand", key: node.item.key } : { kind: "collapse", key: node.item.key }) + } + onSelectionChange={(_, lead) => lead && send({ kind: "select", key: lead.item.key })} + onActivate={(n) => send({ kind: "activate", key: n.item.key })} + multiSelect={false} + /> + ); +} + +// --- form ------------------------------------------------------------------------- + +function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) => void }) { + const commit = (value: string) => send({ kind: "field", key: field.key, value }); + const v = field.value; + const id = `plugin-field-${field.key}`; + return ( + <div className={styles.field}> + {v.kind !== "checkbox" && ( + <label className={styles.fieldLabel} htmlFor={id}> + {field.label} + </label> + )} + {v.kind === "text" && <TextField id={id} initial={v.value} onCommit={commit} />} + {v.kind === "multiline" && <TextField id={id} initial={v.value} onCommit={commit} multiline />} + {v.kind === "checkbox" && ( + <Checkbox checked={v.value} label={field.label} onChange={(on) => commit(on ? "true" : "false")} /> + )} + {v.kind === "choice" && ( + <Select + aria-label={field.label} + value={v.value.selected} + options={v.value.options.map((o) => ({ value: o, label: o }))} + onChange={commit} + /> + )} + </div> + ); +} + +function TextField({ + id, + initial, + onCommit, + multiline, +}: { + id: string; + initial: string; + onCommit: (v: string) => void; + multiline?: boolean; +}) { + const [value, setValue] = useState(initial); + useEffect(() => setValue(initial), [initial]); + const commit = () => value !== initial && onCommit(value); + return multiline ? ( + <textarea + id={id} + className={styles.textarea} + value={value} + spellCheck={false} + onChange={(e) => setValue(e.target.value)} + onBlur={commit} + /> + ) : ( + <Input + id={id} + value={value} + onChange={(e) => setValue(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === "Enter" && commit()} + /> + ); +} + +// --- table ------------------------------------------------------------------------ + +/** The rows to ask for when `first..first + visible` is shown. */ +export function wantedViewport(first: number, visible: number, total: number): Viewport { + const start = Math.max(0, first - OVERSCAN); + const end = Math.min(total, first + visible + OVERSCAN); + return { firstRow: start, rows: Math.max(0, end - start) }; +} + +function TableBody({ + table, + send, + onViewport, +}: { + table: ViewTable; + send: (e: ViewEvent) => void; + onViewport: (v: Viewport) => void; +}) { + const scroller = useRef<HTMLDivElement>(null); + const [scrollTop, setScrollTop] = useState(0); + const [height, setHeight] = useState(400); + const [selected, setSelected] = useState<Set<number>>(new Set()); + const [editing, setEditing] = useState<{ row: number; column: number } | null>(null); + + useEffect(() => { + const el = scroller.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => setHeight(el.clientHeight || 400)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const first = Math.floor(scrollTop / ROW_HEIGHT); + const visible = Math.ceil(height / ROW_HEIGHT) + 1; + const loadedEnd = table.firstRow + table.rows.length; + const shownEnd = Math.min(table.totalRows, first + visible); + // Ask for rows the scroll position shows but the last render didn't include. + useEffect(() => { + if (first < table.firstRow || shownEnd > loadedEnd) onViewport(wantedViewport(first, visible, table.totalRows)); + }, [first, visible, shownEnd, loadedEnd, table.firstRow, table.totalRows, onViewport]); + + const rowMenu = (row: number, e: React.MouseEvent) => { + e.preventDefault(); + const rows = selected.has(row) ? [...selected].sort((a, b) => a - b) : [row]; + if (!selected.has(row)) setSelected(new Set([row])); + const items: MenuItem[] = [ + { label: tx("Insert Row Above"), icon: "plus", run: () => send({ kind: "insertRow", before: row }) }, + { label: tx("Insert Row Below"), run: () => send({ kind: "insertRow", before: row + 1 }) }, + { kind: "separator" }, + { + label: rows.length > 1 ? tx`Delete ${rows.length} Rows` : tx("Delete Row"), + icon: "trash", + danger: true, + run: () => send({ kind: "deleteRows", rows }), + }, + ]; + if (table.editable) showContextMenu(e, items); + }; + + const rows: React.ReactNode[] = []; + for (let r = Math.max(first, table.firstRow); r < Math.min(shownEnd, loadedEnd); r++) { + const cells = table.rows[r - table.firstRow] ?? []; + rows.push( + <div + key={r} + role="row" + aria-rowindex={r + 2} + aria-selected={selected.has(r)} + className={styles.tableRow} + style={{ top: (r + 1) * ROW_HEIGHT }} + onClick={(e) => + setSelected((s) => { + if (!(e.metaKey || e.ctrlKey)) return new Set([r]); + const next = new Set(s); + if (next.has(r)) next.delete(r); + else next.add(r); + return next; + }) + } + onContextMenu={(e) => rowMenu(r, e)} + > + <div className={styles.rowNumber} role="rowheader"> + {r + 1} + </div> + {table.columns.map((_, c) => ( + <div + key={c} + role="gridcell" + className={styles.cell} + title={cells[c]} + onDoubleClick={() => table.editable && setEditing({ row: r, column: c })} + > + {editing?.row === r && editing.column === c ? ( + <CellEditor + initial={cells[c] ?? ""} + onDone={(value) => { + setEditing(null); + if (value !== null && value !== (cells[c] ?? "")) send({ kind: "cell", row: r, column: c, value }); + }} + /> + ) : ( + cells[c] + )} + </div> + ))} + </div>, + ); + } + + return ( + <div + ref={scroller} + className={styles.tableScroller} + role="grid" + aria-rowcount={table.totalRows + 1} + aria-colcount={table.columns.length} + onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)} + > + <div className={styles.tableCanvas} style={{ height: (table.totalRows + 1) * ROW_HEIGHT }}> + <div className={`${styles.tableRow} ${styles.tableHeader}`} role="row" aria-rowindex={1}> + <div className={styles.rowNumber} /> + {table.columns.map((c, i) => ( + <div key={i} role="columnheader" className={styles.cell} title={c}> + {c} + </div> + ))} + </div> + {rows} + </div> + {table.totalRows === 0 && <div className={styles.tableEmpty}>{tx("No rows")}</div>} + </div> + ); +} + +function CellEditor({ initial, onDone }: { initial: string; onDone: (value: string | null) => void }) { + const [value, setValue] = useState(initial); + const done = useRef(false); + const finish = (v: string | null) => { + if (done.current) return; + done.current = true; + onDone(v); + }; + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === "Enter") finish(value); + else if (e.key === "Escape") finish(null); + }; + return ( + <Input + autoFocus + variant="bare" + aria-label={tx("Cell value")} + value={value} + onChange={(e) => setValue(e.target.value)} + onKeyDown={onKeyDown} + onBlur={() => finish(value)} + /> + ); +} + +// --- html ------------------------------------------------------------------------- + +function HtmlBody({ + html, + send, + pluginId, + view, +}: { + html: string; + send: (e: ViewEvent) => void; + pluginId: string; + view: string; +}) { + const frame = useRef<HTMLIFrameElement>(null); + const dark = typeof document !== "undefined" && document.documentElement.dataset.theme === "dark"; + const srcDoc = useMemo(() => frameDocument(html, dark), [html, dark]); + + useEffect(() => { + const onMessage = (e: MessageEvent) => { + if (e.source !== frame.current?.contentWindow) return; + const data = e.data as { tag?: unknown; text?: unknown } | null; + if (data?.tag === FRAME_TAG && typeof data.text === "string") send({ kind: "message", text: data.text }); + }; + window.addEventListener("message", onMessage); + const off = onViewMessage(pluginId, view, (text) => + frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, text }, "*"), + ); + return () => { + window.removeEventListener("message", onMessage); + off(); + }; + }, [pluginId, view, send]); + + // No `allow-same-origin`: an opaque origin without access to the IDE. + return <iframe ref={frame} className={styles.frame} title={view} sandbox="allow-scripts" srcDoc={srcDoc} />; +} diff --git a/src/features/plugins/views/customEditor.tsx b/src/features/plugins/views/customEditor.tsx new file mode 100644 index 000000000..c5574e15a --- /dev/null +++ b/src/features/plugins/views/customEditor.tsx @@ -0,0 +1,234 @@ +/** + * Plugin custom editors (`[[contributes.customEditors]]`): a file matching + * the declared patterns opens in a tab whose content the plugin renders from + * the file's document. The tab joins the document shared by every editor of + * the file (`attachDocument`), so: + * + * - the plugin edits the document (versioned `apply-edits`), and a text tab + * of the same file shows the change at once — and the other way round; + * - Undo / Redo work on the document's one history: the text tab's when one + * is open, else this tab's own; + * - unsaved changes, Save, and the close prompts are the document's. + */ +import { history } from "@codemirror/commands"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { useEffect, useState } from "react"; +import { attachDocument, type DocumentAttachment } from "@/editor/api"; +import { tx } from "@/platform/i18n"; +import { EmptyState, Spinner } from "@/ui"; +import { + editorService, + notifyEditorDocumentChanged, + type EditorDocumentLifecycle, + type EditorInput, +} from "@/workbench/contributions"; +import { PluginView } from "./PluginView"; +import styles from "./PluginView.module.css"; + +export const CUSTOM_EDITOR_KIND = "pluginEditor"; + +export interface CustomEditorData { + pluginId: string; + editorId: string; + title: string; +} + +export interface CustomEditorDecl { + id: string; + title: string; + patterns: string[]; + priority: "default" | "option"; +} + +export function customEditorInput(pluginId: string, decl: CustomEditorDecl, path: string): EditorInput { + const data: CustomEditorData = { pluginId, editorId: decl.id, title: decl.title }; + return { kind: CUSTOM_EDITOR_KIND, key: `${CUSTOM_EDITOR_KIND}:${pluginId}/${decl.id}:${path}`, path, data }; +} + +/** File name glob (`*`, `?`, `[abc]`, `{a,b}`), case-insensitive. */ +export function matchesPattern(pattern: string, name: string): boolean { + let re = ""; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]!; + if (c === "*") re += "[^/]*"; + else if (c === "?") re += "[^/]"; + else if (c === "{") { + const end = pattern.indexOf("}", i); + if (end < 0) re += "\\{"; + else { + re += `(?:${pattern + .slice(i + 1, end) + .split(",") + .map((p) => p.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")) + .join("|")})`; + i = end; + } + } else if (c === "[") { + const end = pattern.indexOf("]", i); + if (end < 0) re += "\\["; + else { + re += pattern.slice(i, end + 1); + i = end; + } + } else re += c.replace(/[.+^$()|\\]/g, "\\$&"); + } + return new RegExp(`^${re}$`, "i").test(name); +} + +export function basename(path: string): string { + return path.split(/[\\/]/).pop() ?? path; +} + +// --- sessions: one per document shown by a custom editor (splits share it) ----------- + +interface Session { + attachment: DocumentAttachment; + /** Holds this tab's undo history and receives the document's changes. */ + view: EditorView; + revision: number; + listeners: Set<(revision: number) => void>; + cancelTimer: () => void; +} + +const sessions = new Map<string, Promise<Session>>(); + +/** Edits settle in the backend before the plugin renders the table again. */ +const RENDER_DELAY_MS = 120; + +function session(input: EditorInput): Promise<Session> { + let s = sessions.get(input.key); + if (!s) { + s = attachDocument(input.path ?? "").then((attachment) => { + const listeners = new Set<(revision: number) => void>(); + let timer: ReturnType<typeof setTimeout> | null = null; + const changed = () => { + notifyEditorDocumentChanged(); + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void attachment.synced().then(() => { + state.revision++; + for (const l of listeners) l(state.revision); + }); + }, RENDER_DELAY_MS); + }; + const view = new EditorView({ + state: EditorState.create({ + doc: attachment.text(), + extensions: [attachment.extension, history(), EditorView.updateListener.of((u) => u.docChanged && changed())], + }), + }); + const state: Session = { + attachment, + view, + revision: 0, + listeners, + cancelTimer: () => timer && clearTimeout(timer), + }; + return state; + }); + sessions.set(input.key, s); + s.catch(() => sessions.delete(input.key)); + } + return s; +} + +async function withSession<T>(input: EditorInput, f: (s: Session) => T | Promise<T>): Promise<T | undefined> { + const s = sessions.get(input.key); + return s ? f(await s) : undefined; +} + +const settled = new Map<string, Session>(); + +/** Lifecycle of the tabs' document (dirty mark, Save, close prompts, Undo). */ +export const customEditorDocument: EditorDocumentLifecycle = { + isDirty: (input) => settled.get(input.key)?.attachment.dirty() ?? false, + flush: async (input) => { + await withSession(input, (s) => s.attachment.synced()); + }, + save: async (input) => { + await withSession(input, (s) => s.attachment.save()); + }, + prepareClose: async (input) => { + await withSession(input, (s) => s.attachment.synced()); + }, + cancelClose: () => {}, + discard: async () => { + // The document's unsaved changes go when it closes (`close` releases it). + }, + close: async (input) => { + const s = sessions.get(input.key); + sessions.delete(input.key); + if (!s) return; + const state = await s; + settled.delete(input.key); + state.cancelTimer(); + state.attachment.release(); + state.view.destroy(); + }, + undo: async (input) => { + await withSession(input, (s) => void s.attachment.undo()); + }, + redo: async (input) => { + await withSession(input, (s) => void s.attachment.redo()); + }, +}; + +export function CustomEditor({ input }: { input: EditorInput }) { + const data = input.data as CustomEditorData; + const [state, setState] = useState<{ doc: number; revision: number } | { error: string } | null>(null); + + useEffect(() => { + let live = true; + let off = () => {}; + session(input).then( + (s) => { + if (!live) return; + settled.set(input.key, s); + const listener = (revision: number) => setState({ doc: s.attachment.doc, revision }); + s.listeners.add(listener); + off = () => s.listeners.delete(listener); + setState({ doc: s.attachment.doc, revision: s.revision }); + }, + (e: unknown) => live && setState({ error: e instanceof Error ? e.message : String(e) }), + ); + return () => { + live = false; + off(); + }; + }, [input]); + + if (!state) + return ( + <div className={styles.empty}> + <Spinner label={tx("Loading")} /> + </div> + ); + if ("error" in state) + return ( + <EmptyState className={styles.empty} title={tx`Cannot open ${basename(input.path ?? "")}`}> + {state.error} + </EmptyState> + ); + return ( + <div className={styles.view}> + <PluginView + pluginId={data.pluginId} + view={data.editorId} + doc={state.doc} + revision={state.revision} + label={tx`${data.title}: ${basename(input.path ?? "")}`} + /> + <div className={styles.status}> + <button + type="button" + className={styles.textButton} + onClick={() => input.path && void editorService().openFile(input.path, { asText: true })} + > + {tx("Open as Text")} + </button> + </div> + </div> + ); +} diff --git a/src/features/plugins/views/frame.ts b/src/features/plugins/views/frame.ts new file mode 100644 index 000000000..8ea26b98d --- /dev/null +++ b/src/features/plugins/views/frame.ts @@ -0,0 +1,40 @@ +/** + * The document of a view's `html` body. It runs in an `<iframe sandbox= + * "allow-scripts">` (an opaque origin: no cookies, storage, IDE API or parent + * access) under a CSP that allows only its own inline scripts and styles and + * `data:` images — no network. `openidea.postMessage` / `openidea.onMessage` + * are its only way to the plugin. + */ + +export const FRAME_CSP = + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:"; + +/** Marks frame ↔ workbench messages. */ +export const FRAME_TAG = "openidea-view"; + +const BRIDGE = ` +(() => { + const listeners = []; + window.addEventListener("message", (e) => { + if (e.source !== window.parent || !e.data || e.data.tag !== "${FRAME_TAG}") return; + for (const l of listeners) { try { l(String(e.data.text)); } catch (err) { console.error(err); } } + }); + window.openidea = Object.freeze({ + postMessage: (text) => window.parent.postMessage({ tag: "${FRAME_TAG}", text: String(text) }, "*"), + onMessage: (fn) => { if (typeof fn === "function") listeners.push(fn); }, + }); +})();`; + +/** `html` wrapped with the CSP, the message bridge and base styles. */ +export function frameDocument(html: string, dark: boolean): string { + const scheme = dark ? "dark" : "light"; + return [ + '<!doctype html><html><head><meta charset="utf-8">', + `<meta http-equiv="Content-Security-Policy" content="${FRAME_CSP}">`, + `<style>:root{color-scheme:${scheme}}body{margin:8px;font:13px system-ui,sans-serif}</style>`, + `<script>${BRIDGE}</script>`, + "</head><body>", + html, + "</body></html>", + ].join(""); +} diff --git a/src/features/plugins/views/viewStore.ts b/src/features/plugins/views/viewStore.ts new file mode 100644 index 000000000..e8573d521 --- /dev/null +++ b/src/features/plugins/views/viewStore.ts @@ -0,0 +1,39 @@ +/** + * Client state of plugin views: a revision per (plugin, view) that bumps when + * the plugin asks for a refresh (`viewChanged`), and the messages its code + * sends to the script of an `html` body (`viewMessage`). + */ +import { create } from "zustand"; + +export const viewKey = (pluginId: string, view: string) => `${pluginId}/${view}`; + +export const useViewRevisions = create<{ revisions: Record<string, number> }>(() => ({ revisions: {} })); + +/** The plugin's view content changed: rendered views fetch it again. */ +export function bumpView(pluginId: string, view: string) { + const key = viewKey(pluginId, view); + useViewRevisions.setState((s) => ({ revisions: { ...s.revisions, [key]: (s.revisions[key] ?? 0) + 1 } })); +} + +export function useViewRevision(pluginId: string, view: string): number { + return useViewRevisions((s) => s.revisions[viewKey(pluginId, view)] ?? 0); +} + +type MessageListener = (message: string) => void; +const messageListeners = new Map<string, Set<MessageListener>>(); + +/** Delivers a plugin's message to the frames of its view. */ +export function deliverViewMessage(pluginId: string, view: string, message: string) { + for (const l of messageListeners.get(viewKey(pluginId, view)) ?? []) l(message); +} + +export function onViewMessage(pluginId: string, view: string, listener: MessageListener): () => void { + const key = viewKey(pluginId, view); + const set = messageListeners.get(key) ?? new Set(); + set.add(listener); + messageListeners.set(key, set); + return () => { + set.delete(listener); + if (!set.size) messageListeners.delete(key); + }; +} diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 16002ef12..b0f1fa9d8 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -37,7 +37,9 @@ import { Tree, acquirePopup, fileIconName, + useFileIconRevision, folderIconName, + themedFolderIconName, showContextMenu, SymbolBadge, type DragSession, @@ -125,6 +127,7 @@ function iconFor( root: string | null, build: BuildPresentation, detected: readonly RootFolder[], + expanded: boolean, ): string { if (node.placeholder) return node.placeholder === "dir" ? "folder" : "file"; if (node.icon) return node.icon; @@ -136,7 +139,7 @@ function iconFor( const key = rel !== null && build.modules.has(rel) ? rel : node.path; const module = root ? moduleFolderIcon(key, node.root, build.modules) : null; if (module) return module; - return node.root ? "module" : "folder"; + return node.root ? "module" : (themedFolderIconName(node.label, expanded) ?? "folder"); } return fileIconName(node.label); } @@ -267,15 +270,18 @@ const Row = memo(function Row({ onDoubleClick, onPointerDown, }: RowProps) { + // Memoised: redraw this row itself when the file icon theme changes. + useFileIconRevision(); const deco = useFileDecoration(node.path); const roots = useEffectiveRoots(); const root = useProjectTree((s) => s.root); const detected = useDetectedRoots(); const build = useProjectPresentation(); const file = useJvmPresentation(node.dir || node.placeholder ? "" : node.path, node.entry?.mtimeMs); + const expanded = useProjectTree((s) => node.dir && s.expanded.has(node.key)); const icon = node.dir || node.placeholder || node.icon - ? iconFor(node, roots, root, build, detected) + ? iconFor(node, roots, root, build, detected, expanded) : presentationIcon(node.path, file, roots, root, build.spring); const moduleFolder = node.dir && !node.placeholder && !!root && isModuleFolder(relativePath(root, node.path), node.root, build.modules); @@ -396,6 +402,8 @@ function folderAt(container: HTMLElement | null, x: number, y: number): { key: s export function ProjectView() { const txTemplate = useTxTemplate(); + // Rows compute their file icons while rendering: redraw when the icon theme changes. + useFileIconRevision(); const root = useProjectTree((s) => s.root); const model = useProjectTree(selectModel); const expanded = useProjectTree((s) => s.expanded); diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index c5c50460f..650182818 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -1,7 +1,7 @@ import { useFilePresentation } from "@/platform/filePresentation"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import clsx from "clsx"; -import { actionToggled, executeAction, getAction } from "@/platform/actions"; +import { actionToggled, executeAction, executeActionFrom, getAction } from "@/platform/actions"; import type { ProjectFileSet } from "@/platform/ipc"; import { tx as translate, useTx } from "@/platform/i18n"; import { hasPrimaryModifier, shortcutLabel } from "@/platform/keymap"; @@ -281,10 +281,10 @@ function SearchEverywhereDialog() { if (!row.enabled) return; pushRecentAction(row.id); if (getAction(row.id)?.toggled) { - void executeAction(row.id).then(() => bumpToggles()); + void executeActionFrom("search", row.id).then(() => bumpToggles()); return; } - return closeThen(() => void executeAction(row.id)); + return closeThen(() => void executeActionFrom("search", row.id)); case "setting": return closeThen(() => void executeAction("file.settings", row.pageId)); } @@ -761,7 +761,7 @@ function ResultRow({ aria-label={row.title} onChange={() => { pushRecentAction(row.id); - void executeAction(row.id).then(onToggled); + void executeActionFrom("search", row.id).then(onToggled); }} /> </span> diff --git a/src/platform/actions.ts b/src/platform/actions.ts index b3d017887..19f3ed32c 100644 --- a/src/platform/actions.ts +++ b/src/platform/actions.ts @@ -140,8 +140,13 @@ export function actionToggled(action: Action | undefined): boolean | undefined { } } -type ActionListener = (id: string, arg: unknown) => void; +/** How the user started an action (for listeners such as plugins' `action-performed`). */ +export type ActionSource = "keyboard" | "menu" | "toolbar" | "contextMenu" | "search" | "other"; + +type ActionListener = (id: string, arg: unknown, source: ActionSource) => void; const listeners = new Set<ActionListener>(); +/** Set by `executeActionFrom` for the duration of the synchronous dispatch. */ +let currentSource: ActionSource = "other"; /** Actions running synchronously inside another action's `run`. */ let nesting = 0; @@ -166,7 +171,7 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean> if (nesting === 0) for (const l of listeners) try { - l(id, arg); + l(id, arg, currentSource); } catch (e) { console.error("action listener failed", e); } @@ -184,3 +189,14 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean> } return true; } + +/** `executeAction`, telling listeners how the user started it. */ +export function executeActionFrom(source: ActionSource, id: string, arg?: unknown): Promise<boolean> { + const previous = currentSource; + currentSource = source; + try { + return executeAction(id, arg); + } finally { + currentSource = previous; + } +} diff --git a/src/platform/filePresentation.ts b/src/platform/filePresentation.ts index 26ffe2952..b0e300849 100644 --- a/src/platform/filePresentation.ts +++ b/src/platform/filePresentation.ts @@ -1,7 +1,7 @@ /** Visible-file JVM presentation: one bounded IPC batch, shared cache and watcher. */ import { useEffect, useMemo } from "react"; import { create } from "zustand"; -import { fileIconName, type IconName } from "@/ui/icons"; +import { fileIconName, useFileIconRevision, type IconName } from "@/ui/icons"; import { call, commands, isTauri, onIdeEvent, type JvmPresentation } from "./ipc"; import { basename, relativePath, useProject } from "./project"; import { rootFor, type RootFolder } from "./languageSettings"; @@ -157,6 +157,7 @@ export function useJvmPresentation(path: string, mtime?: number | null) { } export function useFilePresentation(path: string, mtime?: number | null) { + useFileIconRevision(); const info = useJvmPresentation(path, mtime); const roots = useEffectiveRoots(); const root = useProject((s) => s.project?.root ?? null); @@ -173,6 +174,7 @@ export function useFilePresentationIcons(paths: readonly string[]) { return () => disposers.forEach((dispose) => dispose()); }, [key]); const entries = cache((s) => s.entries); + const iconRevision = useFileIconRevision(); const roots = useEffectiveRoots(); const root = useProject((s) => s.project?.root ?? null); const build = useProjectPresentation(); @@ -184,6 +186,7 @@ export function useFilePresentationIcons(paths: readonly string[]) { presentationIcon(path, entries.get(path), roots, root, build.spring), ]), ), - [key, entries, roots, root, build.spring], + // eslint-disable-next-line react-hooks/exhaustive-deps -- iconRevision: the file icon theme changed + [key, entries, roots, root, build.spring, iconRevision], ); } diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index a99bd83c9..91bf254d9 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -11,7 +11,7 @@ * optionally with modifiers (`Alt+Button4`); they are single strokes. */ import { create } from "zustand"; -import { getAction, isActionKeyEnabled, executeAction } from "./actions"; +import { getAction, isActionKeyEnabled, executeActionFrom } from "./actions"; import { getContext } from "./context"; import { intellijMacos } from "./keymaps/intellij-macos"; import { intellijWindows } from "./keymaps/intellij-windows"; @@ -70,9 +70,11 @@ interface KeymapState { preset: string; /** User overrides: action id → bindings (empty array removes defaults). */ overrides: Keymap; + /** Extensions' default bindings (plugins), below the preset. */ + extensions: Keymap; } -export const useKeymap = create<KeymapState>(() => ({ preset: defaultPreset(), overrides: {} })); +export const useKeymap = create<KeymapState>(() => ({ preset: defaultPreset(), overrides: {}, extensions: {} })); const MOUSE_KEY_RE = /^Button(\d+)$/i; @@ -189,17 +191,61 @@ export function suspendKeyboardDispatch(): () => void { } export function bindingsFor(actionId: string): string[] { - const { preset, overrides } = useKeymap.getState(); - return overrides[actionId] ?? PRESETS[preset]?.[actionId] ?? []; + const { preset, overrides, extensions } = useKeymap.getState(); + return overrides[actionId] ?? PRESETS[preset]?.[actionId] ?? usableExtensionBindings(extensions)[actionId] ?? []; } /** Every (binding, actionId) pair of the effective keymap. */ export function effectiveBindings(): Array<[string, string]> { - const { preset, overrides } = useKeymap.getState(); - const merged: Keymap = { ...PRESETS[preset], ...overrides }; + const { preset, overrides, extensions } = useKeymap.getState(); + const merged: Keymap = { ...usableExtensionBindings(extensions), ...PRESETS[preset], ...overrides }; return Object.entries(merged).flatMap(([id, keys]) => keys.map((k) => [normalizeBinding(k), id] as [string, string])); } +let extensionCache: { source: Keymap; preset: string; overrides: Keymap; usable: Keymap } | null = null; + +/** + * Plugin default bindings that stay: those of actions the preset and the + * user leave unbound, on keystrokes nothing else uses (the IDE's own + * shortcuts always win). + */ +function usableExtensionBindings(extensions: Keymap | undefined = {}): Keymap { + const { preset, overrides } = useKeymap.getState(); + if ( + extensionCache?.source === extensions && + extensionCache.preset === preset && + extensionCache.overrides === overrides + ) + return extensionCache.usable; + const builtIn: Keymap = { ...PRESETS[preset], ...overrides }; + const taken = new Set(Object.values(builtIn).flatMap((keys) => keys.map(normalizeBinding))); + const usable: Keymap = {}; + for (const [id, keys] of Object.entries(extensions)) { + if (id in builtIn) continue; + const free = keys.filter((k) => !taken.has(normalizeBinding(k))); + for (const k of free) taken.add(normalizeBinding(k)); + if (free.length) usable[id] = free; + } + extensionCache = { source: extensions, preset, overrides, usable }; + return usable; +} + +/** + * Default bindings contributed by an extension (a plugin's `keymaps`), below + * the preset and the user's: dropped when the keystroke is already taken. + * Returns a disposer. + */ +export function registerExtensionBindings(actionId: string, bindings: string[]): () => void { + const valid = bindings.filter(isValidBinding); + useKeymap.setState((s) => ({ extensions: { ...(s.extensions ?? {}), [actionId]: valid } })); + return () => + useKeymap.setState((s) => { + if (s.extensions?.[actionId] !== valid) return s; + const { [actionId]: _, ...rest } = s.extensions ?? {}; + return { extensions: rest }; + }); +} + const MOD_ORDER = MODIFIERS; /** Canonical form: modifiers in fixed order, so `Shift+Meta+o` == `Meta+Shift+O`. */ @@ -402,7 +448,7 @@ export function isAltGrTyping( function runIfEnabled(id: string): boolean { const action = getAction(id); if (!action || !isActionKeyEnabled(action)) return false; - void executeAction(id); + void executeActionFrom("keyboard", id); return true; } @@ -477,7 +523,7 @@ export function installKeyboardDispatcher( e.preventDefault(); e.stopPropagation(); tap.keyUsed = true; - void executeAction(id); + void executeActionFrom("keyboard", id); return true; } return false; @@ -526,7 +572,7 @@ export function installKeyboardDispatcher( e.preventDefault(); e.stopPropagation(); reset(); - void executeAction(id); + void executeActionFrom("keyboard", id); return; } } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index cafd9a141..b7aa0b6d5 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -670,7 +670,6 @@ "Detected from the toolchain": "从工具链检测到", "Detecting installations": "正在检测安装", "Detecting…": "正在检测…", - "Dev plugin": "开发插件", "Diff": "差异", "Different tool window background": "不同的工具窗口背景", "Direction": "方向", @@ -3730,8 +3729,6 @@ "Macro '{name}' stopped": "宏 '{name}' 已停止", "Id": "ID", "Enable": "启用", - "{name}: the editor is read-only": "{name}:编辑器为只读", - "{name}: no editor to insert into": "{name}:没有可插入的编辑器", "The plugin failed to start.": "插件启动失败。", "Move Directory…": "移动目录…", "Cannot move {name}": "无法移动 {name}", @@ -5710,5 +5707,68 @@ "What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)", "Build and run using": "构建和运行使用", "Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath.": "Gradle 以 JavaExec 任务在源集的运行时类路径上运行主类(包括 Gradle 的资源处理)。OpenIDEA 在 Java 语言服务器的类路径上启动它。", - "Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径" + "Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径", + "Needs permission": "需要授权", + "linked": "已链接", + "Revoke {id}": "撤销 {id}", + "Allow {id}": "允许 {id}", + "This version asks for new permissions. It stays off until you allow them.": "此版本请求了新的权限。在你允许之前它保持停用。", + "Plugins from a project folder run only after you allow them.": "来自项目文件夹的插件需要你允许后才会运行。", + "Review Permissions…": "查看权限…", + "Statistics": "统计", + "Activated {activations}× (last in {toFixed} ms), {calls} calls, {errors} errors, slowest {toFixed} ms": "已激活 {activations} 次(最近一次 {toFixed} 毫秒),调用 {calls} 次,错误 {errors} 次,最慢 {toFixed} 毫秒", + "Recent calls p95 {toFixed} ms; {crashes} crashes, {slowCalls} editor results too late, reclaimed {reclaims}× when idle": "近期调用 p95 {toFixed} 毫秒;崩溃 {crashes} 次,编辑器结果超时 {slowCalls} 次,空闲回收 {reclaims} 次", + "Linked plugin": "链接的插件", + "Project plugin": "项目插件", + "Signed by {publisher}": "由 {publisher} 签名", + "Unsigned": "未签名", + "Roll Back to {previousVersion}": "回滚到 {previousVersion}", + "Install Plugin from Package File…": "从插件包文件安装…", + "Marketplace index URL": "插件市场索引 URL", + "Refresh Marketplace": "刷新插件市场", + "Update to {version}": "更新到 {version}", + "Marketplace": "插件市场", + "Could not download plugin": "无法下载插件", + "Rolled back {name} to {version}": "已将 {name} 回滚到 {version}", + "Could not roll back": "无法回滚", + "Could not open the package": "无法打开插件包", + "Marketplace index URL (https://…/index.json or a folder path)": "插件市场索引 URL(https://…/index.json 或文件夹路径)", + "Settings of the plugin; projects can override them.": "插件的设置;项目可以覆盖。", + "Menus": "菜单", + "Shortcuts": "快捷键", + "Status Bar Items": "状态栏项", + "Editors": "编辑器", + "Open With": "打开方式", + "The plugin could not show this view": "插件无法显示此视图", + "Loading": "加载中", + "Insert Row Above": "在上方插入行", + "Insert Row Below": "在下方插入行", + "Delete {length} Rows": "删除 {length} 行", + "Delete Row": "删除行", + "No rows": "没有行", + "Cell value": "单元格的值", + "{title}: {basename}": "{title}:{basename}", + "Secrets": "密钥", + "Stored": "已保存", + "Not set": "未设置", + "Replace…": "替换…", + "Value of {label}": "{label} 的值", + "The plugin can only send these with its network requests; it cannot read them.": "插件只能在网络请求中发送这些值,无法读取它们。", + "Editor Providers": "编辑器提供者", + "Linked for development": "为开发而链接", + "Low": "低", + "Medium": "中", + "High": "高", + "Install {name} {version}?": "安装 {name} {version}?", + "Allow {name} to run?": "允许 {name} 运行?", + "Allow": "允许", + "This package is not signed. Only continue if you trust where it came from.": "此软件包未签名。只有在信任其来源时才继续。", + "This plugin needs no special permissions.": "此插件不需要特殊权限。", + "Requested permissions": "请求的权限", + "Required": "必需", + "High-risk permissions are equivalent to running arbitrary code on this machine.": "高风险权限相当于在这台计算机上运行任意代码。", + "{icons} icons": "{icons} 个图标", + "Use Default Icons": "使用默认图标", + "Apply icon theme {label}": "应用图标主题 {label}", + "File Icon Themes": "文件图标主题" } diff --git a/src/ui/Menu.tsx b/src/ui/Menu.tsx index 7ccaf8d30..be637b6b6 100644 --- a/src/ui/Menu.tsx +++ b/src/ui/Menu.tsx @@ -13,7 +13,7 @@ import { } from "react"; import clsx from "clsx"; import { create } from "zustand"; -import { getAction, isActionEnabled, executeAction, actionToggled } from "@/platform/actions"; +import { getAction, isActionEnabled, executeActionFrom, actionToggled } from "@/platform/actions"; import { useContextKeys } from "@/platform/context"; import { shortcutLabel } from "@/platform/keymap"; import { Icon, type IconName } from "./icons"; @@ -84,7 +84,7 @@ export function resolveMenuItems(items: MenuItem[]): Resolved[] { shortcut: shortcutLabel(item.action), disabled: !!item.disabled || !isActionEnabled(a), checked: actionToggled(a), - run: () => void executeAction(item.action, item.arg), + run: () => void executeActionFrom("menu", item.action, item.arg), }); return; } diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index c1bafc8f5..6ea79a905 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -21,6 +21,7 @@ */ import { useId, useMemo, type CSSProperties } from "react"; import clsx from "clsx"; +import { create } from "zustand"; import styles from "./icons.module.css"; const RAW = import.meta.glob<string>(["./expui/**/*.svg", "./openidea/**/*.svg"], { @@ -537,10 +538,64 @@ export type IconName = BaseName | `symbol-${SymbolKind}`; export const ICON_NAMES = Object.keys(ICONS) as BaseName[]; function iconPath(name: string): string | undefined { + if (name.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.has(name) ? name : undefined; if (name.startsWith("symbol-")) return SYMBOL_ICONS[name.slice(7) as SymbolKind]; return (ICONS as Record<string, string>)[name]; } +// --------------------------------------------------------------------------- +// Icons supplied at runtime (plugin icon themes). The markup was sanitised by +// the backend (`ide_host::plugins::sanitize_svg`); names are `ext:<source>/<icon>`. + +const EXTERNAL_PREFIX = "ext:"; +const EXTERNAL = new Map<string, string>(); + +/** Registers icons by name (`ext:…`); the returned function removes them. */ +export function registerExternalIcons(icons: ReadonlyArray<readonly [name: string, svg: string]>): () => void { + const names: string[] = []; + for (const [name, svg] of icons) { + if (!name.startsWith(EXTERNAL_PREFIX)) continue; + EXTERNAL.set(name, svg); + prepared.delete(`${name}|16`); + prepared.delete(`${name}|20`); + names.push(name); + } + return () => { + for (const name of names) { + EXTERNAL.delete(name); + prepared.delete(`${name}|16`); + prepared.delete(`${name}|20`); + } + }; +} + +/** A file icon theme (plugin `iconThemes`). Each method returns an icon name, or null for the built-in one. */ +export interface FileIconTheme { + file(fileName: string): string | null; + /** Plain folders only: folders with a role (source root, excluded, …) keep their built-in icons. */ + folder(folderName: string, expanded: boolean): string | null; +} + +let fileIconTheme: FileIconTheme | null = null; + +/** Bumped when the file icon theme changes; file lists subscribe to redraw their icons. */ +export const useFileIconRevision = create<number>(() => 0); + +/** + * Sets the file icon theme: consulted after the user's own file type + * associations and before the built-in icons. + */ +export function setFileIconTheme(theme: FileIconTheme | null) { + if (fileIconTheme === theme) return; + fileIconTheme = theme; + useFileIconRevision.setState((n) => n + 1, true); +} + +/** The icon theme's icon for a plain folder, or null to keep the built-in one. */ +export function themedFolderIconName(folderName: string, expanded = false): IconName | null { + return (fileIconTheme?.folder(folderName, expanded) ?? null) as IconName | null; +} + // --------------------------------------------------------------------------- // SVG processing @@ -562,6 +617,7 @@ interface Prepared { } function raw(path: string): string | undefined { + if (path.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.get(path); return RAW[path.startsWith("openidea/") ? `./${path}.svg` : `./expui/${path}.svg`]; } @@ -934,6 +990,8 @@ export function setFileIconOverride(resolve: ((fileName: string) => IconName | n export function fileIconName(fileName: string): IconName { const custom = iconOverride?.(fileName); if (custom) return custom; + const themed = fileIconTheme?.file(fileName); + if (themed) return themed as IconName; const lower = fileName.toLowerCase(); const named = FILE_NAMES[lower] ?? NAME_PATTERNS.find(([re]) => re.test(lower))?.[1]; if (named) return named; diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 4f8d8df13..62d0823f1 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -941,6 +941,26 @@ export type ContextMenuId = | "selectIn" | "new"; +/** + * Entries of the native main menu's open slots (src-tauri/src/menu.rs + * `Entry::Dynamic`), e.g. `pluginTools` at the end of Tools: each runs `action`. + */ +export interface MenuSlotItem { + action: string; + arg?: string | null; + label: string; +} + +export const useMenuSlotItems = create<{ slots: Record<string, MenuSlotItem[]> }>(() => ({ slots: {} })); + +export function registerMenuSlotItem(slot: string, item: MenuSlotItem): () => void { + useMenuSlotItems.setState((s) => ({ slots: { ...s.slots, [slot]: [...(s.slots[slot] ?? []), item] } })); + return () => + useMenuSlotItems.setState((s) => ({ + slots: { ...s.slots, [slot]: (s.slots[slot] ?? []).filter((i) => i !== item) }, + })); +} + export interface MenuGroupContext { /** Files / directories the menu is for (absolute paths; a virtual URI for virtual documents). */ paths: string[]; diff --git a/wit/events.wit b/wit/events.wit new file mode 100644 index 000000000..8470c298c --- /dev/null +++ b/wit/events.wit @@ -0,0 +1,68 @@ +package openidea:plugin@1.0.0; + +/// Workbench events, delivered in batches to `event-handler.handle`. +interface events { + use types.{error}; + + enum event-kind { + file-opened, + file-saved, + /// The focused editor changed. + editor-focused, + /// An IDE action ran. Requires `events.input`. + action-performed, + /// A repository's state changed (commit, checkout, fetch, …). + vcs-changed, + /// The plugin's `on-schedule` period elapsed. + scheduled, + } + + /// How the user started an action. + enum action-source { + keyboard, + menu, + toolbar, + context-menu, + search, + other, + } + + record action-performed { + /// Action id (`editor.duplicate-line`). + action: string, + title: string, + source: action-source, + /// Its keyboard shortcut, when it has one (`Ctrl+D`). + shortcut: option<string>, + } + + variant event { + file-opened(string), + file-saved(string), + editor-focused(option<string>), + action-performed(action-performed), + vcs-changed(string), + scheduled, + } + + /// Delivers `kind` events from now on, for the rest of the session (also + /// across idle reclaim: an event activates the plugin again). Declare + /// `activation = ["on-event:<kind>"]` to get them before first use. + /// Requires `events` (`events.input` for `action-performed`). + subscribe: func(kind: event-kind) -> result<_, error>; + unsubscribe: func(kind: event-kind); +} + +/// Long-running work with a progress indicator. While a task is open, the +/// call that began it may run for the background budget (60 s) instead of 5 s. +interface tasks { + resource task { + /// `done` of `total` units (`none`: indeterminate), with a short status. + report: func(done: u32, total: option<u32>, message: option<string>); + /// The user cancelled it: stop soon and return. + is-cancelled: func() -> bool; + } + + /// Starts a task shown in the status bar; dropping it ends it. + begin: func(title: string, cancellable: bool) -> task; +} diff --git a/wit/exports.wit b/wit/exports.wit new file mode 100644 index 000000000..f907ff472 --- /dev/null +++ b/wit/exports.wit @@ -0,0 +1,70 @@ +package openidea:plugin@1.0.0; + +interface lifecycle { + use types.{context}; + + /// Called once after the instance is created. Register runtime + /// contributions and subscriptions here; restore state from `storage`. + activate: func(ctx: context) -> result<_, string>; + /// Called before the instance is dropped (disable, uninstall, idle reclaim). + deactivate: func(); +} + +interface commands { + use invocation.{invocation}; + + /// Runs one of the plugin's commands. Returns an optional message. + run: func(id: string, ctx: borrow<invocation>, arg: option<string>) -> result<option<string>, string>; +} + +interface event-handler { + use events.{event}; + use invocation.{invocation}; + + /// A batch of subscribed events, oldest first, from one window (`ctx`). + handle: func(events: list<event>, ctx: borrow<invocation>); +} + +/// Optional export (world `plugin-syntax`): syntax of the plugin's own +/// languages, for languages the IDE has no grammar for. The IDE calls it +/// with the whole document; results are flat. Offsets are UTF-8 byte offsets +/// into `text` (the IDE converts them); lines are 0-based. +interface syntax-provider { + /// One highlighted range. `kind` is a highlight category: keyword, string, + /// string-special, escape, regexp, atom, number, constant, comment, + /// doc-comment, function, function-call, builtin, type, namespace, + /// variable, self-variable, property, attribute, macro, label, tag, + /// operator, punctuation. Unknown kinds are ignored; ranges must be + /// sorted and not overlap. + record token { + start: u32, + end: u32, + kind: string, + } + + /// A symbol of the structure view. `parent` is the index of the enclosing + /// symbol in the same list (which must come first). + record symbol { + name: string, + detail: option<string>, + /// class, interface, struct, enum, function, method, constructor, + /// field, property, variable, constant, module, namespace, event, … + kind: string, + start: u32, + end: u32, + name-start: u32, + name-end: u32, + parent: option<u32>, + } + + /// A foldable line range. `kind`: region, comment or imports. + record fold { + start-line: u32, + end-line: u32, + kind: string, + } + + highlights: func(language: string, text: string) -> list<token>; + outline: func(language: string, text: string) -> list<symbol>; + folding: func(language: string, text: string) -> list<fold>; +} diff --git a/wit/host.wit b/wit/host.wit new file mode 100644 index 000000000..750bfd342 --- /dev/null +++ b/wit/host.wit @@ -0,0 +1,51 @@ +package openidea:plugin@1.0.0; + +/// Writes to the IDE log, tagged with the plugin id. +interface log { + use types.{log-level}; + + log: func(level: log-level, message: string); +} + +/// Contributions made at run time (declare static ones in plugin.toml). +interface contributions { + use types.{error}; + + /// Registers (or updates) a command `plugin.<plugin-id>.<id>`. + /// `id` must match `[a-z0-9][a-z0-9._-]*`. + register-command: func(id: string, title: string, category: option<string>) -> result<_, error>; + /// A language from a `languages/<id>.toml`-style definition. + register-language: func(definition: string) -> result<_, error>; + /// A theme from JSON: {"id","name","kind":"dark"|"light","tokens":{"--var":"value"}}. + register-theme: func(definition: string) -> result<_, error>; +} + +interface ui { + use types.{notification-level}; + + record status-item { + text: string, + tooltip: option<string>, + /// One of the plugin's commands, run when the item is clicked. + command: option<string>, + } + + /// Shows a notification balloon in the window the current command or + /// event came from (every window when there is none). + show-notification: func(level: notification-level, title: string, body: string); + /// Shows or updates the plugin's status bar item `id` in every window. + set-status-item: func(id: string, item: status-item); + remove-status-item: func(id: string); +} + +/// Persistent key-value storage of the plugin (survives restarts and idle +/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB. +interface storage { + use types.{error}; + + get: func(key: string) -> result<option<string>, error>; + set: func(key: string, value: string) -> result<_, error>; + delete: func(key: string) -> result<_, error>; + /// Keys starting with `prefix`, sorted. + keys: func(prefix: string) -> result<list<string>, error>; +} diff --git a/wit/io.wit b/wit/io.wit new file mode 100644 index 000000000..a1aeb8490 --- /dev/null +++ b/wit/io.wit @@ -0,0 +1,85 @@ +package openidea:plugin@1.0.0; + +/// Problems in files, shown in the editor and the Problems view. +interface diagnostics { + use types.{error, range}; + + enum severity { + error, + warning, + info, + hint, + } + + record diagnostic { + range: range, + severity: severity, + message: string, + /// Rule or check id (`LineLength`). + code: option<string>, + } + + /// Replaces the plugin's diagnostics for the file at `path` (absolute, in + /// an open project); an empty list clears them. Requires `project.read`. + publish: func(path: string, diagnostics: list<diagnostic>) -> result<_, error>; +} + +/// HTTP(S) to the hosts granted with `net:<host>`. +interface net { + use types.{error}; + + record header { + name: string, + value: string, + } + + record request { + method: string, + /// `https://…`; plain `http://` only to `localhost` / `127.0.0.1`. + url: string, + /// `{{secret:<name>}}` in a value is replaced by the plugin's secret + /// `name` on the way out (requires `secrets`); the plugin never sees it. + headers: list<header>, + body: option<list<u8>>, + /// Default 30 s; the call's own budget applies too. + timeout-ms: option<u32>, + } + + record response { + status: u16, + headers: list<header>, + /// At most 8 MiB. + body: list<u8>, + } + + /// Fails with `denied` for a host the plugin may not reach, `cancelled` + /// when a newer request replaced the call or the user cancelled. + fetch: func(req: request) -> result<response, error>; +} + +/// Secrets the user stored for the plugin (Settings › Plugins, kept in the +/// OS keychain). Plugins only use them through `{{secret:<name>}}` in +/// `net` requests. Requires `secrets`. +interface secrets { + is-set: func(name: string) -> bool; +} + +/// One-off runs of programs granted with `process:<program>` (the file the +/// user approved, checked before every run). +interface process { + use types.{error}; + use workspace.{project}; + + record output { + /// `none` when killed by a signal. + status: option<s32>, + /// At most 4 MiB each (the rest is dropped). + stdout: string, + stderr: string, + } + + /// Runs `program` with `args` in `cwd`'s root (else the plugin's data + /// directory) and waits; cancelled with the task or call. Call it inside + /// a task for anything slow. + run: func(program: string, args: list<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index 707d8bd91..84b20a900 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -1,84 +1,68 @@ -/// OpenIDEA plugin API, version 0.1. +/// OpenIDEA plugin API, version 1.0 (docs/plugin_plan.md 4.3). Frozen: 1.x +/// only adds (new interfaces, new optional exports); a 2.0 keeps 1.0 running +/// through an adapter in the host. /// -/// A plugin is a WebAssembly component targeting the `plugin` world. It runs -/// sandboxed: no filesystem, network, environment or clock access beyond what -/// WASI preview 2 grants by default (wall/monotonic clocks and random only). -/// Everything else goes through the `host` interface and is gated by the -/// permissions declared in `plugin.toml`. -package openidea:plugin@0.1.0; - -interface types { - enum log-level { - trace, - debug, - info, - warn, - error, - } - - enum notification-level { - info, - warning, - error, - } - - /// Passed to `activate`. - record context { - plugin-id: string, - plugin-version: string, - /// Version of this API the host implements, e.g. "0.1". - api-version: string, - /// Permissions granted from the manifest, e.g. ["project-read"]. - permissions: list<string>, - } -} - -interface host { - use types.{log-level, notification-level}; - - /// Writes to the IDE log (tagged with the plugin id). - log: func(level: log-level, message: string); - - /// Registers (or updates) a command. It appears as action - /// `plugin.<plugin-id>.<id>` in Search Everywhere and the keymap editor. - /// `id` must match `[a-z0-9][a-z0-9._-]*`. - register-command: func(id: string, title: string, category: option<string>); - - /// Shows a notification balloon. - show-notification: func(level: notification-level, title: string, body: string); - - /// Root of the project the current command was invoked from. - /// Requires `project-read`. - workspace-root: func() -> option<string>; - - /// Path of the focused editor's file. Requires `editor-read`. - active-file: func() -> option<string>; - - /// Reads a UTF-8 text file inside the project root (absolute, or relative - /// to the root). Paths escaping the root are rejected. Requires `project-read`. - read-file: func(path: string) -> result<string, string>; - - /// Inserts text at the cursor of the active editor. Requires `editor-write`. - insert-text: func(text: string); - - /// Replaces the active editor's selection(s). Requires `editor-write`. - replace-selection: func(text: string); - - /// Registers a language from a `languages/<id>.toml`-style definition. - register-language: func(definition: string) -> result<_, string>; - - /// Registers a theme from JSON: {"id","name","kind":"dark"|"light","tokens":{"--var":"value"}}. - register-theme: func(definition: string) -> result<_, string>; -} +/// A plugin is a WebAssembly component targeting the `plugin` world (or +/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment +/// or processes beyond WASI's clocks and randomness; everything else goes +/// through the imports below, gated by the capabilities granted in +/// `plugin.toml` (a missing one returns `error::denied`). +package openidea:plugin@1.0.0; world plugin { - use types.{context}; - import host; + import log; + import contributions; + import ui; + import storage; + import workspace; + import editor; + import invocation; + import vcs; + import events; + import tasks; + import diagnostics; + import net; + import secrets; + import process; + import views; + import settings; - /// Called once after loading. Register commands here. - export activate: func(ctx: context) -> result<_, string>; - /// Called before unloading / disabling. - export deactivate: func(); - /// Runs one of the plugin's commands. Returns an optional message. - export run-command: func(id: string, arg: option<string>) -> result<option<string>, string>; + export lifecycle; + export commands; + export event-handler; +} + +/// A plugin that also provides syntax for its languages. +world plugin-syntax { + include plugin; + export syntax-provider; +} + +/// A plugin with editor providers (`[[contributes.providers]]`). +world plugin-providers { + include plugin; + export providers; +} + +/// A plugin with views or custom editors (`[[contributes.views]]`, +/// `[[contributes.custom-editors]]`). +world plugin-views { + include plugin; + export view-provider; +} + +/// Only the `syntax-provider` export: guests generate it next to `plugin` +/// (the component tools merge both into one component). +world syntax { + export syntax-provider; +} + +/// Only the `providers` export, generated next to `plugin` like `syntax`. +world editor-providers { + export providers; +} + +/// Only the `view-provider` export, generated next to `plugin` like `syntax`. +world views-provider { + export view-provider; } diff --git a/wit/providers.wit b/wit/providers.wit new file mode 100644 index 000000000..af3f86eb4 --- /dev/null +++ b/wit/providers.wit @@ -0,0 +1,83 @@ +package openidea:plugin@1.0.0; + +/// Editor features a plugin provides for the languages it declares under +/// `[[contributes.providers]]` (the host calls only declared kinds). They run +/// on the editing path: a result later than ~50 ms is dropped, a call still +/// running at 500 ms is interrupted. +interface providers { + use types.{position, range, text-edit, location}; + + /// The document a request is about. + record document { + path: string, + language: string, + version: u64, + text: string, + } + + enum completion-kind { + text, + method, + function, + %constructor, + field, + variable, + class, + %interface, + module, + property, + keyword, + snippet, + value, + constant, + file, + reference, + } + + record completion-item { + label: string, + detail: option<string>, + kind: option<completion-kind>, + insert-text: string, + /// Replaced on accept; `none`: the word at the caret. + replace: option<range>, + sort-text: option<string>, + documentation: option<string>, + } + + record inline-suggestion { + insert-text: string, + /// Replaced range; `none` inserts at the caret. + replace: option<range>, + } + + record file-edit { + path: string, + edits: list<text-edit>, + } + + record code-action { + title: string, + /// `quickfix`, `refactor`, … + kind: option<string>, + preferred: bool, + edits: list<file-edit>, + } + + record line-marker { + line: u32, + /// Gutter icon: `navigate` (default), `implemented`, `implementing`, `run`. + icon: option<string>, + tooltip: string, + /// Opened on click. + target: option<location>, + } + + completion: func(doc: document, at: position, trigger: option<string>) -> list<completion-item>; + /// Ghost text at the caret (typing replaces an older request: it is cancelled). + inline-completion: func(doc: document, at: position) -> option<inline-suggestion>; + /// Actions for `selection` (quick fixes for the plugin's own diagnostics). + code-actions: func(doc: document, selection: range) -> list<code-action>; + definition: func(doc: document, at: position) -> list<location>; + line-markers: func(doc: document) -> list<line-marker>; +} diff --git a/wit/types.wit b/wit/types.wit new file mode 100644 index 000000000..5c4f237e1 --- /dev/null +++ b/wit/types.wit @@ -0,0 +1,64 @@ +package openidea:plugin@1.0.0; + +/// Shared types. Positions are 0-based lines and UTF-16 code unit columns, +/// as in the editor and LSP. +interface types { + /// Why a host call failed. A capability that is not granted is reported + /// as `denied`, so the plugin can degrade (0.1 trapped instead). + variant error { + /// Not granted; the payload names the capability (`editor.write`). + denied(string), + /// The document changed since the version the edit was made for. + stale, + not-found(string), + invalid(string), + /// Cancelled by the user, or the plugin is being stopped. + cancelled, + failed(string), + } + + enum log-level { + trace, + debug, + info, + warn, + error, + } + + enum notification-level { + info, + warning, + error, + } + + record position { + line: u32, + character: u32, + } + + record range { + start: position, + end: position, + } + + record text-edit { + range: range, + text: string, + } + + /// A range in a file (absolute path). + record location { + path: string, + range: range, + } + + /// Passed to `lifecycle.activate`. + record context { + plugin-id: string, + plugin-version: string, + /// Version of this API the host implements, e.g. "1.0". + api-version: string, + /// Capabilities granted to the plugin (`editor.write`, `process:git`, …). + capabilities: list<string>, + } +} diff --git a/wit/views.wit b/wit/views.wit new file mode 100644 index 000000000..1dfbd1357 --- /dev/null +++ b/wit/views.wit @@ -0,0 +1,146 @@ +package openidea:plugin@1.0.0; + +/// Declarative views (docs/plugin_plan.md 4.2, L2): tool windows declared +/// under `[[contributes.views]]` and custom editors under +/// `[[contributes.customEditors]]`. The plugin describes what to show; the +/// IDE renders it with its own components (theme, keyboard, accessibility) +/// and sends the user's actions back as `view-event`s. Nothing of the plugin +/// runs in the WebView, except a `html` body, which is shown in a sandboxed +/// frame that can only exchange messages with the plugin. +interface view-types { + record button { + id: string, + label: string, + /// An IDE icon name (`refresh`, `add`, `remove`, `run`, …). + icon: option<string>, + enabled: bool, + } + + /// A tree node; `parent` is the key of an earlier item (none: a root). + record tree-item { + key: string, + parent: option<string>, + label: string, + icon: option<string>, + /// Grey text after the label. + detail: option<string>, + /// Shows an expander even before its children are known + /// (`expand` asks for them). + expandable: bool, + expanded: bool, + } + + record list-item { + key: string, + label: string, + icon: option<string>, + detail: option<string>, + } + + variant field-value { + text(string), + multiline(string), + checkbox(bool), + /// The options and the chosen one. + choice(tuple<list<string>, string>), + } + + record field { + key: string, + label: string, + value: field-value, + } + + /// Rows `first-row`..`first-row + len(rows)` of `total-rows`: the IDE + /// asks for the rows it shows (`viewport`), so large tables stay cheap. + record table { + columns: list<string>, + total-rows: u32, + first-row: u32, + rows: list<list<string>>, + editable: bool, + } + + variant body { + /// A centred message (empty state, errors). + message(string), + tree(list<tree-item>), + %list(list<list-item>), + form(list<field>), + table(table), + /// HTML shown in a sandboxed frame: no scripts from elsewhere, no + /// network, no IDE API. Its script talks to the plugin with + /// `openidea.postMessage(text)` / `openidea.onMessage(fn)`. + html(string), + } + + record view { + toolbar: list<button>, + body: body, + /// A line below the body (counts, last refresh…). + status: option<string>, + } + + /// The table rows the IDE shows. + record viewport { + first-row: u32, + rows: u32, + } + + record cell-edit { + row: u32, + column: u32, + value: string, + } + + variant view-event { + /// A toolbar button. + button(string), + /// Double click / Enter on a tree or list item. + activate(string), + select(string), + expand(string), + collapse(string), + /// A form field changed: key and new value (`true`/`false` for checkboxes). + field(tuple<string, string>), + cell(cell-edit), + /// Insert an empty row before this one (the row count: append). + insert-row(u32), + delete-rows(list<u32>), + /// From the `html` body's script. + message(string), + } +} + +/// What a plugin's code can ask of its views. +interface views { + use types.{error}; + + /// The view (or every custom editor of that id) shows outdated content: + /// the IDE renders it again. + refresh: func(view: string); + /// Sends a message to the script of a view's `html` body. + post-message: func(view: string, message: string) -> result<_, error>; + /// Opens a terminal tab in the window the call came from, running + /// `program` (pinned by a `process:<program>` grant) with `args` in the + /// project root: long-running output (logs) and interactive sessions. + open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>; +} + +/// The plugin's own settings (`[contributes.settings]`), as the user set them +/// (project over global) or the schema's default. +interface settings { + /// The value of `key` as JSON (`true`, `3`, `"text"`), none if unknown. + get: func(key: string) -> option<string>; +} + +/// Optional export (world `plugin-views`): content of declared views and +/// custom editors. For a custom editor, `ctx.editor()` is its document. +interface view-provider { + use view-types.{view, viewport, view-event}; + use invocation.{invocation}; + use types.{error}; + + render: func(view: string, viewport: option<viewport>, ctx: borrow<invocation>) -> result<view, error>; + handle: func(view: string, event: view-event, ctx: borrow<invocation>) -> result<_, error>; +} diff --git a/wit/workspace.wit b/wit/workspace.wit new file mode 100644 index 000000000..febca7c6f --- /dev/null +++ b/wit/workspace.wit @@ -0,0 +1,90 @@ +package openidea:plugin@1.0.0; + +interface workspace { + use types.{error}; + + /// A project open in the IDE. + resource project { + /// Absolute path of the project root. Requires `project.read`. + root: func() -> result<string, error>; + /// Reads a UTF-8 text file inside the project (absolute, or relative + /// to the root); paths leaving the root are refused. Unsaved editor + /// contents win over the disk. Requires `project.read`. + read-file: func(path: string) -> result<string, error>; + /// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`), + /// at most `limit`, as absolute paths. Ignored and excluded files are + /// skipped. Requires `project.read`. + find-files: func(glob: string, limit: u32) -> result<list<string>, error>; + } + + /// Projects open in any window. Requires `project.read`. + projects: func() -> result<list<project>, error>; +} + +interface editor { + use types.{error, range, text-edit}; + + record document { + /// Grows with every change; edits name the version they were made for. + version: u64, + text: string, + } + + /// A file open in an editor. + resource text-editor { + /// Absolute path of the file. + path: func() -> string; + /// Language id (`rust`, `solidity`, …). + language: func() -> string; + /// Current version and text. Requires `editor.read`. + document: func() -> result<document, error>; + /// Selections when the command was invoked (a cursor is an empty + /// range). Requires `editor.read`. + selections: func() -> result<list<range>, error>; + /// Applies `edits` as one undoable change, only if the document is + /// still at `version` (else `stale`, and nothing changes). Ranges are + /// in that version and must not overlap. Returns the new version. + /// Requires `editor.write`. + apply-edits: func(version: u64, edits: list<text-edit>) -> result<u64, error>; + } +} + +/// Where a command or event comes from. +interface invocation { + use workspace.{project}; + use editor.{text-editor}; + + resource invocation { + /// The window's project. + project: func() -> option<project>; + /// The window's focused editor. + editor: func() -> option<text-editor>; + } +} + +/// Git, for the repository containing a project. +interface vcs { + use types.{error}; + use workspace.{project}; + + record repository-status { + /// Absolute path of the working tree root. + root: string, + /// `none` when HEAD is detached. + branch: option<string>, + commit: option<string>, + upstream: option<string>, + ahead: u32, + behind: u32, + /// Merge / rebase / cherry-pick in progress. + operation: option<string>, + /// Files with staged or unstaged changes. + changed-files: u32, + } + + /// `none` when the project is not in a repository. Requires `vcs.read`. + status: func(project: borrow<project>) -> result<option<repository-status>, error>; + /// Fetches all remotes (pruning deleted branches). Requires `vcs.write`. + /// Long-running: call it inside a task. + fetch: func(project: borrow<project>) -> result<_, error>; +}