From 1c5a92f952bbda236993e8a844ec7ca594bf1b64 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:54:31 +0800 Subject: [PATCH] feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin system built on WebAssembly components (wasmtime, WASI p2), with a typed WIT API (`openidea:plugin@1.0.0`) and a Rust SDK, shipped as a preview. - Extension points declared in `plugin.toml` (commands, languages, themes, icon themes, menus, key bindings, status items, settings, views, custom editors, file icons and templates, run detectors, editor providers), validated before anything takes effect and projected onto the workbench's own registries: plugin themes and file icon themes are chosen in Settings › Appearance and Quick Switch, plugin shortcuts are edited in Settings › Keymap, plugin problems are grouped in Settings › Inspections. - Capabilities with risk levels and consent at install, pinned `process:` programs, secrets in the OS keychain. - Runtime: one thread per active plugin, fuel/time/memory budgets, soft and hard deadlines on the editing path, cancellation, crash backoff, idle reclaim, a precompiled-component cache; remote projects run code plugins on `openidea-server`. - Web views and custom editors with a sandboxed page, a document bridge joined to the text editor's undo history, and split or preview layouts. - Distribution: signed `.oiplugin` packages, a static marketplace index with revocation, versioned installs with rollback, install from a `.zip` or a folder. The release workflow signs and publishes the official plugins and their index, which released builds use as the default marketplace. - Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion, Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor Night, Excalidraw, Graphviz, Regex Tester, License Header. Closes #732 Co-authored-by: Deng Liu <5108773+dengliu@users.noreply.github.com> Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com> --- .github/workflows/ci.yml | 51 + .github/workflows/release.yml | 65 +- .gitignore | 3 + .oxfmtrc.json | 2 + Cargo.lock | 42 +- Cargo.toml | 5 + crates/ide-core/src/settings.rs | 10 +- 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 | 277 ++ crates/ide-extension/src/index.rs | 104 + crates/ide-extension/src/ledger.rs | 483 +++ crates/ide-extension/src/lib.rs | 36 + crates/ide-extension/src/manifest.rs | 501 +++ crates/ide-extension/src/package.rs | 465 +++ crates/ide-extension/src/point.rs | 175 + crates/ide-extension/src/registry.rs | 305 ++ crates/ide-extension/src/ui.rs | 247 ++ crates/ide-extension/src/when.rs | 136 + crates/ide-host/Cargo.toml | 14 + crates/ide-host/benches/plugins.rs | 149 + .../ide-host/src/bin/openidea-plugin/main.rs | 624 ++++ .../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 | 216 +- crates/ide-host/src/plugins/api.rs | 803 +++++ crates/ide-host/src/plugins/icons.rs | 615 ++++ crates/ide-host/src/plugins/install.rs | 288 ++ crates/ide-host/src/plugins/market.rs | 314 ++ crates/ide-host/src/plugins/mod.rs | 2288 ++++++++++++ crates/ide-host/src/plugins/points.rs | 71 + crates/ide-host/src/plugins/providers.rs | 342 ++ crates/ide-host/src/plugins/remote.rs | 222 ++ crates/ide-host/src/plugins/remote_host.rs | 504 +++ crates/ide-host/src/plugins/run_detectors.rs | 167 + .../src => ide-host/src/plugins}/theme.rs | 67 +- crates/ide-host/src/plugins/types.rs | 236 ++ crates/ide-host/src/plugins/ui_points.rs | 544 +++ crates/ide-host/src/plugins/views.rs | 92 + crates/ide-host/src/plugins/web.rs | 245 ++ 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 | 2330 ++++++++++++ crates/ide-host/tests/remote_plugins.rs | 304 ++ crates/ide-http/src/client.rs | 29 +- crates/ide-language/Cargo.toml | 1 + crates/ide-language/src/lib.rs | 48 +- crates/ide-language/src/plugin_point.rs | 294 ++ 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 | 15 +- crates/ide-plugin/src/actor.rs | 312 ++ crates/ide-plugin/src/lib.rs | 779 +--- crates/ide-plugin/src/manifest.rs | 279 -- crates/ide-plugin/src/package.rs | 74 - crates/ide-plugin/src/runtime.rs | 1115 ++++-- crates/ide-plugin/src/types.rs | 574 ++- crates/ide-plugin/src/wit_host.rs | 662 ++++ crates/ide-plugin/tests/api_compat.rs | 270 ++ .../tests/fixtures}/hello/Cargo.lock | 9 +- .../tests/fixtures}/hello/Cargo.toml | 4 +- .../tests/fixtures/hello/dist/plugin.wasm | Bin 0 -> 132268 bytes .../fixtures/hello/dist/plugin.wasm.sources | 1 + .../tests/fixtures/hello/plugin.toml | 52 + .../tests/fixtures/hello/src/lib.rs | 180 + .../ide-plugin/tests/fixtures/jobs/Cargo.lock | 333 ++ .../ide-plugin/tests/fixtures/jobs/Cargo.toml | 21 + .../tests/fixtures/jobs/dist/plugin.wasm | Bin 0 -> 123922 bytes .../fixtures/jobs/dist/plugin.wasm.sources | 1 + .../tests/fixtures/jobs/plugin.toml | 29 + .../tests/fixtures/jobs/settings.schema.json | 14 + .../ide-plugin/tests/fixtures/jobs/src/lib.rs | 74 + .../fixtures}/language-toy/languages/toy.toml | 0 .../tests/fixtures}/language-toy/plugin.toml | 0 .../tests/fixtures/misbehave/dist/plugin.wasm | Bin 47332 -> 87772 bytes .../misbehave/dist/plugin.wasm.sources | 1 + .../tests/fixtures/misbehave/plugin.toml | 43 +- .../tests/fixtures/misbehave/src/lib.rs | 123 +- .../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 | 353 ++ 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/plugins/themes.md | 11 +- knip.ts | 2 + perf/budgets.json | 8 +- perf/rust.ts | 2 +- plugins/README.md | 784 +++- plugins/build.sh | 133 +- plugins/hello/dist/plugin.wasm | Bin 70241 -> 0 bytes plugins/hello/plugin.toml | 23 - plugins/hello/src/lib.rs | 70 - 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 -> 166971 bytes .../ai-completion/dist/plugin.wasm.sources | 1 + 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 -> 159016 bytes .../checkstyle/dist/plugin.wasm.sources | 1 + 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 -> 130963 bytes .../csv-editor/dist/plugin.wasm.sources | 1 + plugins/official/csv-editor/plugin.toml | 23 + .../official/csv-editor/settings.schema.json | 17 + plugins/official/csv-editor/src/lib.rs | 319 ++ plugins/official/excalidraw/.gitignore | 4 + plugins/official/excalidraw/NOTICE.md | 28 + .../official/excalidraw/icons/excalidraw.svg | 5 + .../excalidraw/icons/excalidraw_dark.svg | 5 + .../excalidraw/languages/excalidraw.toml | 6 + plugins/official/excalidraw/plugin.toml | 35 + .../excalidraw/templates/drawing.excalidraw | 13 + .../official/excalidraw/web-src/index.html | 32 + .../official/excalidraw/web-src/package.json | 26 + .../excalidraw/web-src/pnpm-lock.yaml | 3208 +++++++++++++++++ .../official/excalidraw/web-src/src/assets.ts | 6 + .../excalidraw/web-src/src/downloads.ts | 25 + .../official/excalidraw/web-src/src/main.tsx | 123 + .../excalidraw/web-src/src/openidea.d.ts | 41 + .../excalidraw/web-src/src/scene.test.ts | 40 + .../official/excalidraw/web-src/src/scene.ts | 170 + .../excalidraw/web-src/src/sync.test.ts | 107 + .../official/excalidraw/web-src/src/sync.ts | 128 + .../excalidraw/web-src/src/test-setup.ts | 10 + .../official/excalidraw/web-src/tsconfig.json | 17 + .../excalidraw/web-src/vite.config.ts | 48 + 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 -> 108855 bytes .../git-toolbox/dist/plugin.wasm.sources | 1 + plugins/official/git-toolbox/plugin.toml | 26 + plugins/official/git-toolbox/src/lib.rs | 242 ++ plugins/official/graphviz/.gitignore | 4 + plugins/official/graphviz/Cargo.lock | 334 ++ plugins/official/graphviz/Cargo.toml | 22 + plugins/official/graphviz/NOTICE.md | 28 + plugins/official/graphviz/dist/plugin.wasm | Bin 0 -> 124849 bytes .../graphviz/dist/plugin.wasm.sources | 1 + plugins/official/graphviz/icons/graphviz.svg | 6 + .../official/graphviz/icons/graphviz_dark.svg | 6 + plugins/official/graphviz/languages/dot.toml | 8 + plugins/official/graphviz/plugin.toml | 38 + plugins/official/graphviz/src/lib.rs | 141 + plugins/official/graphviz/templates/graph.dot | 11 + plugins/official/graphviz/web-src/index.html | 25 + .../official/graphviz/web-src/package.json | 20 + .../official/graphviz/web-src/pnpm-lock.yaml | 669 ++++ plugins/official/graphviz/web-src/src/main.ts | 213 ++ .../graphviz/web-src/src/openidea.d.ts | 40 + .../graphviz/web-src/src/render.test.ts | 45 + .../official/graphviz/web-src/src/render.ts | 68 + .../official/graphviz/web-src/src/style.css | 89 + .../official/graphviz/web-src/tsconfig.json | 16 + .../official/graphviz/web-src/vite.config.ts | 18 + .../official/harbor-night}/README.md | 20 +- plugins/official/harbor-night/plugin.toml | 16 + .../themes/harbor-night.scheme.json | 0 .../themes/harbor-night.theme.json | 0 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 -> 98959 bytes .../key-promoter/dist/plugin.wasm.sources | 1 + plugins/official/key-promoter/plugin.toml | 31 + plugins/official/key-promoter/src/lib.rs | 164 + plugins/official/license-header/Cargo.lock | 333 ++ plugins/official/license-header/Cargo.toml | 21 + .../official/license-header/dist/plugin.wasm | Bin 0 -> 143721 bytes .../license-header/dist/plugin.wasm.sources | 1 + plugins/official/license-header/plugin.toml | 35 + .../license-header/settings.schema.json | 21 + plugins/official/license-header/src/lib.rs | 395 ++ 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 -> 159800 bytes .../official/mybatis/dist/plugin.wasm.sources | 1 + plugins/official/mybatis/plugin.toml | 17 + plugins/official/mybatis/src/lib.rs | 376 ++ plugins/official/regex-tester/Cargo.lock | 373 ++ plugins/official/regex-tester/Cargo.toml | 23 + .../official/regex-tester/dist/plugin.wasm | Bin 0 -> 930798 bytes .../regex-tester/dist/plugin.wasm.sources | 1 + plugins/official/regex-tester/plugin.toml | 32 + .../regex-tester/settings.schema.json | 14 + plugins/official/regex-tester/src/lib.rs | 280 ++ plugins/official/regex-tester/src/page.html | 377 ++ 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 -> 107192 bytes .../solidity/dist/plugin.wasm.sources | 1 + .../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 -> 164796 bytes .../string-tools/dist/plugin.wasm.sources | 1 + plugins/official/string-tools/plugin.toml | 171 + plugins/official/string-tools/src/lib.rs | 460 +++ scripts/theme/theme.test.ts | 5 +- sdk/rust/openidea-plugin/Cargo.lock | 327 ++ sdk/rust/openidea-plugin/Cargo.toml | 20 + sdk/rust/openidea-plugin/src/host.rs | 560 +++ sdk/rust/openidea-plugin/src/lib.rs | 637 ++++ sdk/rust/openidea-plugin/src/mock.rs | 732 ++++ sdk/rust/openidea-plugin/src/providers.rs | 167 + sdk/rust/openidea-plugin/src/syntax.rs | 224 ++ sdk/rust/openidea-plugin/src/text.rs | 82 + sdk/rust/openidea-plugin/src/types.rs | 278 ++ sdk/rust/openidea-plugin/src/views.rs | 225 ++ sdk/rust/openidea-plugin/tests/api_frozen.rs | 180 + .../openidea-plugin/tests/wit_coverage.rs | 53 + sdk/rust/openidea-plugin/wit/events.wit | 68 + sdk/rust/openidea-plugin/wit/exports.wit | 75 + sdk/rust/openidea-plugin/wit/host.wit | 38 + sdk/rust/openidea-plugin/wit/io.wit | 85 + sdk/rust/openidea-plugin/wit/plugin.wit | 67 + sdk/rust/openidea-plugin/wit/providers.wit | 69 + 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 | 5 + src-tauri/src/commands/app.rs | 10 +- src-tauri/src/commands/mod.rs | 25 + src-tauri/src/commands/plugin.rs | 488 ++- src-tauri/src/lib.rs | 3 + src-tauri/src/menu.rs | 5 +- src-tauri/src/plugin_web.rs | 107 + src-tauri/src/remote_dev.rs | 110 +- src-tauri/src/state.rs | 4 +- src-tauri/tauri.conf.json | 2 +- src/bindings.ts | 428 ++- src/editor/api.ts | 21 + src/editor/core/cm/languages.ts | 11 + src/editor/core/model/attachment.test.ts | 57 + src/editor/core/model/attachment.ts | 29 +- src/features/app/menuBridge.ts | 20 +- src/features/plugins/PluginsPage.module.css | 73 +- src/features/plugins/PluginsPage.tsx | 454 ++- .../plugins/__tests__/plugins.test.tsx | 421 ++- src/features/plugins/__tests__/views.test.tsx | 283 ++ .../plugins/__tests__/webViews.test.tsx | 278 ++ src/features/plugins/backend.ts | 366 +- src/features/plugins/bridge.ts | 225 +- src/features/plugins/consent.tsx | 141 + src/features/plugins/editorContext.ts | 23 + src/features/plugins/iconThemes.ts | 190 + src/features/plugins/index.ts | 35 +- src/features/plugins/lineMarkerGutter.ts | 149 + src/features/plugins/points.tsx | 179 + src/features/plugins/projection.ts | 129 + src/features/plugins/runtimeUi.tsx | 283 ++ src/features/plugins/secrets.tsx | 114 + src/features/plugins/store.ts | 199 +- src/features/plugins/uiPoints.tsx | 429 +++ .../plugins/views/PluginView.module.css | 211 ++ src/features/plugins/views/PluginView.tsx | 506 +++ src/features/plugins/views/WebView.tsx | 259 ++ src/features/plugins/views/customEditor.tsx | 354 ++ src/features/plugins/views/editorLayout.tsx | 90 + src/features/plugins/views/frame-bridge.js | 135 + src/features/plugins/views/frame.ts | 102 + src/features/plugins/views/viewStore.ts | 39 + src/features/project-tree/ProjectView.tsx | 12 +- .../search/everywhere/SearchEverywhere.tsx | 8 +- .../settings/__tests__/inspections.test.ts | 22 + .../settings/__tests__/keymap.test.ts | 25 + src/features/settings/fileTypesRuntime.ts | 6 + src/features/settings/keymap/model.ts | 19 +- src/features/settings/model/behaviorSchema.ts | 15 +- src/features/settings/model/schema.ts | 9 +- src/features/settings/pages/behavior.tsx | 54 +- .../settings/pages/editor/inspections.tsx | 11 +- .../settings/pages/editor/inspectionsModel.ts | 42 +- src/features/settings/pages/registry.tsx | 31 +- src/features/templates/files/store.ts | 30 +- .../viewers/markdown/MarkdownEditor.tsx | 3 +- src/features/viewers/markdown/mermaid.test.ts | 3 +- src/features/viewers/markdown/mermaid.ts | 21 +- src/features/welcome/CustomizePage.tsx | 13 +- src/platform/actions.ts | 20 +- src/platform/filePresentation.ts | 7 +- src/platform/inspections.ts | 7 + src/platform/keymap.ts | 64 +- src/platform/languageList.ts | 20 + src/platform/locales/text/zh.json | 76 +- src/platform/themeWatch.ts | 25 + src/platform/themes.test.ts | 56 +- src/platform/themes.ts | 99 + src/ui/Menu.tsx | 4 +- src/ui/icons.tsx | 75 + src/workbench/TitleBar.tsx | 6 +- src/workbench/contributions.ts | 20 + src/workbench/quickSwitch.ts | 9 +- src/workbench/statusBarWidgets.ts | 9 +- src/workbench/theme.ts | 77 +- templates/theme-plugin/plugin.toml | 15 - vite.config.ts | 13 +- wit/events.wit | 68 + wit/exports.wit | 75 + wit/host.wit | 38 + wit/io.wit | 85 + wit/plugin.wit | 141 +- wit/providers.wit | 69 + wit/types.wit | 64 + wit/views.wit | 146 + wit/workspace.wit | 90 + 450 files changed, 47018 insertions(+), 3058 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 (76%) 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/src/plugins/web.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/api_compat.rs rename {plugins => crates/ide-plugin/tests/fixtures}/hello/Cargo.lock (98%) rename {plugins => crates/ide-plugin/tests/fixtures}/hello/Cargo.toml (73%) create mode 100644 crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/hello/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/hello/src/lib.rs create mode 100644 crates/ide-plugin/tests/fixtures/jobs/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/jobs/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/jobs/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/jobs/settings.schema.json create mode 100644 crates/ide-plugin/tests/fixtures/jobs/src/lib.rs rename {plugins => crates/ide-plugin/tests/fixtures}/language-toy/languages/toy.toml (100%) rename {plugins => crates/ide-plugin/tests/fixtures}/language-toy/plugin.toml (100%) create mode 100644 crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources 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 delete mode 100644 plugins/hello/dist/plugin.wasm delete mode 100644 plugins/hello/plugin.toml delete mode 100644 plugins/hello/src/lib.rs 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/dist/plugin.wasm.sources 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/dist/plugin.wasm.sources 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/dist/plugin.wasm.sources 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/excalidraw/.gitignore create mode 100644 plugins/official/excalidraw/NOTICE.md create mode 100644 plugins/official/excalidraw/icons/excalidraw.svg create mode 100644 plugins/official/excalidraw/icons/excalidraw_dark.svg create mode 100644 plugins/official/excalidraw/languages/excalidraw.toml create mode 100644 plugins/official/excalidraw/plugin.toml create mode 100644 plugins/official/excalidraw/templates/drawing.excalidraw create mode 100644 plugins/official/excalidraw/web-src/index.html create mode 100644 plugins/official/excalidraw/web-src/package.json create mode 100644 plugins/official/excalidraw/web-src/pnpm-lock.yaml create mode 100644 plugins/official/excalidraw/web-src/src/assets.ts create mode 100644 plugins/official/excalidraw/web-src/src/downloads.ts create mode 100644 plugins/official/excalidraw/web-src/src/main.tsx create mode 100644 plugins/official/excalidraw/web-src/src/openidea.d.ts create mode 100644 plugins/official/excalidraw/web-src/src/scene.test.ts create mode 100644 plugins/official/excalidraw/web-src/src/scene.ts create mode 100644 plugins/official/excalidraw/web-src/src/sync.test.ts create mode 100644 plugins/official/excalidraw/web-src/src/sync.ts create mode 100644 plugins/official/excalidraw/web-src/src/test-setup.ts create mode 100644 plugins/official/excalidraw/web-src/tsconfig.json create mode 100644 plugins/official/excalidraw/web-src/vite.config.ts 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/dist/plugin.wasm.sources create mode 100644 plugins/official/git-toolbox/plugin.toml create mode 100644 plugins/official/git-toolbox/src/lib.rs create mode 100644 plugins/official/graphviz/.gitignore create mode 100644 plugins/official/graphviz/Cargo.lock create mode 100644 plugins/official/graphviz/Cargo.toml create mode 100644 plugins/official/graphviz/NOTICE.md create mode 100644 plugins/official/graphviz/dist/plugin.wasm create mode 100644 plugins/official/graphviz/dist/plugin.wasm.sources create mode 100644 plugins/official/graphviz/icons/graphviz.svg create mode 100644 plugins/official/graphviz/icons/graphviz_dark.svg create mode 100644 plugins/official/graphviz/languages/dot.toml create mode 100644 plugins/official/graphviz/plugin.toml create mode 100644 plugins/official/graphviz/src/lib.rs create mode 100644 plugins/official/graphviz/templates/graph.dot create mode 100644 plugins/official/graphviz/web-src/index.html create mode 100644 plugins/official/graphviz/web-src/package.json create mode 100644 plugins/official/graphviz/web-src/pnpm-lock.yaml create mode 100644 plugins/official/graphviz/web-src/src/main.ts create mode 100644 plugins/official/graphviz/web-src/src/openidea.d.ts create mode 100644 plugins/official/graphviz/web-src/src/render.test.ts create mode 100644 plugins/official/graphviz/web-src/src/render.ts create mode 100644 plugins/official/graphviz/web-src/src/style.css create mode 100644 plugins/official/graphviz/web-src/tsconfig.json create mode 100644 plugins/official/graphviz/web-src/vite.config.ts rename {templates/theme-plugin => plugins/official/harbor-night}/README.md (74%) create mode 100644 plugins/official/harbor-night/plugin.toml rename {templates/theme-plugin => plugins/official/harbor-night}/themes/harbor-night.scheme.json (100%) rename {templates/theme-plugin => plugins/official/harbor-night}/themes/harbor-night.theme.json (100%) 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/dist/plugin.wasm.sources create mode 100644 plugins/official/key-promoter/plugin.toml create mode 100644 plugins/official/key-promoter/src/lib.rs create mode 100644 plugins/official/license-header/Cargo.lock create mode 100644 plugins/official/license-header/Cargo.toml create mode 100644 plugins/official/license-header/dist/plugin.wasm create mode 100644 plugins/official/license-header/dist/plugin.wasm.sources create mode 100644 plugins/official/license-header/plugin.toml create mode 100644 plugins/official/license-header/settings.schema.json create mode 100644 plugins/official/license-header/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/dist/plugin.wasm.sources create mode 100644 plugins/official/mybatis/plugin.toml create mode 100644 plugins/official/mybatis/src/lib.rs create mode 100644 plugins/official/regex-tester/Cargo.lock create mode 100644 plugins/official/regex-tester/Cargo.toml create mode 100644 plugins/official/regex-tester/dist/plugin.wasm create mode 100644 plugins/official/regex-tester/dist/plugin.wasm.sources create mode 100644 plugins/official/regex-tester/plugin.toml create mode 100644 plugins/official/regex-tester/settings.schema.json create mode 100644 plugins/official/regex-tester/src/lib.rs create mode 100644 plugins/official/regex-tester/src/page.html 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/dist/plugin.wasm.sources 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/dist/plugin.wasm.sources 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/tests/api_frozen.rs create mode 100644 sdk/rust/openidea-plugin/tests/wit_coverage.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-tauri/src/plugin_web.rs create mode 100644 src/features/plugins/__tests__/views.test.tsx create mode 100644 src/features/plugins/__tests__/webViews.test.tsx create mode 100644 src/features/plugins/consent.tsx create mode 100644 src/features/plugins/editorContext.ts 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/WebView.tsx create mode 100644 src/features/plugins/views/customEditor.tsx create mode 100644 src/features/plugins/views/editorLayout.tsx create mode 100644 src/features/plugins/views/frame-bridge.js create mode 100644 src/features/plugins/views/frame.ts create mode 100644 src/features/plugins/views/viewStore.ts create mode 100644 src/platform/languageList.ts create mode 100644 src/platform/themeWatch.ts delete mode 100644 templates/theme-plugin/plugin.toml 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a1236d6..180729a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,57 @@ jobs: - run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl - run: target/x86_64-unknown-linux-musl/debug/openidea-server --version + # The plugin SDK, the official plugins and the test fixtures build in their + # own workspaces (the root excludes them), so the jobs above don't see them. + plugins: + name: Plugins (SDK, official, fixtures) + runs-on: ubuntu-22.04 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: { targets: wasm32-wasip2, components: "clippy, rustfmt" } + - uses: Swatinem/rust-cache@v2 + with: + key: plugins + cache-directories: target-plugins + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Format, lint (host and wasm) and test each crate + shell: bash + run: | + set -euo pipefail + for dir in sdk/rust/openidea-plugin plugins/official/*/ crates/ide-plugin/tests/fixtures/*/; do + [ -f "$dir/Cargo.toml" ] || continue + echo "::group::$dir" + cargo fmt --check --manifest-path "$dir/Cargo.toml" + cargo clippy --locked --all-targets --manifest-path "$dir/Cargo.toml" -- -D warnings + cargo clippy --locked --target wasm32-wasip2 --manifest-path "$dir/Cargo.toml" -- -D warnings + cargo test --locked --manifest-path "$dir/Cargo.toml" + echo "::endgroup::" + done + - uses: pnpm/action-setup@v4 + with: { version: 11 } + - uses: actions/setup-node@v4 + with: { node-version: 22 } + # Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test. + - name: Web pages typecheck and test + shell: bash + run: | + set -euo pipefail + for dir in plugins/official/*/web-src/; do + [ -f "$dir/package.json" ] || continue + echo "::group::$dir" + pnpm --dir "$dir" install --frozen-lockfile --ignore-workspace + pnpm --dir "$dir" exec tsc --noEmit + pnpm --dir "$dir" test + echo "::endgroup::" + done + # The host's tests run the checked-in components: they must be built from these sources. + # Web pages aren't checked in: this builds them and checks the result. + - name: Checked-in components are up to date, web pages build + run: plugins/build.sh --check + deny: name: Cargo deny runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1527d20f..3874f17e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -180,6 +180,69 @@ jobs: if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256" gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber + # The official plugins, signed, and the marketplace index released apps read + # (releases/latest/download/plugins-index.json). They are not in the + # installers: users install them from Settings › Plugins. Without the + # signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen` + # writes) nothing is published and the release goes on. + plugins: + needs: create-release + name: Official plugins + runs-on: ubuntu-22.04 + env: + SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }} + steps: + - id: key + run: | + if [ -z "$SIGNING_KEY" ]; then + echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published" + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v4 + if: steps.key.outputs.skip != 'true' + with: { ref: "${{ env.TAG }}" } + - uses: dtolnay/rust-toolchain@stable + if: steps.key.outputs.skip != 'true' + with: { targets: wasm32-wasip2 } + - uses: Swatinem/rust-cache@v2 + if: steps.key.outputs.skip != 'true' + with: { key: release-plugins } + - uses: pnpm/action-setup@v4 + if: steps.key.outputs.skip != 'true' + with: { version: 11 } + - uses: actions/setup-node@v4 + if: steps.key.outputs.skip != 'true' + with: { node-version: 22 } + # Builds the web pages; fails when a checked-in component isn't built from these sources. + - name: Build + if: steps.key.outputs.skip != 'true' + run: | + plugins/build.sh --check + cargo build --release --locked -p ide-host --bin openidea-plugin + - name: Pack, index and upload + if: steps.key.outputs.skip != 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tool="$PWD/target/release/openidea-plugin" + key="$RUNNER_TEMP/openidea.key" + out="$RUNNER_TEMP/market" + (umask 077 && printf '%s' "$SIGNING_KEY" > "$key") + trap 'rm -f "$key"' EXIT + mkdir -p "$out" + for dir in plugins/official/*/; do + id="$(sed -n 's/^id *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)" + version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)" + "$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin" + done + # The published index's revocations and earlier keys carry over. + curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \ + -o "$out/plugins-index.json" || rm -f "$out/plugins-index.json" + "$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" + gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber + # The updater manifest (latest.json), built once from the signed bundles every build uploaded. # Fails when a platform is missing, so a release never ships a manifest without it. updater-manifest: @@ -243,7 +306,7 @@ jobs: core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`); publish: - needs: [create-release, build, server, updater-manifest] + needs: [create-release, build, server, plugins, updater-manifest] runs-on: ubuntu-latest steps: - uses: actions/github-script@v7 diff --git a/.gitignore b/.gitignore index 88c7d40b..75785cf0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ lerna-debug.log* node_modules dist dist-ssr +# Plugin components are checked in (plugins/build.sh --check keeps them current). +!plugins/official/*/dist +!crates/ide-plugin/tests/fixtures/*/dist *.local # Editor directories and files diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 70f64132..0a0d7102 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -15,6 +15,8 @@ "pnpm-lock.yaml", "**/*.toml", "fixtures", + "plugins/*/web", + "plugins/official/*/web", "crates/**/tests/fixtures" ], "overrides": [ diff --git a/Cargo.lock b/Cargo.lock index a39ae699..4d308c5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4181,6 +4181,26 @@ dependencies = [ "which", ] +[[package]] +name = "ide-extension" +version = "0.1.35" +dependencies = [ + "base64 0.22.1", + "blake3", + "ed25519-dalek", + "ide-core", + "parking_lot", + "semver", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tempfile", + "toml 0.9.12+spec-1.1.0", + "tracing", + "zip", +] + [[package]] name = "ide-fonts" version = "0.1.35" @@ -4210,9 +4230,13 @@ dependencies = [ name = "ide-host" version = "0.1.35" dependencies = [ + "base64 0.22.1", + "criterion", "dirs 6.0.0", "dunce", "encoding_rs", + "getrandom 0.3.4", + "globset", "ide-agent", "ide-analysis", "ide-build", @@ -4220,12 +4244,14 @@ dependencies = [ "ide-coverage", "ide-dap", "ide-db", + "ide-extension", "ide-http", "ide-index", "ide-jsonrpc", "ide-language", "ide-lsp", "ide-notebook", + "ide-plugin", "ide-remote", "ide-run", "ide-sdk", @@ -4239,7 +4265,9 @@ dependencies = [ "ide-vcs", "ide-vfs", "ide-workspace", + "ignore", "parking_lot", + "reqwest", "rustix", "serde", "serde_json", @@ -4247,10 +4275,12 @@ dependencies = [ "specta-typescript", "tempfile", "tokio", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "uuid", "windows-sys 0.61.2", + "zip", ] [[package]] @@ -4316,6 +4346,7 @@ dependencies = [ "blake3", "criterion", "ide-core", + "ide-extension", "ide-text", "parking_lot", "regex-lite", @@ -4417,19 +4448,20 @@ dependencies = [ name = "ide-plugin" version = "0.1.35" dependencies = [ + "blake3", "ide-core", - "ide-language", + "ide-extension", "parking_lot", + "semver", "serde", "serde_json", "specta", "tempfile", "thiserror 2.0.21", - "toml 0.9.12+spec-1.1.0", + "tokio", "tracing", "wasmtime", "wasmtime-wasi", - "zip", ] [[package]] @@ -6150,6 +6182,7 @@ dependencies = [ name = "openidea" version = "0.1.35" dependencies = [ + "base64 0.22.1", "blake3", "block2 0.6.2", "dirs 6.0.0", @@ -6164,6 +6197,7 @@ dependencies = [ "ide-dap", "ide-db", "ide-docker", + "ide-extension", "ide-fonts", "ide-github", "ide-host", @@ -6195,6 +6229,8 @@ dependencies = [ "objc2-foundation 0.3.2", "objc2-web-kit 0.3.2", "parking_lot", + "percent-encoding", + "security-framework", "serde", "serde_json", "specta", diff --git a/Cargo.toml b/Cargo.toml index f306034e..a1c42bf2 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 95ba3f5f..ebb97835 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(), } } } @@ -323,7 +327,8 @@ impl Default for NotificationGroupSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct AppearanceSettings { - /// `dark` | `light` | `system` | `dark-hc` (high-contrast dark) + /// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy + /// "Sync with OS"), or a plugin's theme: `plugin:/`. pub theme: String, pub accent: String, pub ui_font_size: f32, @@ -332,6 +337,8 @@ pub struct AppearanceSettings { pub sync_with_os: bool, pub preferred_light_theme: String, pub preferred_dark_theme: String, + /// A plugin's file icon theme (`/`); empty = the built-in icons. + pub file_icon_theme: String, /// IDE zoom in percent (50–300). pub zoom: u32, /// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). @@ -386,6 +393,7 @@ impl Default for AppearanceSettings { sync_with_os: false, preferred_light_theme: "light".into(), preferred_dark_theme: "dark".into(), + file_icon_theme: String::new(), zoom: 100, use_custom_font: false, ui_font_family: "Inter".into(), diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 341e0663..2f4f88fc 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 e1fb0eca..f108c0fb 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 | Self::Clipboard => { + Risk::Medium + } + Self::Process(_) | Self::FsRead(_) => Risk::High, + } + } + + /// Low-risk capabilities that the one-time migration of the 0.1 "declared + /// means granted" behaviour may grant automatically (plan 4.6). + pub fn auto_migrates(&self) -> bool { + self.risk() == Risk::Low + } + + /// Short user-facing description for install and grant pages. + pub fn describe(&self) -> String { + match self { + Self::ProjectRead => "Read files in the open project".into(), + Self::EditorRead => "Read the focused editor's file and selection".into(), + Self::EditorWrite => "Change text in the editor".into(), + Self::VcsRead => "Read version control state".into(), + Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(), + Self::Storage => "Store its own data".into(), + Self::Secrets => "Store secrets in the system keychain".into(), + Self::Events => "Observe workbench events (files opened, saved, focus)".into(), + Self::InputEvents => "Observe keystrokes and editor input".into(), + Self::Clipboard => "Read and write the clipboard from its web views".into(), + Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"), + Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"), + Self::FsRead(path) => format!("Read files under {path}, outside the project"), + } + } + + /// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`). + pub fn covers(&self, wanted: &Capability) -> bool { + match (self, wanted) { + (Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host), + (Self::FsRead(base), Self::FsRead(path)) => { + std::path::Path::new(path).starts_with(std::path::Path::new(base)) + } + _ => self == wanted, + } + } +} + +/// `example.com` matches exactly; `*.example.com` matches any subdomain (not the apex). +pub fn host_matches(pattern: &str, host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + let pattern = pattern.to_ascii_lowercase(); + match pattern.strip_prefix("*.") { + Some(suffix) => host.len() > suffix.len() + 1 && host.ends_with(&format!(".{suffix}")), + None => host == pattern, + } +} + +impl fmt::Display for Capability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ProjectRead => f.write_str("project.read"), + Self::EditorRead => f.write_str("editor.read"), + Self::EditorWrite => f.write_str("editor.write"), + Self::VcsRead => f.write_str("vcs.read"), + Self::VcsWrite => f.write_str("vcs.write"), + Self::Storage => f.write_str("storage"), + Self::Secrets => f.write_str("secrets"), + Self::Events => f.write_str("events"), + Self::InputEvents => f.write_str("events.input"), + Self::Clipboard => f.write_str("clipboard"), + Self::Net(h) => write!(f, "net:{h}"), + Self::Process(b) => write!(f, "process:{b}"), + Self::FsRead(p) => write!(f, "fs.read:{p}"), + } + } +} + +impl FromStr for Capability { + type Err = String; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + if let Some((kind, arg)) = s.split_once(':') { + let arg = arg.trim(); + if arg.is_empty() { + return Err(format!("capability {s:?} needs a value after ':'")); + } + return match kind { + "net" => { + let host = arg.strip_prefix("*.").unwrap_or(arg); + let valid = !host.is_empty() + && host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + }); + if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) } + } + "process" => { + if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() { + return Err(format!("{s:?}: use a program name or an absolute path")); + } + Ok(Self::Process(arg.to_owned())) + } + "fs.read" => { + if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') { + return Err(format!("{s:?}: use an absolute path (or ~/…)")); + } + Ok(Self::FsRead(arg.to_owned())) + } + _ => Err(format!("unknown capability {s:?}")), + }; + } + Ok(match s { + "project.read" | "project-read" => Self::ProjectRead, + "editor.read" | "editor-read" => Self::EditorRead, + "editor.write" | "editor-write" => Self::EditorWrite, + "vcs.read" => Self::VcsRead, + "vcs.write" => Self::VcsWrite, + "storage" => Self::Storage, + "secrets" => Self::Secrets, + "events" => Self::Events, + "events.input" => Self::InputEvents, + "clipboard" => Self::Clipboard, + _ => return Err(format!("unknown capability {s:?}")), + }) + } +} + +impl Serialize for Capability { + fn serialize(&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", + ] { + assert_eq!(s.parse::().unwrap().to_string(), s); + } + // Absolute paths are the platform's. + let (git, etc) = if cfg!(windows) { + (r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows") + } else { + ("process:/usr/bin/git", "fs.read:/etc") + }; + for s in [git, etc] { + assert_eq!(s.parse::().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!("clipboard".parse::().unwrap(), Capability::Clipboard); + assert_eq!(Capability::Clipboard.risk(), Risk::Medium); + assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High); + assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates()); + } + + #[test] + fn host_patterns() { + assert!(host_matches("crates.io", "crates.io")); + assert!(host_matches("crates.io", "CRATES.IO.")); + assert!(!host_matches("crates.io", "static.crates.io")); + assert!(host_matches("*.github.com", "api.github.com")); + assert!(!host_matches("*.github.com", "github.com")); + assert!(!host_matches("*.github.com", "evilgithub.com")); + let any_gh: Capability = "net:*.github.com".parse().unwrap(); + assert!(any_gh.covers(&"net:api.github.com".parse().unwrap())); + assert!(Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etc/hosts".into()))); + assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into()))); + } +} diff --git a/crates/ide-extension/src/index.rs b/crates/ide-extension/src/index.rs new file mode 100644 index 00000000..cadc0ea9 --- /dev/null +++ b/crates/ide-extension/src/index.rs @@ -0,0 +1,104 @@ +//! The contribution index: validated UI +//! declarations of installed packages, kept on disk so start-up does not +//! validate them again (sanitising thousands of icon SVGs, say). +//! +//! Only packages with a [`Package::digest`] are indexed: installed copies, +//! which the host never modifies after install. An entry is keyed by that +//! digest, the extension point, the declaration and a salt naming the +//! validation logic (the IDE version), so a new package version, a changed +//! declaration or an IDE upgrade simply misses. A miss or an unreadable entry +//! only costs the validation. + +use crate::point::Package; +use crate::ui::UiDecl; +use std::collections::BTreeSet; +use std::path::PathBuf; + +/// Bumped when [`UiDecl`]'s serialized shape changes. +const FORMAT: u32 = 1; + +pub struct ContributionIndex { + dir: PathBuf, + salt: String, +} + +impl ContributionIndex { + /// `salt` names the validators' version (the IDE version). + pub fn new(dir: PathBuf, salt: &str) -> Self { + Self { dir, salt: format!("{FORMAT}:{salt}") } + } + + /// Entries start with a prefix of the package digest, for pruning. + fn file(&self, digest: &str, point: &str, decl: &toml::Value) -> PathBuf { + let mut h = blake3::Hasher::new(); + for part in [self.salt.as_str(), digest, point, &decl.to_string()] { + h.update(part.as_bytes()); + h.update(&[0]); + } + let key = h.finalize().to_hex(); + self.dir.join(format!("{}-{}.json", prefix(digest), &key[..32])) + } + + pub fn get(&self, pkg: &Package, point: &str, decl: &toml::Value) -> Option { + 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 00000000..9bed117b --- /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 00000000..9089067a --- /dev/null +++ b/crates/ide-extension/src/lib.rs @@ -0,0 +1,36 @@ +//! The plugin system's kernel, independent of the Wasm runtime: +//! +//! - [`Manifest`]: `plugin.toml` (0.2 format; 0.1 still parses); +//! - [`Capability`]: what a plugin may do, with its [`Risk`] level; +//! - [`ExtensionPoint`]: one kind of contribution, implemented by the crate that +//! owns the feature; [`ExtensionRegistry`] validates a package's declarations +//! ([`ExtensionRegistry::prepare`], no side effects), makes them take effect +//! all or nothing ([`ExtensionRegistry::commit`]) and withdraws them per plugin; +//! - [`UiStore`] / [`UiPoint`]: UI contributions, validated here and projected +//! by the client without the possibility to fail; +//! - [`ledger`]: which capabilities the user granted to which plugin; +//! - [`package`]: the package file, its signature and the marketplace index. +//! +//! Dependency direction: `ide-core` ← `ide-extension` ← feature crates (their +//! extension points) and `ide-plugin` (Wasm) ← `ide-host` (assembly). + +mod capability; +mod index; +pub mod ledger; +mod manifest; +pub mod package; +mod point; +mod registry; +mod ui; +mod when; + +pub use capability::{Capability, CapabilityInfo, Risk, host_matches}; +pub use index::ContributionIndex; +pub use manifest::{ + ActivationEvent, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, check_relative, + engine_compatible, validate_item_id, validate_plugin_id, validate_point_id, +}; +pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode}; +pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet}; +pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json}; +pub use when::validate_when; diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs new file mode 100644 index 00000000..99820d5f --- /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 00000000..b8d3988d --- /dev/null +++ b/crates/ide-extension/src/package.rs @@ -0,0 +1,465 @@ +//! Plugin distribution: 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()) +} + +/// Archiver metadata at the top of an archive, not part of a package +/// (`__MACOSX/…`, `.DS_Store`, AppleDouble `._…` files). +fn archiver_metadata(name: &str) -> bool { + let top = name.split('/').next().unwrap_or_default(); + top == "__MACOSX" || top == ".DS_Store" || (top.starts_with("._") && top.len() > 2) +} + +/// Where the package is inside an archive: at its root (`.oiplugin`, or a zip +/// of the package's contents) or in its only folder (a zip of the package +/// folder). `None` when neither holds a manifest. +fn package_prefix(names: &[String]) -> Option { + if names.iter().any(|n| n == MANIFEST_FILE) { + return Some(String::new()); + } + let mut tops = names.iter().filter(|n| !archiver_metadata(n)).map(|n| n.split('/').next().unwrap_or_default()); + let top = tops.next()?; + if tops.any(|t| t != top) { + return None; + } + let prefix = format!("{top}/"); + names.iter().any(|n| *n == format!("{prefix}{MANIFEST_FILE}")).then_some(prefix) +} + +/// Extracts the package `archive` (an `.oiplugin`, or a `.zip` of a package +/// folder or of its contents) into the empty directory `dest`, refusing +/// paths leaving it, links, and oversized contents. +pub fn unpack(archive: &Path, dest: &Path) -> Result { + let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?; + let mut zip = + zip::ZipArchive::new(file).map_err(|e| format!("{}: not a plugin package ({e})", archive.display()))?; + if zip.len() > MAX_ENTRIES { + return Err(format!("more than {MAX_ENTRIES} files")); + } + let mut names = Vec::with_capacity(zip.len()); + for i in 0..zip.len() { + let entry = zip.by_index(i).map_err(|e| e.to_string())?; + if !entry.is_dir() { + names.push(entry.name().map_err(|e| e.to_string())?.into_owned()); + } + } + // An IntelliJ plugin or any other zip: say what is missing rather than fail later. + let prefix = package_prefix(&names).ok_or_else(|| { + format!("{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", archive.display()) + })?; + std::fs::create_dir_all(dest).map_err(|e| e.to_string())?; + let (mut files, mut signature, mut total, mut manifest) = (Vec::new(), None, 0u64, false); + for i in 0..zip.len() { + let mut entry = zip.by_index(i).map_err(|e| e.to_string())?; + if entry.is_dir() { + continue; + } + let full = entry.name().map_err(|e| e.to_string())?.into_owned(); + // Outside the package folder there is only archiver metadata (`package_prefix` checked). + let Some(name) = full.strip_prefix(&prefix).map(str::to_owned) else { continue }; + if archiver_metadata(&name) { + continue; + } + if entry.is_symlink() { + return Err(format!("{full}: links are not allowed in packages")); + } + let rel = safe_path(&name).ok_or_else(|| format!("invalid path {full:?} in package"))?; + let mut bytes = Vec::new(); + entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + total += bytes.len() as u64; + if total > MAX_TOTAL { + return Err(format!("the package unpacks to more than {} MiB", MAX_TOTAL >> 20)); + } + if rel == Path::new(SIGNATURE_FILE) { + signature = Some(serde_json::from_slice(&bytes).map_err(|e| format!("{SIGNATURE_FILE}: {e}"))?); + continue; + } + let to = dest.join(&rel); + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&to, bytes).map_err(|e| e.to_string())?; + if rel == Path::new(MANIFEST_FILE) { + manifest = true; + } else { + files.push(rel); + } + } + if !manifest { + return Err(format!("the package has no {MANIFEST_FILE}")); + } + files.sort(); + Ok(Unpacked { files, signature }) +} + +// ---- index ------------------------------------------------------------------- + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Index { + pub format: u32, + #[serde(default)] + pub publishers: Vec, + #[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()); + } + + fn zip_of(path: &Path, entries: &[(&str, &str)]) { + let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap()); + for (name, body) in entries { + zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(body.as_bytes()).unwrap(); + } + zip.finish().unwrap(); + } + + #[test] + fn a_zip_holds_the_package_at_its_root_or_in_its_only_folder() { + let tmp = tempfile::tempdir().unwrap(); + let manifest = "id = \"acme.tools\""; + let at_root = tmp.path().join("root.zip"); + zip_of(&at_root, &[(MANIFEST_FILE, manifest), ("dist/plugin.wasm", "w"), (".DS_Store", "x")]); + let got = unpack(&at_root, &tmp.path().join("a")).unwrap(); + assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]); + + // What zipping the folder in Finder makes: the folder plus `__MACOSX`. + let folder = tmp.path().join("folder.zip"); + zip_of( + &folder, + &[ + ("acme-tools/plugin.toml", manifest), + ("acme-tools/dist/plugin.wasm", "w"), + ("__MACOSX/acme-tools/._plugin.toml", "x"), + ], + ); + let dest = tmp.path().join("b"); + let got = unpack(&folder, &dest).unwrap(); + assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]); + assert!(dest.join(MANIFEST_FILE).is_file()); + assert!(!dest.join("__MACOSX").exists() && !dest.join("acme-tools").exists()); + + // Two folders, a manifest one level too deep, or none (an IntelliJ plugin): not a package. + for (name, entries) in [ + ("two.zip", &[("a/plugin.toml", manifest), ("b/x.txt", "x")][..]), + ("deep.zip", &[("a/b/plugin.toml", manifest)][..]), + ("intellij.zip", &[("my-plugin/lib/my-plugin.jar", "jar")][..]), + ] { + let path = tmp.path().join(name); + zip_of(&path, entries); + let err = unpack(&path, &tmp.path().join(name).with_extension("out")).unwrap_err(); + assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}"); + } + } +} diff --git a/crates/ide-extension/src/point.rs b/crates/ide-extension/src/point.rs new file mode 100644 index 00000000..7a74ac0d --- /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 00000000..e8630fd8 --- /dev/null +++ b/crates/ide-extension/src/registry.rs @@ -0,0 +1,305 @@ +//! Extension points plus the contributions committed to them, accounted per plugin. + +use crate::capability::Capability; +use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared}; +use parking_lot::{Mutex, RwLock}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +/// A contribution as the plugin details page lists it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ContributionInfo { + pub point: String, + pub label: String, +} + +struct Entry { + point: &'static str, + key: Option, + label: String, + disposable: Disposable, +} + +/// Every declaration of a package, validated; nothing has taken effect yet. +pub struct PreparedSet { + pub plugin: String, + /// Union of what the declarations need (beyond the manifest's own list). + pub capabilities: Vec, + /// 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, 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) + .map_err(|e| format!("contributes.{point_id}[{i}]: {}", strip_prefix(&e, point_id)))?; + for cap in &prepared.capabilities { + if !set.capabilities.contains(cap) { + set.capabilities.push(cap.clone()); + } + } + for f in &prepared.files { + if !set.files.contains(f) { + set.files.push(f.clone()); + } + } + set.items.push((point.id(), prepared)); + } + } + Ok(set) + } + + /// Makes a prepared set take effect, all or nothing: if one commit fails, + /// the ones before it are disposed in reverse order. + pub fn commit(&self, set: PreparedSet) -> Result<(), String> { + let mut committed: Vec = Vec::with_capacity(set.items.len()); + for (point, prepared) in set.items { + let Prepared { key, label, commit, .. } = prepared; + match commit() { + Ok(disposable) => committed.push(Entry { point, key, label, disposable }), + Err(e) => { + tracing::error!(plugin = %set.plugin, "contributes.{point}: commit failed: {e}"); + while let Some(entry) = committed.pop() { + entry.disposable.dispose(); + } + return Err(format!("contributes.{point}: {e}")); + } + } + } + let replaced = { + let mut entries = self.entries.lock(); + let list = entries.entry(set.plugin).or_default(); + let mut replaced = Vec::new(); + for new in &committed { + if let Some(key) = &new.key + && let Some(pos) = list.iter().position(|e| e.point == new.point && e.key.as_ref() == Some(key)) + { + replaced.push(list.remove(pos)); + } + } + list.extend(committed); + replaced + }; + for old in replaced.into_iter().rev() { + old.disposable.dispose(); + } + Ok(()) + } + + /// Withdraws everything `plugin` contributed, newest first. + pub fn dispose_plugin(&self, plugin: &str) -> bool { + let list = self.entries.lock().remove(plugin).unwrap_or_default(); + let any = !list.is_empty(); + for entry in list.into_iter().rev() { + entry.disposable.dispose(); + } + any + } + + pub fn contributions(&self, plugin: &str) -> Vec { + self.entries + .lock() + .get(plugin) + .map(|list| { + list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect() + }) + .unwrap_or_default() + } + + /// How many contributions `plugin` has at `point`. + pub fn count(&self, plugin: &str, point: &str) -> usize { + self.entries.lock().get(plugin).map_or(0, |list| list.iter().filter(|e| e.point == point).count()) + } + + /// Plugins with at least one contribution. + pub fn plugins(&self) -> Vec { + 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).unwrap(); + assert_eq!(*live.lock(), ["c", "a", "b"]); + assert_eq!(reg.count("p", "things"), 2); + assert_eq!(reg.contributions("p").len(), 3); + assert!(reg.dispose_plugin("p")); + assert!(live.lock().is_empty()); + } + + #[test] + fn a_failed_commit_rolls_back() { + let (reg, live) = setup(); + let p = pkg("[[contributes.things]]\nname = \"a\"\n[[contributes.things]]\nname = \"b\"\nfail = true"); + let err = reg.commit(reg.prepare(&p).unwrap()).unwrap_err(); + assert!(err.contains("boom")); + assert!(live.lock().is_empty(), "a was undone"); + assert!(reg.contributions("p").is_empty()); + } + + #[test] + fn invalid_declarations_fail_prepare_and_unknown_points_warn() { + let (reg, _) = setup(); + let err = reg.prepare(&pkg("[[contributes.things]]\nnope = 1")).unwrap_err(); + assert_eq!(err, "contributes.things[0]: missing name"); + let set = + reg.prepare(&pkg("[[contributes.futureThing]]\nx = 1\n[[contributes.things]]\nname = \"a\"")).unwrap(); + assert_eq!(set.len(), 1); + assert_eq!(set.warnings.len(), 1); + assert!(set.warnings[0].contains("futureThing")); + } +} diff --git a/crates/ide-extension/src/ui.rs b/crates/ide-extension/src/ui.rs new file mode 100644 index 00000000..9a59029b --- /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; + + #[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()).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 00000000..98cd3c93 --- /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 6f2c1ce5..e95aee03 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,15 @@ tempfile.workspace = true [dev-dependencies] ide-ssh = { workspace = true, features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } +criterion = { version = "0.5", default-features = false } +zip.workspace = true [target.'cfg(unix)'.dependencies] rustix = { version = "1.1", features = ["fs", "process"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + +[[bench]] +name = "plugins" +harness = false diff --git a/crates/ide-host/benches/plugins.rs b/crates/ide-host/benches/plugins.rs new file mode 100644 index 00000000..c7ba4f6c --- /dev/null +++ b/crates/ide-host/benches/plugins.rs @@ -0,0 +1,149 @@ +//! `cargo bench -p ide-host`: the plugin runtime budgets, checked by `pnpm perf:rust` against +//! `perf/budgets.json` › rust (`plugins/…`). +//! +//! - `load_50`: start-up load of 50 installed plugins (20 with code, 10 +//! languages with a syntax provider, 10 icon themes, 10 declarations only), +//! from the contribution index; no Wasm is instantiated. +//! - `cold_activation`: an idle-reclaimed plugin's next command: activation +//! from the precompiled component plus the call. +//! - `syntax_highlights_2k_lines`: an edit-path call (Solidity highlighting). + +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use ide_core::LanguageId; +use ide_host::plugins::{Consent, PluginHost}; +use ide_plugin::{Invocation, Limits}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +fn repo() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap() +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for e in std::fs::read_dir(from).unwrap().flatten() { + let name = e.file_name(); + if matches!(name.to_str(), Some("target" | "src" | "Cargo.lock" | ".gitignore")) { + continue; + } + let dst = to.join(&name); + if e.path().is_dir() { copy_dir(&e.path(), &dst) } else { std::fs::copy(e.path(), &dst).map(|_| ()).unwrap() } + } +} + +/// A copy of `src` installed under a new id (and new language / theme ids). +fn variant(work: &Path, src: &Path, n: usize, rename: &[(&str, String)]) -> PathBuf { + let dir = work.join(format!("pkg{n}")); + copy_dir(src, &dir); + for file in ["plugin.toml", "languages/solidity.toml"] { + let path = dir.join(file); + if let Ok(mut text) = std::fs::read_to_string(&path) { + for (from, to) in rename { + text = text.replace(from, to); + } + std::fs::write(&path, text).unwrap(); + } + } + dir +} + +fn sink() -> Arc { + Arc::new(|_: Option<&str>, _| {}) +} + +fn install_50(plugins: &Path, work: &Path) { + let host = PluginHost::new(plugins.to_path_buf(), sink(), Limits::default()).unwrap(); + host.load_all(); + let root = repo(); + let mut n = 0; + let mut install = |src: PathBuf, rename: Vec<(&str, String)>| { + let pkg = variant(work, &src, n, &rename); + n += 1; + host.install_from_dir(&pkg, &Consent::default()).unwrap(); + }; + for i in 0..20 { + install( + root.join("crates/ide-plugin/tests/fixtures/hello"), + vec![("id = \"hello\"", format!("id = \"bench.hello{i}\""))], + ); + } + for i in 0..10 { + install( + root.join("plugins/official/solidity"), + vec![ + ("openidea.solidity", format!("bench.solidity{i}")), + ("id = \"solidity\"", format!("id = \"solidity{i}\"")), + ("extensions = [\"sol\"]", format!("extensions = [\"sol{i}\"]")), + ("on-language:solidity", format!("on-language:solidity{i}")), + ("id = \"forge-", format!("id = \"f{i}-")), + ("id = \"hardhat-", format!("id = \"h{i}-")), + ], + ); + } + for i in 0..10 { + install( + root.join("plugins/official/material-icons"), + vec![ + ("openidea.material-icons", format!("bench.icons{i}")), + ("id = \"material\"", format!("id = \"material{i}\"")), + ], + ); + } + for i in 0..10 { + let dir = work.join(format!("static{i}")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("plugin.toml"), + format!( + "id = \"bench.static{i}\"\nname = \"Static {i}\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.commands]]\nid = \"a\"\ntitle = \"Static {i}: A\"\nwhen = \"editorLang == rust\"\n" + ), + ) + .unwrap(); + host.install_from_dir(&dir, &Consent::default()).unwrap(); + } +} + +fn bench(c: &mut Criterion) { + let tmp = tempfile::tempdir().unwrap(); + let plugins = tmp.path().join("plugins"); + install_50(&plugins, &tmp.path().join("work")); + + let mut g = c.benchmark_group("plugins"); + g.sample_size(20); + g.bench_function("load_50", |b| { + b.iter(|| { + let host = PluginHost::new(plugins.clone(), sink(), Limits::default()).unwrap(); + host.load_all(); + assert_eq!(host.instances(), 0, "start-up instantiates no Wasm"); + black_box(host.list(None).plugins.len()) + }) + }); + + let limits = Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() }; + let host = Arc::new(PluginHost::new(plugins.clone(), sink(), limits).unwrap()); + host.load_all(); + host.warm_component_cache(); + g.bench_function("cold_activation", |b| { + b.iter(|| { + host.tick(); + // Fails for want of an editor, after activating and running. + let r = host.run_command("bench.hello0", "word-count", None, Invocation::default()); + black_box(r.is_err()) + }) + }); + + let provider = host.syntax_provider(); + let lang = LanguageId::new("solidity0"); + let unit = "contract Token {\n uint256 public total;\n mapping(address => uint256) balances;\n event Sent(address to, uint256 v);\n function send(address to, uint256 v) public returns (bool) {\n // move funds\n balances[to] += v;\n emit Sent(to, v);\n return true;\n }\n}\n"; + let text = unit.repeat(2000 / unit.lines().count()); + assert!(provider.highlights(&lang, &text).is_some()); + g.bench_function("syntax_highlights_2k_lines", |b| { + b.iter(|| black_box(provider.highlights(&lang, &text).map(|t| t.len()))) + }); + g.finish(); +} + +criterion_group!(benches, bench); +criterion_main!(benches); 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 00000000..99b73630 --- /dev/null +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -0,0 +1,624 @@ +//! `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 built_for = engine.check_component(&component)?; + let size = std::fs::metadata(dir.join(wasm)).map(|m| m.len()).unwrap_or(0); + let api = built_for.map(|v| format!(", plugin API {}.{}", v.major, v.minor)).unwrap_or_default(); + println!(" code: {} ({}{api})", wasm.display(), human_size(size)); + } else { + println!(" code: none (contributions only)"); + } + if !manifest.activation.is_empty() { + let events: Vec = 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 00000000..fc23cdd0 --- /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 the Harbor Night plugin. + .replace("harbor-night", &slug) + .replace("Harbor Night", vars.name) + }; + let files: Vec<(String, &str)> = match template { + Template::Command => vec![ + ("plugin.toml".into(), COMMAND_MANIFEST), + ("Cargo.toml".into(), COMMAND_CARGO), + ("src/lib.rs".into(), COMMAND_LIB), + (".gitignore".into(), "/target\n"), + ("README.md".into(), COMMAND_README), + ], + Template::Theme => vec![ + ("plugin.toml".into(), THEME_MANIFEST), + (format!("themes/{slug}.theme.json"), THEME_UI), + (format!("themes/{slug}.scheme.json"), THEME_SCHEME), + ], + Template::IconTheme => vec![ + ("plugin.toml".into(), ICON_MANIFEST), + ("icons/theme.json".into(), ICON_THEME), + ("icons/file.svg".into(), ICON_FILE), + ("icons/folder.svg".into(), ICON_FOLDER), + ("icons/folder-open.svg".into(), ICON_FOLDER_OPEN), + ("icons/markdown.svg".into(), ICON_MARKDOWN), + ], + Template::Language => { + vec![("plugin.toml".into(), LANGUAGE_MANIFEST), (format!("languages/{slug}.toml"), LANGUAGE_DEF)] + } + }; + files.into_iter().map(|(path, content)| (path, fill(content))).collect() +} + +const COMMAND_MANIFEST: &str = r#"id = "__ID__" +name = "__NAME__" +version = "0.1.0" +description = "Adds two sample commands." +authors = [] +engines.openidea = "^1.0" +# Built by `openidea-plugin build`. The code is loaded the first time one of +# its commands runs; the commands below show in Find Action before that. +wasm = "dist/plugin.wasm" + +# What the code may do. The user approves these when installing. +[capabilities] +required = ["editor.read"] + +[[contributes.commands]] +id = "say-hello" +title = "__NAME__: Say Hello" +category = "__NAME__" + +[[contributes.commands]] +id = "line-count" +title = "__NAME__: Line Count of Active File" +category = "__NAME__" +"#; + +const COMMAND_CARGO: &str = r#"[package] +name = "__CRATE__" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "__SDK__" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" +"#; + +const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin. + +use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin}; + +#[derive(Default)] +pub struct Main { + greetings: u32, +} + +impl Plugin for Main { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result, String> { + match id { + "say-hello" => { + // Instance state lasts until the plugin is idle for a while; + // keep what must survive in `host::storage`. + self.greetings += 1; + let body = format!("Hello #{} from __NAME__!", self.greetings); + host::notify(NotificationLevel::Info, "__NAME__", &body); + Ok(None) + } + "line-count" => { + let editor = ctx.editor().ok_or("no file is open")?; + let text = editor.document()?.text; + Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path()))) + } + _ => Err(format!("unknown command {id}")), + } + } +} + +register_plugin!(Main); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + #[test] + fn says_hello() { + let host = MockHost::install(); + let mut plugin = Main::default(); + plugin.run_command("say-hello", &host.invocation(), None).unwrap(); + plugin.run_command("say-hello", &host.invocation(), None).unwrap(); + assert_eq!(host.notifications()[1].body, "Hello #2 from __NAME__!"); + } + + #[test] + fn counts_lines() { + let host = MockHost::install(); + host.open_editor("/project/src/main.rs", "rust", "fn main() {\n}\n", &[]); + let out = Main::default().run_command("line-count", &host.invocation(), None).unwrap(); + assert_eq!(out.as_deref(), Some("2 lines in /project/src/main.rs")); + } +} +"#; + +const COMMAND_README: &str = r#"# __NAME__ + +An OpenIDEA plugin. + +```sh +cargo test # logic, against the SDK's mock host +openidea-plugin build # → dist/plugin.wasm, validated like an install +openidea-plugin link # load this folder in the IDE (Settings › Plugins) +``` + +`plugin.toml` declares the commands, so they appear in Find Action before the +code is loaded, and the capabilities the code needs. `src/lib.rs` implements +them with the `openidea-plugin` SDK. +"#; + +const THEME_MANIFEST: &str = r#"id = "__ID__" +name = "__NAME__" +version = "0.1.0" +description = "A dark theme." +authors = [] +engines.openidea = "^1.0" + +# One entry per theme. `editor-scheme` is optional: its tokens are merged over +# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md. +[[contributes.themes]] +path = "themes/__SLUG__.theme.json" +editor-scheme = "themes/__SLUG__.scheme.json" +"#; + +const THEME_UI: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.theme.json"); +const THEME_SCHEME: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.scheme.json"); + +const ICON_MANIFEST: &str = r#"id = "__ID__" +name = "__NAME__" +version = "0.1.0" +description = "File and folder icons." +authors = [] +engines.openidea = "^1.0" + +# Apply it in Settings › Plugins › __NAME__. SVGs are sanitised on install: +# no scripts, event handlers, external references or entities. +[[contributes.iconThemes]] +id = "__SLUG__" +label = "__NAME__" +path = "icons/theme.json" +"#; + +const ICON_THEME: &str = r#"{ + "iconDefinitions": { + "file": "icons/file.svg", + "folder": "icons/folder.svg", + "folder-open": "icons/folder-open.svg", + "markdown": "icons/markdown.svg" + }, + "file": "file", + "folder": "folder", + "folderExpanded": "folder-open", + "fileExtensions": { "md": "markdown", "markdown": "markdown" }, + "fileNames": { "readme": "markdown" }, + "folderNames": {} +} +"#; + +const ICON_FILE: &str = r##" +"##; + +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 ada210d8..6a1f2578 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 898b2884..edaafb43 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 f84ab3c7..f8b27063 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,99 @@ 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); + +/// A plugin's `completion-item.kind` (wit/providers.wit lists them). +fn plugin_completion_kind(kind: &str) -> Option { + Some(match kind { + "text" => CompletionKind::Text, + "method" => CompletionKind::Method, + "function" => CompletionKind::Function, + "constructor" => CompletionKind::Constructor, + "field" => CompletionKind::Field, + "variable" => CompletionKind::Variable, + "class" => CompletionKind::Class, + "interface" => CompletionKind::Interface, + "module" => CompletionKind::Module, + "property" => CompletionKind::Property, + "unit" => CompletionKind::Unit, + "value" => CompletionKind::Value, + "enum" => CompletionKind::Enum, + "keyword" => CompletionKind::Keyword, + "snippet" => CompletionKind::Snippet, + "color" => CompletionKind::Color, + "file" => CompletionKind::File, + "reference" => CompletionKind::Reference, + "folder" => CompletionKind::Folder, + "enum-member" => CompletionKind::EnumMember, + "constant" => CompletionKind::Constant, + "struct" => CompletionKind::Struct, + "event" => CompletionKind::Event, + "operator" => CompletionKind::Operator, + "type-parameter" => CompletionKind::TypeParameter, + _ => return None, + }) +} + +fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem { + let kind = item.kind.as_deref().and_then(plugin_completion_kind); + CompletionItem { + label: item.label, + label_detail: None, + detail: item.detail, + kind, + documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }), + sort_text: item.sort_text, + filter_text: None, + preselect: false, + deprecated: false, + insert_text: item.insert_text, + is_snippet: false, + replace_range: item.replace, + replace_range_tab: None, + additional_edits: Vec::new(), + commit_characters: Vec::new(), + command: None, + data: None, + server: PLUGIN_SERVER, + } } /// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing. @@ -54,9 +146,26 @@ pub async fn lsp_inline_completion( ) -> IdeResult> { let lsp = host.project(window)?.lsp.clone(); let cancel = host.begin_request(request); - let result = lsp.inline_completion(doc, pos, automatic, cancel).await; + let result = lsp.inline_completion(doc, pos, automatic, cancel.clone()).await; + // Plugins: cancelled (cooperatively) when the editor cancels the request. + let plugin = match plugin_providers(host, window, doc, ProviderKind::InlineCompletion) { + Some((plugins, d)) if !matches!(&result, Ok(items) if !items.is_empty()) => { + let cancel = cancel.clone(); + plugin_call(move || plugins.provide_inline_completion(&d, pos, &cancel)).await.flatten() + } + _ => None, + }; host.end_request(request); - result + match plugin { + Some((_, s)) => Ok(vec![InlineCompletionItem { + insert_text: s.insert_text, + range: s.replace, + filter_text: None, + command: None, + server: PLUGIN_SERVER, + }]), + None => result, + } } pub async fn lsp_resolve_completion( @@ -87,7 +196,40 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) } pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { - 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 +307,47 @@ pub async fn lsp_code_actions( let project = host.project(window)?; let documents = project.documents.clone(); let version = blocking(move || documents.version(doc)).await?; - let actions = project.lsp.code_actions(doc, range, only).await?; + let result = project.lsp.code_actions(doc, range, only.clone()).await; + let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) { + Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(), + None => Vec::new(), + }; + let plugin_actions: Vec = plugin_actions + .into_iter() + .map(|(_, a)| plugin_code_action(a)) + .filter(|a| { + only.is_empty() + || a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}.")))) + }) + .collect(); + let actions = if plugin_actions.is_empty() { + result? + } else { + let mut out = result.unwrap_or_default(); + out.extend(plugin_actions); + out + }; prepare_actions(project, doc, version, actions).await } +fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction { + let files = a + .edits + .into_iter() + .map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None }) + .collect(); + CodeAction { + title: a.title, + kind: a.kind, + is_preferred: a.preferred, + disabled_reason: None, + edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }), + command: None, + data: None, + server: PLUGIN_SERVER, + } +} + /// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). pub async fn lsp_code_action_query( host: &Host, @@ -551,3 +730,28 @@ pub(crate) fn register(d: &mut Dispatcher) { lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_completion_kind_the_plugin_api_documents_is_understood() { + let wit = include_str!("../../../../wit/providers.wit"); + let doc: String = wit + .lines() + .map(|l| l.trim().trim_start_matches("///").trim()) + .skip_while(|l| !l.starts_with("Icon and sort group:")) + .take_while(|l| !l.starts_with("kind:")) + .collect::>() + .join(" "); + let list = doc.strip_prefix("Icon and sort group:").unwrap().split(". Unknown").next().unwrap(); + let list = list.replace(" or ", ", "); + let kinds: Vec<&str> = list.split(',').map(str::trim).filter(|k| !k.is_empty()).collect(); + assert_eq!(kinds.len(), 25, "{kinds:?}"); + for k in kinds { + assert!(plugin_completion_kind(k).is_some(), "{k:?}"); + } + assert_eq!(plugin_completion_kind("nonsense"), None); + } +} diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs new file mode 100644 index 00000000..d1f8f1a1 --- /dev/null +++ b/crates/ide-host/src/plugins/api.rs @@ -0,0 +1,803 @@ +//! [`HostApi`] for one plugin's code: what its imports reach in the IDE. +//! Capabilities were checked by the runtime before any of this runs. + +use super::{PluginEvent, PluginEventSink}; +use ide_core::{CancellationToken, TextEdit}; +use ide_extension::Package; +use ide_language::ProgramPin; +use ide_plugin::{ + EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus, + StatusItem, +}; +use parking_lot::{Mutex, RwLock}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +/// Largest storage of one plugin (keys + values). +const MAX_STORAGE: usize = 1 << 20; +const MAX_KEY: usize = 256; +/// Most status bar items per plugin. +const MAX_STATUS_ITEMS: usize = 8; + +/// What plugins see of the open projects; provided by the app +/// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop. +pub trait PluginWorkspace: Send + Sync { + /// Roots of the open projects. + fn projects(&self) -> Vec; + /// 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 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>>, + /// plugin → files it shows problems for, withdrawn when it stops. + diagnostics: 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, + data_dir: PathBuf, + pins: Arc>>, + ui: Arc, + ) -> Arc { + Arc::new(Self { + ui, + sink, + workspace: RwLock::new(None), + secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })), + pins, + data_dir, + tasks: Mutex::default(), + next_task: AtomicU64::new(1), + status: Mutex::default(), + subscriptions: Mutex::default(), + diagnostics: Mutex::default(), + }) + } + + fn workspace(&self) -> Option> { + self.workspace.read().clone() + } + + pub fn project_of(&self, window: &str) -> Option { + self.workspace()?.project_of(window) + } + + /// Shows `plugin`'s problems of `path`, remembering the file until they are + /// cleared (by the plugin, or when it stops). + fn publish_diagnostics( + &self, + plugin: &str, + path: &Path, + items: Vec, + ) -> Result<(), HostError> { + let workspace = self.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?; + let empty = items.is_empty(); + workspace.publish_diagnostics(plugin, path, items)?; + let mut published = self.diagnostics.lock(); + if empty { + if let Some(paths) = published.get_mut(plugin) { + paths.remove(path); + if paths.is_empty() { + published.remove(plugin); + } + } + } else { + published.entry(plugin.to_owned()).or_default().insert(path.to_path_buf()); + } + Ok(()) + } + + pub fn status_items(&self) -> Vec<(String, String, StatusItem)> { + self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect() + } + + /// Withdraws a plugin's run-time state (it stopped or failed). Its + /// subscriptions go only with `subscriptions`, as they survive restarts. + pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) { + let removed: Vec = { + 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); + } + // Its problems go with it (an idle reclaim keeps them: it isn't cleared then). + let paths = self.diagnostics.lock().remove(plugin).unwrap_or_default(); + if let Some(workspace) = self.workspace() { + for path in paths { + let _ = workspace.publish_diagnostics(plugin, &path, Vec::new()); + } + } + if subscriptions && self.subscriptions.lock().remove(plugin).is_some() { + self.sink.emit(None, PluginEvent::SubscriptionsChanged); + } + } + + /// The user cancelled a task in the UI. + pub fn cancel_task(&self, id: u64) -> bool { + let mut tasks = self.tasks.lock(); + let Some(t) = tasks.get_mut(&id) else { return false }; + t.cancelled = true; + t.cancel.cancel(); + true + } + + fn end_task(&self, id: u64) { + if let Some(t) = self.tasks.lock().remove(&id) { + self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() }); + } + } + + pub fn storage_file(&self, plugin: &str) -> PathBuf { + self.data_dir.join(plugin).join("storage.json") + } +} + +pub(super) struct PluginApi { + pub pkg: Package, + pub shared: Arc, +} + +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 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> { + self.shared.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, 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 00000000..729bf626 --- /dev/null +++ b/crates/ide-host/src/plugins/icons.rs @@ -0,0 +1,615 @@ +//! The `iconThemes` extension point: file and folder icons. +//! +//! ```toml +//! [[contributes.iconThemes]] +//! id = "material" +//! label = "Material Icons" +//! path = "icons/theme.json" +//! ``` +//! +//! `theme.json` (paths relative to the package): +//! +//! ```json +//! { +//! "iconDefinitions": { "rust": "icons/rust.svg", "folder-src": "icons/folder-src.svg" }, +//! "file": "file", "folder": "folder", "folderExpanded": "folder-open", +//! "fileExtensions": { "rs": "rust" }, +//! "fileNames": { "cargo.toml": "cargo" }, +//! "folderNames": { "src": "folder-src" } +//! } +//! ``` +//! +//! Every SVG is sanitised here (allow-listed elements and attributes, no +//! scripts, event handlers, external references or entities) and stored as an +//! asset; the client fetches the mapping and the icons only when the theme is +//! selected, so installed icon themes cost nothing at start-up. + +use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +pub const POINT_ID: &str = "iconThemes"; +const MAX_ICONS: usize = 5000; +const MAX_SVG: usize = 64 << 10; +const MAX_MAPPINGS: usize = 20_000; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + id: String, + label: String, + path: PathBuf, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ThemeFile { + icon_definitions: BTreeMap, + #[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/") +} + +/// An SVG icon of the package, size-checked and sanitised. +pub(super) fn read_icon(pkg: &Package, path: &std::path::Path) -> Result { + check_relative(path, "icon")?; + let svg = pkg.read_text(path)?; + if svg.len() > MAX_SVG { + return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10)); + } + sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display())) +} + +pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result { + 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")?; + let clean = read_icon(pkg, path)?; + assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean)); + files.push(path.clone()); + } + let map = serde_json::json!({ + "file": theme.file, + "folder": theme.folder, + "folderExpanded": theme.folder_expanded, + "fileExtensions": file_extensions, + "fileNames": file_names, + "folderNames": folder_names, + }); + assets.push((map_asset(&d.id), map.to_string())); + let json = serde_json::json!({ "id": d.id, "label": d.label, "icons": theme.icon_definitions.len() }); + let mut out = UiDecl::new(d.id.clone(), d.label, json); + out.files = files; + out.assets = assets; + Ok(out) +} + +// --------------------------------------------------------------------------- +// SVG sanitiser + +const ELEMENTS: &[&str] = &[ + "svg", + "g", + "path", + "circle", + "ellipse", + "line", + "polyline", + "polygon", + "rect", + "defs", + "lineargradient", + "radialgradient", + "stop", + "clippath", + "mask", + "use", + "symbol", + "pattern", + "text", + "tspan", + "filter", + "fegaussianblur", + "feoffset", + "feblend", + "fecolormatrix", + "feflood", + "fecomposite", + "femerge", + "femergenode", + "fedropshadow", +]; + +/// Dropped with their content: harmless metadata. +const STRIPPED: &[&str] = &["title", "desc", "metadata"]; + +const ATTRIBUTES: &[&str] = &[ + "id", + "class", + "viewbox", + "width", + "height", + "x", + "y", + "x1", + "y1", + "x2", + "y2", + "cx", + "cy", + "r", + "rx", + "ry", + "fx", + "fy", + "d", + "points", + "transform", + "fill", + "fill-opacity", + "fill-rule", + "clip-rule", + "clip-path", + "mask", + "stroke", + "stroke-width", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-opacity", + "opacity", + "offset", + "stop-color", + "stop-opacity", + "gradientunits", + "gradienttransform", + "spreadmethod", + "patternunits", + "patterncontentunits", + "patterntransform", + "clippathunits", + "maskunits", + "maskcontentunits", + "preserveaspectratio", + "style", + "href", + "xlink:href", + "xmlns", + "xmlns:xlink", + "version", + "font-family", + "font-size", + "font-weight", + "text-anchor", + "dominant-baseline", + "letter-spacing", + "display", + "visibility", + "color", + "filter", + "in", + "in2", + "result", + "stddeviation", + "dx", + "dy", + "mode", + "operator", + "values", + "type", + "flood-color", + "flood-opacity", + "k1", + "k2", + "k3", + "k4", + "filterunits", + "primitiveunits", + "vector-effect", + "shape-rendering", + "isolation", + "mix-blend-mode", +]; + +/// Parses `src` and re-serialises the allow-listed subset. Fails on anything +/// that could execute, fetch or escape (scripts, `foreignObject`, event +/// handlers, external `href`s, `url()` to anything but a local fragment, +/// DTDs, entities, CDATA). +pub fn sanitize_svg(src: &str) -> Result { + 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 00000000..5056300a --- /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. 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 00000000..a9e045c1 --- /dev/null +++ b/crates/ide-host/src/plugins/market.rs @@ -0,0 +1,314 @@ +//! The marketplace: a static index (`index.json` + +//! packages, served from any URL or folder) lists publishers and their keys, +//! plugins, and revocations. The host keeps the last index it read +//! (`market-index.json`), checks signatures against it, and disables what it +//! revokes — at start-up and on every refresh (the desktop refreshes daily). + +use super::{PluginHost, PluginSource, PluginStatus}; +use ide_core::{IdeError, IdeResult}; +use ide_extension::Manifest; +use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// The official marketplace: the index the release workflow publishes with +/// the app, next to the signed packages of the official plugins. +pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json"; + +/// The last index read, in the plugins directory. +pub const INDEX_FILE: &str = "market-index.json"; +const DOWNLOADS: &str = ".downloads"; +const MAX_INDEX: usize = 16 << 20; +const MAX_PACKAGE: usize = 256 << 20; + +/// A marketplace entry with what is installed. +#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct MarketPlugin { + #[serde(flatten)] + pub entry: IndexEntry, + /// Installed version, if any. + pub installed: Option, +} + +/// 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`: a URL, or a local path (with the +/// platform's separators, e.g. `C:\\market\\index.json`). +pub fn resolve(base: &str, href: &str) -> String { + if href.contains("://") || href.starts_with('/') || Path::new(href).is_absolute() { + return href.to_owned(); + } + if base.contains("://") { + return match base.rfind('/') { + Some(i) => format!("{}/{href}", &base[..i]), + None => href.to_owned(), + }; + } + match Path::new(base).parent() { + Some(dir) => dir.join(href).to_string_lossy().into_owned(), + None => href.to_owned(), + } +} + +impl PluginHost { + pub(super) fn load_market_index(dir: &Path) -> Option { + let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?; + Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok() + } + + /// The marketplace index URL: the user's, else the default (none: no marketplace). + pub fn market_url(&self) -> Option { + self.state.lock().market_url.clone().or_else(|| self.default_market_url.read().clone()) + } + + /// The marketplace used while the user has set none. + pub fn set_default_market_url(&self, url: Option) { + *self.default_market_url.write() = url.filter(|u| !u.trim().is_empty()); + } + + 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`, or a `.zip` of a package) 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!( + Path::new(&resolve("/srv/market/index.json", "a.oiplugin")), + Path::new("/srv/market").join("a.oiplugin") + ); + #[cfg(windows)] + assert_eq!(resolve(r"C:\market\index.json", "a.oiplugin"), r"C:\market\a.oiplugin"); + assert_eq!(resolve("https://x.dev/index.json", "https://cdn.dev/a"), "https://cdn.dev/a"); + } +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs new file mode 100644 index 00000000..bc0beba1 --- /dev/null +++ b/crates/ide-host/src/plugins/mod.rs @@ -0,0 +1,2288 @@ +//! The plugin host: 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 mod web; + +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, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX}; +pub use points::{COMMANDS, CommandDecl, 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, 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>, +} + +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>, + /// The marketplace used while the user has set none (the app's official one). + default_market_url: 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(), plugins_dir.join(".data"), pins.clone(), ui.clone()); + let market = RwLock::new(Self::load_market_index(&plugins_dir)); + let host = Self { + market, + default_market_url: RwLock::new(None), + 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", + )); + } + // Its secrets (kept outside its directory, in the OS keychain on the desktop) go with it. + let secrets: Vec = plugin + .manifest() + .contributes + .get(providers::SECRETS_POINT) + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(|s| s.get("name")?.as_str().map(str::to_owned)) + .collect(); + self.unload(id); + { + let store = self.shared.secrets.read().clone(); + for name in &secrets { + if let Err(e) = store.set(id, name, None) { + tracing::warn!(plugin = %id, "cannot delete secret {name:?}: {e}"); + } + } + } + // 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) + }) + } + + 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 + 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(_)))); + // Cancelling or a missing grant is not the plugin failing. + let ok = match &result { + Ok(_) => true, + Err(ActorError::Call(CallError::Guest(e))) => !e.is_failure(), + Err(_) => false, + }; + self.record_call(&plugin, started.elapsed(), ok, !crashed); + } + match result { + Ok(value) => return Ok(value), + Err(ActorError::Call(CallError::Guest(e))) => return Err(e.into()), + 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 crashed since. + let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); + for p in plugins { + 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; + }, + ); + 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. + let component = + self.engine.compile_cached(&src.join(wasm), &self.component_cache()).map_err(IdeError::invalid)?; + self.engine.check_component(&component).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) { + 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(); + // The ledger before a plugin's `meta`, as everywhere (`refresh_pins` holds the + // ledger and locks each plugin's): the other order deadlocks. + let grant = self.ledger.lock().get(&p.subject()).cloned(); + let meta = p.meta.lock(); + 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 00000000..bdf23c66 --- /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"; +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)))?; + registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?; + registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?; + Ok(()) +} diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs new file mode 100644 index 00000000..d60b806a --- /dev/null +++ b/crates/ide-host/src/plugins/providers.rs @@ -0,0 +1,342 @@ +//! Editor providers of plugins: completion, inline +//! completion, code actions, go to definition and gutter line markers, for +//! the languages a plugin declares. The host calls only plugins whose +//! declaration matches, so the others stay unloaded. +//! +//! ```toml +//! [[contributes.providers]] +//! kinds = ["definition", "line-markers"] +//! languages = ["java", "xml"] # editor language ids; "*" for any +//! ``` +//! +//! And `[[contributes.secrets]]`: secrets the user can store for the plugin +//! (shown in Settings › Plugins), used only through `net` request headers. +//! +//! ```toml +//! [[contributes.secrets]] +//! name = "api-key" +//! label = "API key" +//! description = "Create one at https://example.com/keys" +//! ``` + +use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub const POINT_ID: &str = "providers"; +pub const SECRETS_POINT: &str = "secrets"; + +/// What a plugin can provide in the editor. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderKind { + Completion, + InlineCompletion, + CodeActions, + Definition, + LineMarkers, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + kinds: Vec, + 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, Pending, + ProviderDocument, Runtime, +}; +use std::sync::Weak; +use std::time::{Duration, Instant}; + +/// How long a superseded inline completion may take to return after its +/// request was cancelled before it is interrupted. +const CANCEL_GRACE: Duration = Duration::from_millis(250); + +static EDITOR_PROVIDERS: RwLock>> = 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() + } + + /// Edit-path calls (soft / hard deadline) to every usable plugin that + /// declares `kind` for `language`, in plugin order, leaving out those that + /// failed or were late. Each plugin runs on its own thread, so the calls + /// run at the same time: the wait is the slowest plugin's, not the sum. + fn edit_calls( + &self, + kind: ProviderKind, + language: &str, + f: impl FnOnce(&mut Runtime, Duration) -> Result + Clone + Send + 'static, + ) -> Vec<(Arc, R)> { + let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard); + let queued: Vec<_> = self + .provider_plugins(kind, language) + .into_iter() + .filter_map(|plugin| { + let (actor, activated) = self.ensure_active(&plugin, None).ok()?; + if activated { + self.changed(); + } + let f = f.clone(); + let started = Instant::now(); + let call = actor.submit(Some(soft), move |rt| f(rt, hard)); + Some((plugin, actor, started, call)) + }) + .collect(); + queued + .into_iter() + .filter_map(|(plugin, actor, started, call)| { + let result = call.and_then(Pending::wait); + self.finish_provider_call(&plugin, &actor, started, result).map(|r| (plugin, r)) + }) + .collect() + } + + pub(super) fn finish_provider_call( + &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, !e.is_failure(), true); + tracing::debug!(plugin = %plugin.id(), "provider: {e}"); + None + } + Err(ActorError::Call(CallError::Trap(_)) | ActorError::Stopped) => { + self.record_call(plugin, elapsed, false, false); + self.reap(plugin, actor, None); + None + } + } + } + + pub fn provide_completion( + &self, + doc: &ProviderDocument, + at: ide_core::Pos, + trigger: Option<&str>, + ) -> Vec<(String, CompletionItem)> { + let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned)); + self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| { + rt.provide_completion(&d, at, t.as_deref(), timeout) + }) + .into_iter() + .flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i))) + .collect() + } + + pub fn provide_code_actions( + &self, + doc: &ProviderDocument, + selection: ide_core::Range, + ) -> Vec<(String, CodeAction)> { + let d = Arc::new(doc.clone()); + self.edit_calls(ProviderKind::CodeActions, &doc.language, move |rt, timeout| { + rt.provide_code_actions(&d, selection, timeout) + }) + .into_iter() + .flat_map(|(p, actions)| actions.into_iter().map(move |a| (p.id().to_owned(), a))) + .collect() + } + + pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec { + let d = Arc::new(doc.clone()); + self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| { + rt.provide_definition(&d, at, timeout) + }) + .into_iter() + .flat_map(|(_, locations)| locations) + .collect() + } + + pub fn provide_line_markers(&self, doc: &ProviderDocument) -> Vec<(String, LineMarker)> { + let d = Arc::new(doc.clone()); + self.edit_calls(ProviderKind::LineMarkers, &doc.language, move |rt, timeout| { + rt.provide_line_markers(&d, timeout) + }) + .into_iter() + .flat_map(|(p, markers)| markers.into_iter().map(move |m| (p.id().to_owned(), m))) + .collect() + } + + /// Ghost text from the first plugin that has some. Not bound by the + /// edit-path soft deadline (it may need the network) but by `cancel`: + /// the editor cancels a request as soon as the user types on. + pub fn provide_inline_completion( + &self, + doc: &ProviderDocument, + at: ide_core::Pos, + cancel: &CancellationToken, + ) -> Option<(String, InlineSuggestion)> { + for p in self.provider_plugins(ProviderKind::InlineCompletion, &doc.language) { + if cancel.is_cancelled() { + return None; + } + let Ok((actor, activated)) = self.ensure_active(&p, None) else { continue }; + if activated { + self.changed(); + } + let (d, timeout) = (doc.clone(), self.limits.timeout); + let started = Instant::now(); + let result = + actor.call_cancellable(cancel, CANCEL_GRACE, move |rt| rt.provide_inline_completion(&d, at, timeout)); + if let Some(Some(s)) = self.finish_provider_call(&p, &actor, started, result) { + return Some((p.id().to_owned(), s)); + } + } + None + } +} diff --git a/crates/ide-host/src/plugins/remote.rs b/crates/ide-host/src/plugins/remote.rs new file mode 100644 index 00000000..e3666cf4 --- /dev/null +++ b/crates/ide-host/src/plugins/remote.rs @@ -0,0 +1,222 @@ +//! Plugin languages of a remote project: `Workspace` contributions take effect where the project is, so the +//! desktop pushes its plugins' language definitions to `openidea-server` +//! (`plugin_sync_languages`) together with the `process:` programs the user +//! granted. The server resolves each granted program on its own `PATH`, pins +//! the file (path + hash, kept in `plugin-pins.json`) the first time and +//! starts only that file afterwards; a program missing from a sync is revoked. +//! Revoking and granting again on the desktop re-pins a replaced program. + +use ide_extension::Capability; +use ide_extension::ledger::{self, BinaryGrant}; +use ide_language::plugin_point::{PluginLanguages, validate_definition}; +use ide_language::{LanguageConfig, ProgramPin}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +pub const PINS_FILE: &str = "plugin-pins.json"; +/// The server command the desktop calls. +pub const SYNC_COMMAND: &str = "plugin_sync_languages"; + +/// One plugin language as the desktop sends it. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteLanguage { + pub plugin: String, + pub language: LanguageConfig, + /// Program names of the plugin's granted `process:` capabilities. + #[serde(default)] + pub granted: Vec, +} + +/// 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 00000000..71be9116 --- /dev/null +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -0,0 +1,504 @@ +//! Code plugins of remote projects: +//! Wasm runs where the project is, so `openidea-server` hosts the desktop's +//! code plugins and they read, search, run processes and publish diagnostics +//! on the remote machine. +//! +//! The desktop stays the authority over what is installed and granted. It +//! pushes its live code plugins with [`SYNC_PACKAGES`]: first their digests +//! and grants, then the files of those the server doesn't have (one package +//! per request); a package missing from a sync is uninstalled. The server +//! installs them under `/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")?, + c.arg("editor")?, + ) + .await + } + }); + let h = host.clone(); + d.add("plugin_view_event", move |c| { + let h = h.clone(); + async move { + let plugins = alive(&h)?; + super::views::event( + &c.host, + plugins, + &c.window, + c.arg("pluginId")?, + c.arg("view")?, + c.arg("event")?, + c.arg("doc")?, + c.arg("editor")?, + ) + .await + } + }); + d.add("plugin_line_markers", |c| async move { + crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await + }); + } +} + +fn alive(host: &Weak) -> 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 00000000..c48b001e --- /dev/null +++ b/crates/ide-host/src/plugins/run_detectors.rs @@ -0,0 +1,167 @@ +//! The `runDetectors` extension point: run configurations a plugin offers in +//! projects that contain a marker file. +//! +//! ```toml +//! [[contributes.runDetectors]] +//! id = "forge-test" +//! name = "forge test" +//! files = ["foundry.toml"] # any of these at the project root +//! command = "forge" +//! args = ["test"] +//! test = true # results go to the test tree +//! ``` +//! +//! The command is a `process:` capability like a language server's: the +//! configuration is offered only while it is granted, and starts the approved +//! file (checked right before it starts). + +use ide_extension::{ + Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, check_relative, decode, validate_item_id, +}; +use ide_language::ProgramPin; +use ide_run::{EXTERNAL_PREFIX, ExternalDetection, RunConfig}; +use parking_lot::RwLock; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub const POINT_ID: &str = "runDetectors"; +const MAX_FILES: usize = 16; +const MAX_ARGS: usize = 64; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Detector { + pub id: String, + pub name: String, + pub files: Vec, + 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 76% rename from crates/ide-plugin/src/theme.rs rename to crates/ide-host/src/plugins/theme.rs index 0d4e65e7..e4f38ff5 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,55 @@ 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: PathBuf, + #[serde(default)] + editor_scheme: 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 path = &decl.path; + let mut theme = ThemeDef::parse(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?; + let mut files = vec![path.clone()]; + if let Some(scheme) = &decl.editor_scheme { + files.push(scheme.clone()); + theme.merge_editor_scheme(&pkg.read_text(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?; + } + let item = UiContribution { + plugin_id: pkg.id().to_owned(), + point: POINT_ID.into(), + key: theme.id.clone(), + decl: serde_json::to_value(&theme).map_err(|e| e.to_string())?, + }; + let store = self.store.clone(); + let mut prepared = Prepared::new(theme.name.clone(), move || Ok(store.insert(item))).key(theme.id); + prepared.files = files; + Ok(prepared) + } +} + /// Blanks out `//` and `/* */` comments outside strings, keeping newlines so /// parse errors still point at the right line. fn strip_json_comments(src: &str) -> String { diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs new file mode 100644 index 00000000..c9cac430 --- /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 00000000..3b5e3d14 --- /dev/null +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -0,0 +1,544 @@ +//! Declarative UI contributions: menus, key +//! bindings, status bar items and settings (L0, shown before any code runs), +//! and the views and custom editors whose content plugin code renders (L2). +//! Everything is validated here; the client only projects the JSON. +//! +//! ```toml +//! [[contributes.menus]] +//! menu = "editor.context" # projectView.context, editorTab.context, tools +//! command = "check" +//! when = "editorLang == java" +//! +//! [[contributes.keymaps]] +//! command = "check" +//! key = "Ctrl+Alt+C" # `mac` for a different binding on macOS (`Meta+Alt+C`) +//! +//! [[contributes.statusBar]] +//! id = "branch" +//! text = "…" # code replaces it with `set-status-item("branch", …)` +//! command = "show" +//! +//! [contributes.settings] +//! schema = "settings.schema.json" # {"properties": {"key": {"type": "boolean", "default": true, …}}} +//! +//! [[contributes.views]] +//! id = "jobs" +//! title = "Jobs" +//! icon = "server" +//! location = "right" # left, right, bottom +//! +//! [[contributes.customEditors]] +//! id = "table" +//! title = "Table" +//! patterns = ["*.csv", "*.tsv"] +//! priority = "default" # or "option": only through Open With +//! layout = "tab" # or "split" / "preview": beside the text editor +//! web = "web/index.html" # a web page from the package instead of rendered content +//! +//! [[contributes.fileIcons]] +//! id = "drawing" +//! patterns = ["*.excalidraw"] +//! icon = "icons/drawing.svg" +//! iconDark = "icons/drawing_dark.svg" +//! +//! [[contributes.fileTemplates]] +//! id = "drawing" +//! name = "Excalidraw Drawing" +//! extension = "excalidraw" +//! path = "templates/drawing.excalidraw" +//! ``` + +use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::path::PathBuf; + +pub const MENUS: &str = "menus"; +pub const KEYMAPS: &str = "keymaps"; +pub const STATUS_BAR: &str = "statusBar"; +pub const SETTINGS: &str = "settings"; +pub const VIEWS: &str = "views"; +pub const CUSTOM_EDITORS: &str = "customEditors"; +pub const FILE_ICONS: &str = "fileIcons"; +pub const FILE_TEMPLATES: &str = "fileTemplates"; + +const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"]; +const MAX_TITLE: usize = 256; +const MAX_SETTINGS: usize = 200; +const MAX_PATTERNS: usize = 16; + +/// Command ids the package declares. +fn commands(pkg: &Package) -> BTreeSet { + 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, + /// A web page of the package (`web/index.html`) instead of content the code renders. + #[serde(default, skip_serializing_if = "Option::is_none")] + web: Option, +} + +/// The files of a `web` entry, or a check that the code renders the view. +fn web_or_code(pkg: &Package, web: Option<&str>, what: &str) -> Result, String> { + match web { + Some(entry) => super::web::web_files(pkg, entry, what), + None if pkg.manifest.wasm.is_none() => { + Err(format!("{what}: give a web page (`web`) or the code that renders it (`wasm`)")) + } + None => Ok(Vec::new()), + } +} + +pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { + let d: ViewDecl = decode(VIEWS, decl)?; + validate_item_id(&d.id, "view id")?; + title(VIEWS, &d.title)?; + let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?; + if !["left", "right", "bottom"].contains(&d.location.as_str()) { + return Err(format!("view {:?}: `location` is left, right or bottom", d.id)); + } + if d.icon.as_ref().is_some_and(|i| i.len() > 64) { + return Err(format!("view {:?}: icon names are up to 64 bytes", d.id)); + } + let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?); + out.files = files; + Ok(out) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct CustomEditorDecl { + id: String, + title: String, + patterns: Vec, + #[serde(default = "default_priority")] + priority: String, + /// `tab`: the file opens in this editor's own tab. `split` / `preview`: + /// the file opens in the text editor with this editor beside it (Editor / + /// Editor and Preview / Preview); the value is the layout files start in. + #[serde(default = "default_layout")] + layout: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + web: Option, +} + +fn default_priority() -> String { + "default".into() +} + +fn default_layout() -> String { + "tab".into() +} + +/// File name globs (`*.csv`, `{a,b}.json`): no directories. +fn file_patterns(patterns: &[String], what: &str) -> Result<(), String> { + if patterns.is_empty() || patterns.len() > MAX_PATTERNS { + return Err(format!("{what}: 1–{MAX_PATTERNS} file name `patterns`")); + } + for p in patterns { + if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() { + return Err(format!("{what}: invalid file name pattern {p:?}")); + } + } + Ok(()) +} + +pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result { + let d: CustomEditorDecl = decode(CUSTOM_EDITORS, decl)?; + validate_item_id(&d.id, "custom editor id")?; + title(CUSTOM_EDITORS, &d.title)?; + let what = format!("custom editor {:?}", d.id); + let files = web_or_code(pkg, d.web.as_deref(), &what)?; + file_patterns(&d.patterns, &what)?; + if d.priority != "default" && d.priority != "option" { + return Err(format!("{what}: `priority` is default or option")); + } + if !["tab", "split", "preview"].contains(&d.layout.as_str()) { + return Err(format!("{what}: `layout` is tab, split or preview")); + } + let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?); + out.files = files; + Ok(out) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FileIconDecl { + id: String, + patterns: Vec, + icon: PathBuf, + #[serde(default)] + icon_dark: Option, +} + +/// Asset id of a file icon's SVG (`dark`: its dark-theme variant). +pub fn file_icon_asset(id: &str, dark: bool) -> String { + format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" }) +} + +/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`, +/// whatever icon theme is in use (the user's own file type icons still win). +pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result { + let d: FileIconDecl = decode(FILE_ICONS, decl)?; + validate_item_id(&d.id, "file icon id")?; + let what = format!("file icon {:?}", d.id); + file_patterns(&d.patterns, &what)?; + let mut files = Vec::new(); + let mut assets = Vec::new(); + for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] { + let Some(path) = path else { continue }; + let svg = super::icons::read_icon(pkg, path)?; + assets.push((file_icon_asset(&d.id, dark), svg)); + files.push(path.clone()); + } + let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() }); + let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json); + out.files = files; + out.assets = assets; + Ok(out) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FileTemplateDecl { + id: String, + name: String, + /// Without the dot. + extension: String, + /// The new file's content (`$NAME$`, `$END$` and the other template variables work). + path: PathBuf, +} + +const MAX_TEMPLATE: usize = 256 << 10; + +/// `[[contributes.fileTemplates]]`: an entry of the New menu. +pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result { + let d: FileTemplateDecl = decode(FILE_TEMPLATES, decl)?; + validate_item_id(&d.id, "file template id")?; + title(FILE_TEMPLATES, &d.name)?; + let what = format!("file template {:?}", d.id); + let ext = d.extension.trim_start_matches('.'); + if ext.is_empty() + || ext.len() > 32 + || !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + { + return Err(format!("{what}: `extension` is a file extension like \"md\" ({:?})", d.extension)); + } + let body = pkg.read_text(&d.path)?; + if body.len() > MAX_TEMPLATE { + return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10)); + } + let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body }); + let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json); + out.files = vec![d.path.clone()]; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bindings_need_a_modifier() { + for ok in ["Ctrl+Alt+C", "Meta+Shift+K", "F5", "Shift+F6", "Meta+K Meta+C", "Alt+Enter"] { + assert!(validate_binding(ok).is_ok(), "{ok}"); + } + for bad in ["C", "Shift+C", "Hyper+C", "Ctrl+Ctrl+C", "Ctrl+é", "Ctrl+A B C", ""] { + assert!(validate_binding(bad).is_err(), "{bad}"); + } + } + + #[test] + fn settings_schemas_are_typed() { + let s = parse_settings_schema( + r#"{"properties": { + "context": {"type": "string", "title": "Context", "default": ""}, + "watch": {"type": "boolean", "default": true}, + "mode": {"type": "string", "enum": ["a", "b"], "default": "b"}, + "limit": {"type": "integer", "default": 100} + }}"#, + ) + .unwrap(); + assert_eq!(s.len(), 4); + assert!(s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("a"))); + assert!(!s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("c"))); + assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err()); + assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err()); + } +} diff --git a/crates/ide-host/src/plugins/views.rs b/crates/ide-host/src/plugins/views.rs new file mode 100644 index 00000000..a62484ea --- /dev/null +++ b/crates/ide-host/src/plugins/views.rs @@ -0,0 +1,92 @@ +//! Declarative views and custom editors as the +//! window commands `plugin_view_render` / `plugin_view_event` serve them — +//! the desktop for local projects, `openidea-server` for remote ones. + +use super::{PluginEditor, PluginHost}; +use ide_core::{DocId, IdeError, IdeResult}; +use ide_plugin::{EditorContext, Invocation, View, ViewEvent, Viewport}; +use std::sync::Arc; + +/// The window's project, plus the editor the call is about: the document a +/// custom editor shows, else (a tool window) the window's active editor, as +/// a command gets it, so a view's buttons can act on what the user is editing. +pub fn view_invocation( + host: &crate::Host, + window: &str, + doc: Option, + editor: Option, +) -> IdeResult { + let project = host.project(window).ok(); + let editor = match doc { + None => editor.map(PluginEditor::context), + Some(doc) => { + let project = project.as_ref().ok_or_else(|| IdeError::not_found("no project is open in this window"))?; + let info = project.documents.snapshot(doc)?; + Some(EditorContext { path: info.path.0, language: info.language.0, selections: Vec::new() }) + } + }; + Ok(Invocation { client: Some(window.to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor }) +} + +async fn blocking(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, + editor: Option, +) -> IdeResult { + let invocation = view_invocation(host, window, doc, editor)?; + blocking(move || plugins.render_view(&plugin_id, &view, viewport, invocation)).await +} + +#[allow(clippy::too_many_arguments)] +pub async fn event( + host: &crate::Host, + plugins: Arc, + window: &str, + plugin_id: String, + view: String, + event: ViewEvent, + doc: Option, + editor: Option, +) -> IdeResult<()> { + let invocation = view_invocation(host, window, doc, editor)?; + blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Host, HostConfig}; + + #[tokio::test] + async fn a_tool_window_call_carries_the_active_editor() { + let tmp = tempfile::tempdir().unwrap(); + let host = Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let range = ide_core::Range::new(ide_core::Pos::new(1, 0), ide_core::Pos::new(1, 4)); + let active = PluginEditor { path: "/p/a.rs".into(), language: "rust".into(), selections: vec![range] }; + let inv = view_invocation(&host, "main", None, Some(active)).unwrap(); + let editor = inv.editor.expect("the active editor"); + assert_eq!((editor.path.to_string_lossy().as_ref(), editor.selections.as_slice()), ("/p/a.rs", &[range][..])); + assert_eq!(inv.client.as_deref(), Some("main")); + assert!(view_invocation(&host, "main", None, None).unwrap().editor.is_none()); + } +} diff --git a/crates/ide-host/src/plugins/web.rs b/crates/ide-host/src/plugins/web.rs new file mode 100644 index 00000000..11a109f8 --- /dev/null +++ b/crates/ide-host/src/plugins/web.rs @@ -0,0 +1,245 @@ +//! Web views: a view or custom editor whose UI is a web page shipped in the +//! package (`web = "web/index.html"`) instead of content the plugin's code +//! renders. +//! +//! The entry's directory is the page's root. Every file under it is part of +//! the declaration: it is checked when the package is prepared (count, size, +//! no links leaving the package), copied on install and covered by the +//! package digest and signature. The client loads the page in a sandboxed +//! frame (an opaque origin with no network and no IDE API) and fetches its +//! files through [`PluginHost::web_file`] (the desktop's `openidea-plugin` +//! URL scheme); the page talks to the IDE only through the frame bridge. + +use super::PluginHost; +use ide_core::{IdeError, IdeResult}; +use ide_extension::{Package, check_relative}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Component, Path, PathBuf}; + +/// Most files under one web root. +const MAX_FILES: usize = 4096; +/// Most bytes under one web root. +const MAX_BYTES: u64 = 256 << 20; +/// Deepest directory nesting under a web root. +const MAX_DEPTH: usize = 16; + +/// Checks a declaration's `web` entry: an `.html` file inside a directory of +/// the package (the directory is what the page can load, so it can't be the +/// package root), and everything under that directory. Returns those files, +/// package-relative. +pub(super) fn web_files(pkg: &Package, entry: &str, what: &str) -> Result, String> { + let path = Path::new(entry); + check_relative(path, "web")?; + if entry.contains('\\') || !entry.to_ascii_lowercase().ends_with(".html") { + return Err(format!("{what}: `web` names an .html file, `/`-separated ({entry:?})")); + } + let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| { + format!("{what}: put the page in a directory of its own (`web/index.html`): the directory is what it can load") + })?; + let real_entry = pkg.resolve(path)?; + if !real_entry.is_file() { + return Err(format!("{what}: {entry} is not a file")); + } + let root = pkg.resolve(dir)?; + let mut files = Vec::new(); + let mut total = 0u64; + let mut stack = vec![(root.clone(), dir.to_path_buf(), 0usize)]; + while let Some((real, rel, depth)) = stack.pop() { + let mut entries: Vec<_> = + std::fs::read_dir(&real).map_err(|e| format!("{}: {e}", rel.display()))?.filter_map(Result::ok).collect(); + entries.sort_by_key(|e| e.file_name()); + for e in entries { + let name = e.file_name(); + let rel = rel.join(&name); + // Links could point anywhere: only what is really in the package counts. + let ty = e.file_type().map_err(|err| format!("{}: {err}", rel.display()))?; + if ty.is_symlink() { + return Err(format!("{what}: {} is a link; web files must be real files", rel.display())); + } + if ty.is_dir() { + if depth + 1 > MAX_DEPTH { + return Err(format!("{what}: {} is nested deeper than {MAX_DEPTH} directories", rel.display())); + } + stack.push((e.path(), rel, depth + 1)); + continue; + } + if name.to_string_lossy() == ".DS_Store" { + continue; + } + let len = e.metadata().map_err(|err| format!("{}: {err}", rel.display()))?.len(); + if len > ide_extension::MAX_PACKAGE_FILE { + return Err(format!( + "{what}: {} is larger than {} MiB", + rel.display(), + ide_extension::MAX_PACKAGE_FILE >> 20 + )); + } + total += len; + files.push(rel); + if files.len() > MAX_FILES || total > MAX_BYTES { + return Err(format!( + "{what}: {} holds more than {MAX_FILES} files or {} MiB", + dir.display(), + MAX_BYTES >> 20 + )); + } + } + } + files.sort(); + Ok(files) +} + +/// The page of a web view, for the client to load in its frame. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct WebPage { + /// `//` under the `openidea-plugin` scheme. The + /// revision changes with the installed package, so a cached file is never + /// stale; relative URLs in the page resolve to the package's files. + pub path: String, +} + +/// A file of a web view. +pub struct WebFile { + pub bytes: Vec, + pub mime: &'static str, +} + +impl PluginHost { + /// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated). + fn web_dirs(&self, plugin: &str) -> Vec { + [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS] + .iter() + .flat_map(|point| self.ui.items(point, Some(plugin))) + .filter_map(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned)) + .filter_map(|entry| entry.rsplit_once('/').map(|(dir, _)| format!("{dir}/"))) + .collect() + } + + /// Changes whenever the installed package does (cache key of its web files). + fn web_revision(&self, plugin: &super::LoadedPlugin) -> String { + let digest = plugin.pkg.digest.as_deref().unwrap_or_default(); + let short: String = digest.chars().filter(char::is_ascii_alphanumeric).take(16).collect(); + if short.is_empty() { format!("v{}", plugin.pkg.manifest.version) } else { short } + } + + /// The page of `plugin`'s view or custom editor `view`. + pub fn web_page(&self, plugin_id: &str, view: &str) -> IdeResult { + let plugin = self.get(plugin_id)?; + if !plugin.status().is_live() { + return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled"))); + } + let entry = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS] + .iter() + .flat_map(|point| self.ui.items(point, Some(plugin_id))) + .find(|item| item.key == view) + .and_then(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned)) + .ok_or_else(|| IdeError::not_found(format!("plugin {plugin_id:?} has no web view {view:?}")))?; + Ok(WebPage { path: format!("{plugin_id}/{}/{entry}", self.web_revision(&plugin)) }) + } + + /// A file of one of `plugin`'s web views: `path` is `/` + /// as [`WebPage::path`] makes them. Only files under a declared web root + /// of a live plugin are served; the revision must be the installed one. + pub fn web_file(&self, plugin_id: &str, path: &str) -> IdeResult { + let plugin = self.get(plugin_id)?; + if !plugin.status().is_live() { + return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled"))); + } + let (revision, rel) = path.split_once('/').ok_or_else(|| IdeError::not_found(path.to_owned()))?; + if revision != self.web_revision(&plugin) { + return Err(IdeError::not_found(format!("{path}: not the installed version"))); + } + let rel_path = Path::new(rel); + if !rel_path.components().all(|c| matches!(c, Component::Normal(_))) { + return Err(IdeError::invalid(format!("{rel}: not a plain relative path"))); + } + if !self.web_dirs(plugin_id).iter().any(|dir| rel.starts_with(dir.as_str())) { + return Err(IdeError::not_found(format!("{rel}: not part of a web view"))); + } + let bytes = plugin.pkg.read_bytes(rel_path).map_err(IdeError::not_found)?; + Ok(WebFile { bytes, mime: mime(rel) }) + } +} + +/// Content type by extension; unknown files are opaque bytes (never sniffed as script). +pub fn mime(path: &str) -> &'static str { + let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default(); + match ext.as_str() { + "html" | "htm" => "text/html; charset=utf-8", + "js" | "mjs" | "cjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" | "map" => "application/json", + "wasm" => "application/wasm", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "avif" => "image/avif", + "ico" => "image/x-icon", + "woff2" => "font/woff2", + "woff" => "font/woff", + "ttf" => "font/ttf", + "otf" => "font/otf", + "txt" | "md" => "text/plain; charset=utf-8", + "mp3" => "audio/mpeg", + "mp4" => "video/mp4", + "webm" => "video/webm", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_extension::Manifest; + + fn pkg(dir: &Path) -> Package { + let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n"; + Package::new(Manifest::parse(src).unwrap(), dir.to_path_buf()) + } + + #[test] + fn a_web_root_is_its_directory_and_nothing_else() { + let tmp = tempfile::tempdir().unwrap(); + let d = tmp.path(); + std::fs::create_dir_all(d.join("web/assets/fonts")).unwrap(); + std::fs::write(d.join("web/index.html"), "

hi

").unwrap(); + std::fs::write(d.join("web/assets/app.js"), "1").unwrap(); + std::fs::write(d.join("web/assets/fonts/a.woff2"), "f").unwrap(); + std::fs::write(d.join("web/.DS_Store"), "x").unwrap(); + std::fs::write(d.join("secret.txt"), "no").unwrap(); + let files = web_files(&pkg(d), "web/index.html", "v").unwrap(); + let files: Vec = files.iter().map(|f| f.to_string_lossy().replace('\\', "/")).collect(); + assert_eq!(files, ["web/assets/app.js", "web/assets/fonts/a.woff2", "web/index.html"]); + + std::fs::write(d.join("index.html"), "x").unwrap(); + let err = |entry: &str| web_files(&pkg(d), entry, "v").err().unwrap_or_default(); + assert!(err("index.html").contains("directory of its own"), "{}", err("index.html")); + assert!(err("web/app.js").contains(".html")); + assert!(err("../web/index.html").contains("relative")); + assert!(err("web/missing.html").contains("no such file")); + } + + #[cfg(unix)] + #[test] + fn links_out_of_a_web_root_are_refused() { + let tmp = tempfile::tempdir().unwrap(); + let d = tmp.path(); + std::fs::create_dir_all(d.join("web")).unwrap(); + std::fs::write(d.join("web/index.html"), "x").unwrap(); + std::os::unix::fs::symlink("/etc/hosts", d.join("web/hosts")).unwrap(); + assert!(web_files(&pkg(d), "web/index.html", "v").unwrap_err().contains("is a link")); + } + + #[test] + fn content_types_never_make_unknown_files_scripts() { + assert_eq!(mime("a/b.JS"), "text/javascript; charset=utf-8"); + assert_eq!(mime("x.woff2"), "font/woff2"); + assert_eq!(mime("x.wasm"), "application/wasm"); + assert_eq!(mime("x.bin"), "application/octet-stream"); + assert_eq!(mime("noext"), "application/octet-stream"); + } +} diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 2ab5bd7f..38795f7d 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 d0e0731d..ea9b9604 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 00000000..540073a5 --- /dev/null +++ b/crates/ide-host/tests/distribution.rs @@ -0,0 +1,262 @@ +//! Versioned installs with automatic rollback, signed packages from +//! a static marketplace index, and revocation. + +use ide_extension::package::{self, Index, IndexEntry, KeyEntry, Publisher, PublisherKey, Revocation}; +use ide_host::plugins::*; +use ide_plugin::{Invocation, Limits}; +use parking_lot::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +type Events = Arc>>; + +fn repo() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap().flatten() { + let (src, dst) = (entry.path(), to.join(entry.file_name())); + if entry.file_name() == "target" { + continue; + } + if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() } + } +} + +/// A copy of `from` (under `dir/name`) whose manifest is rewritten by `f`. +fn variant(from: &Path, dir: &Path, name: &str, f: impl Fn(&str) -> String) -> PathBuf { + let pkg = dir.join(name); + copy_dir(from, &pkg); + let manifest = pkg.join("plugin.toml"); + std::fs::write(&manifest, f(&std::fs::read_to_string(&manifest).unwrap())).unwrap(); + pkg +} + +struct Fixture { + host: PluginHost, + plugins_dir: PathBuf, + events: Events, + _tmp: tempfile::TempDir, + work: PathBuf, +} + +fn fixture() -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + let events: Events = Arc::default(); + let sink = { + let events = events.clone(); + move |_: Option<&str>, e: PluginEvent| events.lock().push(e) + }; + let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), Limits::default()).unwrap(); + host.load_all(); + let work = tmp.path().join("work"); + std::fs::create_dir_all(&work).unwrap(); + Fixture { host, plugins_dir, events, work, _tmp: tmp } +} + +fn inv() -> Invocation { + Invocation { client: Some("main".into()), workspace_root: None, editor: None } +} + +fn misbehave() -> PathBuf { + repo().join("crates/ide-plugin/tests/fixtures/misbehave") +} + +/// P8 acceptance: an update that fails to start rolls back to the version it replaced. +#[test] +fn a_failing_update_rolls_back_automatically() { + let f = fixture(); + let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\"")); + f.host.install_from_dir(&v1, &Consent::all()).unwrap(); + assert_eq!(f.host.run_command("misbehave", "echo", Some("hi"), inv()).unwrap().as_deref(), Some("hi")); + + let broken = + variant(&misbehave(), &f.work, "v2", |t| t.replace("version = \"0.0.2\"", "version = \"2.0.0-broken\"")); + let info = f.host.install_from_dir(&broken, &Consent::all()).unwrap(); + assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("2.0.0-broken", Some("1.0.0"))); + let root = f.plugins_dir.join("misbehave"); + assert!(root.join("2.0.0-broken").is_dir() && root.join("1.0.0").is_dir(), "versions side by side"); + + // Its first start fails: back to 1.0.0, without the user doing anything. + f.events.lock().clear(); + let err = f.host.run_command("misbehave", "echo", Some("x"), inv()).unwrap_err(); + assert!(err.message.contains("version 1.0.0 was restored"), "{}", err.message); + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.version.as_str(), info.status), ("1.0.0", PluginStatus::Inactive)); + assert!(!root.join("2.0.0-broken").exists(), "the failed version is removed"); + assert!( + f.events + .lock() + .iter() + .any(|e| matches!(e, PluginEvent::Notification { title, .. } if title.contains("rolled back"))) + ); + assert_eq!(f.host.run_command("misbehave", "echo", Some("ok"), inv()).unwrap().as_deref(), Some("ok")); + + // A working update is confirmed by its first start; the user can still roll it back. + let v3 = variant(&misbehave(), &f.work, "v3", |t| t.replace("version = \"0.0.2\"", "version = \"3.0.0\"")); + f.host.install_from_dir(&v3, &Consent::all()).unwrap(); + f.host.run_command("misbehave", "echo", None, inv()).unwrap(); + assert_eq!(f.host.info_of("misbehave").unwrap().version, "3.0.0"); + let info = f.host.roll_back_update("misbehave").unwrap(); + assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", None)); + assert!(f.host.roll_back_update("misbehave").is_err(), "nothing older to go back to"); + + // A restart loads the active version. + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.info_of("misbehave").unwrap().version, "1.0.0"); + f.host.uninstall("misbehave").unwrap(); + assert!(!root.exists(), "every version goes"); +} + +#[test] +fn installs_from_before_versioning_keep_working_and_move_on_update() { + let f = fixture(); + // The flat layout of earlier releases. + copy_dir(&misbehave(), &f.plugins_dir.join("misbehave")); + let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + again.load_all(); + assert_eq!(again.info_of("misbehave").unwrap().version, "0.0.2"); + let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\"")); + let info = again.install_from_dir(&v1, &Consent::all()).unwrap(); + assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", Some("0.0.2"))); + assert!(f.plugins_dir.join("misbehave/0.0.2/plugin.toml").is_file()); + assert!(f.plugins_dir.join("misbehave/1.0.0/plugin.toml").is_file()); +} + +// ---- marketplace ------------------------------------------------------------------ + +/// `hello` as `acme.hello` at `version`, packed and signed with `key`. +fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost) -> (PathBuf, IndexEntry) { + let src = variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{version}"), |t| { + t.replace("id = \"hello\"", "id = \"acme.hello\"") + .replace("version = \"0.3.0\"", &format!("version = \"{version}\"")) + }); + let files = host.package_files_of(&src).unwrap(); + let digest = package::package_digest(&src, &files).unwrap(); + let sig = key.sign("acme", "acme.hello", version, &digest); + let out = dir.join(format!("acme.hello-{version}.oiplugin")); + package::pack(&src, &files, &out, Some(&sig)).unwrap(); + let entry = IndexEntry { + id: "acme.hello".into(), + name: "Hello".into(), + version: version.into(), + description: String::new(), + authors: vec![], + publisher: "acme".into(), + download: out.file_name().unwrap().to_string_lossy().into_owned(), + digest, + capabilities: vec![], + }; + (out, entry) +} + +fn write_index( + dir: &Path, + key: &PublisherKey, + plugins: Vec, + 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 00000000..35139a57 --- /dev/null +++ b/crates/ide-host/tests/plugins.rs @@ -0,0 +1,2330 @@ +//! End-to-end tests of the plugin host against real components. +//! +//! The components are checked in (`dist/plugin.wasm` of the official plugins +//! and of the fixtures in `crates/ide-plugin/tests/fixtures/`: `hello`, `jobs`, +//! `misbehave`) so tests need no wasm toolchain. Rebuild them with +//! `plugins/build.sh ` after changing their source or `wit/`. + +use ide_core::error::ErrorKind; +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() +} + +/// A test fixture plugin (`crates/ide-plugin/tests/fixtures/`). +fn sample(name: &str) -> PathBuf { + repo().join("crates/ide-plugin/tests/fixtures").join(name) +} + +fn theme_template() -> PathBuf { + repo().join("plugins/official/harbor-night") +} + +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 { + // A Windows checkout may have CRLF line ends. + text.replace("\r\n", "\n") + .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")); + let inv = Invocation { editor: Some(editor.clone()), ..f.inv() }; + let err = f.host.run_command("hello", "nope", None, inv).unwrap_err(); + assert_eq!(err.kind, ErrorKind::NotFound, "{err:?}"); + assert_eq!(f.status("hello"), PluginStatus::Active); + let stats = f.host.info_of("hello").unwrap().stats; + assert_eq!(stats.activations, 1); + // "no file is open" is the plugin failing; an unknown command is not. + assert!(stats.calls >= 4 && stats.errors == 1, "{stats:?}"); +} + +/// 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); +} + +/// 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 inv = Invocation { editor: Some(editor.clone()), ..f.inv() }; + let err = f.host.run_command("misbehave", "insert", None, inv).unwrap_err(); + assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}"); + assert!(err.message.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!(info.stats.errors, 0, "a missing grant is not the plugin failing"); + let err = f.host.run_command("misbehave", "cancel", None, f.inv()).unwrap_err(); + assert_eq!(err.kind, ErrorKind::Cancelled, "{err:?}"); + assert_eq!(f.host.info_of("misbehave").unwrap().stats.errors, 0, "nor is the user cancelling"); + assert_eq!(f.workspace.text("src/notes.txt"), "one two three\nfour five\n"); +} + +/// 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); +} + +/// 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 +} + +/// 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 (however late this thread gets + // to look), but the instance lives on. + assert!(provider.highlights(&mis, "sleep:150").is_none()); + 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); +} + +/// Editor providers of several plugins are asked at the same time: the wait +/// is the slowest plugin's, not the sum, so each has the whole budget. +#[test] +fn providers_of_several_plugins_run_concurrently() { + let tmp = tempfile::tempdir().unwrap(); + let limits = Limits { + edit_soft: Duration::from_millis(300), + edit_hard: Duration::from_secs(2), + fuel: u64::MAX, + ..Limits::default() + }; + let host = + Arc::new(PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()); + host.load_all(); + host.startup_finished(); + for id in ["slow-a", "slow-b"] { + let pkg = tmp.path().join(id); + copy_dir(&misbehave_pkg(), &pkg); + std::fs::write( + pkg.join("plugin.toml"), + format!( + "id = \"{id}\"\nname = \"{id}\"\nversion = \"0.0.1\"\nengines.openidea = \"^1.0\"\n\ + wasm = \"dist/plugin.wasm\"\nactivation = [\"on-language:text\"]\n\ + [[contributes.providers]]\nkinds = [\"completion\"]\nlanguages = [\"text\"]\n" + ), + ) + .unwrap(); + host.install_from_dir(&pkg, &Consent::all()).unwrap(); + } + let doc = |text: &str| ide_plugin::ProviderDocument { + path: "/p/a.txt".into(), + language: "text".into(), + version: 1, + text: text.into(), + }; + let at = ide_core::Pos::new(0, 0); + // Activation is not what is measured. + assert_eq!(host.provide_completion(&doc("fast"), at, None).len(), 2); + + let started = Instant::now(); + let items = host.provide_completion(&doc("sleep:200"), at, None); + let elapsed = started.elapsed(); + let mut from: Vec<&str> = items.iter().map(|(p, _)| p.as_str()).collect(); + from.sort(); + assert_eq!(from, ["slow-a", "slow-b"], "both within the budget"); + assert!(elapsed < Duration::from_millis(350), "one after the other would take 400ms: {elapsed:?}"); + assert_eq!(host.info_of("slow-b").unwrap().stats.slow_calls, 0); +} + +/// 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(), 8, "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 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("openidea.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(("openidea.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("openidea.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("openidea.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("openidea.harbor-night").join("themes/harbor-night.scheme.json").is_file()); + let active = f.host.set_theme(Some(("openidea.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(("openidea.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); +} + +/// 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()); +} + +/// 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); +} + +/// 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")); +} + +/// 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. The host that loaded it goes + // first: its component maps the file (which Windows won't let anyone replace). + drop(again); + for e in std::fs::read_dir(&cache).unwrap().flatten() { + std::fs::write(e.path(), b"junk").unwrap(); + } + let third = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + third.load_all(); + assert!( + third.run_command("hello", "word-count", None, Invocation::default()).is_err(), + "no active file, but it runs" + ); + assert_eq!(third.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" + ); +} + +/// 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)"); + // Guards against work that grows faster than the icon count, not a + // benchmark: a debug build takes ~1.6 s / ~0.35 s on a laptop; shared CI + // runners running the suite in parallel take several times that (Windows + // ~11 s to install). + assert!(install < Duration::from_secs(30), "install took {install:?}"); + assert!(reload < Duration::from_secs(10), "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("crates/ide-plugin/tests/fixtures"), + repo().join("plugins/official"), + tmp.path().to_path_buf(), + ]); + let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect(); + for id in ["hello", "openidea.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 == "openidea.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) +} + +/// 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"); +} + +/// 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)); +} + +/// 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) +} + +/// 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:?}"); +} + +/// 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); +} + +/// License Header: reported when a file opens, fixed through the provider, +/// and a project scan in a task reports the rest. +#[test] +fn official_license_header_reports_fixes_and_scans() { + let f = fixture(Limits::default()); + let info = f.install(&official("license-header")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let a = f.project.join("src/a.rs"); + let b = f.project.join("src/b.py"); + std::fs::write(&a, "fn a() {}\n").unwrap(); + std::fs::write(&b, "#!/usr/bin/env python3\nprint()\n").unwrap(); + let diagnostics = |path: &Path| { + f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned() + }; + + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(20))); + let d = diagnostics(&a).expect("published on open"); + assert_eq!((d.len(), d[0].code.as_deref()), (1, Some("missing-license-header"))); + assert!(diagnostics(&b).is_none(), "only the opened file"); + + let doc = ide_plugin::ProviderDocument { + path: b.clone(), + language: "python".into(), + version: 1, + text: std::fs::read_to_string(&b).unwrap(), + }; + let actions = f.host.provide_code_actions(&doc, ide_core::Range::default()); + assert_eq!(actions.len(), 1, "{actions:?}"); + let edit = &actions[0].1.edits[0].1[0]; + assert_eq!(edit.range.start.line, 1, "after the shebang"); + assert!( + edit.new_text.starts_with("# Copyright (c) 20") + && edit.new_text.contains("# SPDX-License-Identifier: Apache-2.0") + ); + + f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap(); + assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan"); + std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap(); + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(20))); + assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there"); + + // Disabling the plugin withdraws the problems it still shows. + f.host.set_enabled("openidea.license-header", false).unwrap(); + assert_eq!(diagnostics(&b).map(|d| d.len()), Some(0), "withdrawn with the plugin"); +} + +/// 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); + // The first call indexes the XML files; on a slow machine that runs past the + // edit-path budget, so the answer is dropped (the index is kept) and calls + // queued behind it are skipped. The editor asks again; so does this. + let deadline = Instant::now() + Duration::from_secs(10); + let markers = loop { + let markers = f.host.provide_line_markers(&jdoc); + if !markers.is_empty() || Instant::now() > deadline { + break markers; + } + std::thread::sleep(Duration::from_millis(50)); + }; + 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 slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls; + 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, slow); +} + +/// 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); +} + +/// 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}"); + } +} + +/// Regex Tester: an `html` body its script talks to; the plugin answers in +/// view messages, in UTF-16 offsets. +#[test] +fn official_regex_tester_answers_its_page() { + let f = fixture(Limits::default()); + let info = f.install(&official("regex-tester")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let view = f.host.render_view("openidea.regex-tester", "regex", None, f.inv()).unwrap(); + let ide_plugin::ViewBody::Html(page) = &view.body else { panic!("{view:?}") }; + assert!(page.contains("openidea.postMessage") && page.contains(r#"{"flags":"m"}"#)); + + f.take_events(); + let run = serde_json::json!({ "op": "run", "seq": 3, "pattern": r"(\d+)", "flags": "", "text": "é 12 345" }); + let event = ide_plugin::ViewEvent::Message { text: run.to_string() }; + f.host.view_event("openidea.regex-tester", "regex", event, f.inv()).unwrap(); + let reply = f + .take_events() + .into_iter() + .find_map(|(_, e)| match e { + PluginEvent::ViewMessage { view, message, .. } if view == "regex" => Some(message), + _ => None, + }) + .expect("a reply"); + let reply: serde_json::Value = serde_json::from_str(&reply).unwrap(); + assert_eq!((reply["seq"].clone(), reply["count"].clone()), (serde_json::json!(3), serde_json::json!(2))); + assert_eq!(reply["matches"][0]["groups"][0], serde_json::json!([2, 4])); +} + +/// A tree filled from a pinned program's output, and a terminal for the +/// activated entry in the invoking window, titled and sized by the plugin's +/// settings (the `jobs` fixture). +#[cfg(unix)] +#[test] +fn pinned_programs_fill_views_and_open_terminals() { + 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("jobs"), "#!/bin/sh\n[ \"$1\" = list ] && printf 'build\\ndeploy\\n'\n").unwrap(); + std::fs::set_permissions(bin.join("jobs"), 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(&sample("jobs")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + + let view = f.host.render_view("jobs", "jobs", None, f.inv()).unwrap(); + let ide_plugin::ViewBody::Tree(items) = &view.body else { panic!("{view:?}") }; + assert_eq!(items.iter().map(|i| i.key.as_str()).collect::>(), ["build", "deploy"]); + + let open = |key: &str| { + f.take_events(); + f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Activate { key: key.into() }, f.inv()).unwrap(); + 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) = open("deploy").expect("a terminal"); + assert_eq!((client.as_deref(), title.as_str()), (Some("main"), "follow: deploy")); + assert_eq!(program.0, bin.join("jobs").canonicalize().unwrap()); + assert_eq!(args, ["follow", "deploy", "--lines=20"]); + + // The plugin's settings, string and number. + let values = serde_json::json!({ "title": "tail ", "lines": 5 }).as_object().unwrap().clone(); + f.workspace.settings.lock().insert("jobs".into(), values); + let (_, title, _, args) = open("build").expect("a terminal"); + assert_eq!(title, "tail build"); + assert_eq!(args, ["follow", "build", "--lines=5"]); +} + +/// A plugin with web views and no code: its pages and their files are +/// installed with it and served only from the declared directories, while +/// it is enabled and only for the installed version; file icons and +/// templates are projected too. +#[test] +fn web_views_serve_only_their_own_files() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("web-demo"); + for (path, text) in [ + ("web/index.html", "draw"), + ("web/assets/app.js", "console.log(1)"), + ("panel/index.html", "

panel

"), + ( + "icons/draw.svg", + r##""##, + ), + ("templates/new.draw", "{\"type\": \"drawing\"}\n"), + ("secret.txt", "not served"), + ] { + std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap(); + std::fs::write(pkg.join(path), text).unwrap(); + } + std::fs::write( + pkg.join("plugin.toml"), + r#"id = "web-demo" +name = "Web Demo" +version = "1.0.0" +engines.openidea = "^1.0" + +[capabilities] +optional = ["clipboard"] + +[[contributes.customEditors]] +id = "draw" +title = "Drawing" +patterns = ["*.draw"] +layout = "preview" +web = "web/index.html" + +[[contributes.views]] +id = "panel" +title = "Panel" +web = "panel/index.html" + +[[contributes.fileIcons]] +id = "draw" +patterns = ["*.draw"] +icon = "icons/draw.svg" + +[[contributes.fileTemplates]] +id = "draw" +name = "Drawing" +extension = "draw" +path = "templates/new.draw" +"#, + ) + .unwrap(); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + assert!(info.capabilities.iter().any(|c| c.id == "clipboard" && c.granted), "{:?}", info.capabilities); + // The pages' directories are installed with the plugin; files nothing declares are not. + let installed = f.installed("web-demo"); + assert!(installed.join("web/assets/app.js").is_file() && installed.join("panel/index.html").is_file()); + assert!(!installed.join("secret.txt").exists()); + + let page = f.host.web_page("web-demo", "draw").unwrap(); + let (rev, entry) = page.path.strip_prefix("web-demo/").unwrap().split_once('/').unwrap(); + assert_eq!(entry, "web/index.html"); + let file = |path: &str| f.host.web_file("web-demo", path); + let js = file(&format!("{rev}/web/assets/app.js")).unwrap(); + assert_eq!((js.bytes.as_slice(), js.mime), (b"console.log(1)".as_slice(), "text/javascript; charset=utf-8")); + assert!(file(&format!("{rev}/panel/index.html")).is_ok()); + assert_eq!(f.host.web_page("web-demo", "panel").unwrap().path, format!("web-demo/{rev}/panel/index.html")); + for denied in [ + format!("{rev}/secret.txt"), + format!("{rev}/plugin.toml"), + format!("{rev}/web/../secret.txt"), + format!("{rev}/icons/draw.svg"), + "stale-revision/web/index.html".to_owned(), + ] { + assert!(file(&denied).is_err(), "{denied} was served"); + } + assert!(f.host.web_page("web-demo", "nope").is_err()); + + // File icons and templates. + let icon = f.host.assets("web-demo", "fileIcon/draw/").unwrap(); + assert_eq!(icon.len(), 1); + assert!(icon[0].0 == "fileIcon/draw/light" && icon[0].1.contains(" = (0..4) + .map(|i| { + let (host, done) = (host.clone(), done.clone()); + let root = f._tmp.path().join(format!("p{i}")); + std::thread::spawn(move || { + while !done.load(std::sync::atomic::Ordering::Relaxed) { + if i % 2 == 0 { + host.list(None); + } else { + host.restrict_project(&root); + } + } + }) + }) + .collect(); + std::thread::sleep(Duration::from_millis(500)); + done.store(true, std::sync::atomic::Ordering::Relaxed); + let started = Instant::now(); + for w in workers { + while !w.is_finished() { + assert!(started.elapsed() < Duration::from_secs(10), "deadlocked"); + std::thread::sleep(Duration::from_millis(10)); + } + w.join().unwrap(); + } +} + +/// Excalidraw: a web custom editor beside the text editor, with its fonts, +/// file icon, template and language — no code. +#[test] +fn official_excalidraw_installs_and_serves_its_page() { + let tmp = tempfile::tempdir().unwrap(); + let pkg = with_stand_in_page(&official("excalidraw"), tmp.path()); + let f = fixture(Limits::default()); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + let page = f.host.web_page("openidea.excalidraw", "canvas").unwrap(); + let (rev, entry) = page.path.strip_prefix("openidea.excalidraw/").unwrap().split_once('/').unwrap(); + assert_eq!(entry, "web/index.html"); + let html = f.host.web_file("openidea.excalidraw", &format!("{rev}/{entry}")).unwrap(); + let html = String::from_utf8(html.bytes).unwrap(); + // Relative URLs only: the page is served from the package. + let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap(); + assert!(script.starts_with("./assets/"), "{script}"); + assert!(f.host.web_file("openidea.excalidraw", &format!("{rev}/web/{}", &script[2..])).is_ok()); + let font = f.host.web_file("openidea.excalidraw", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap(); + assert_eq!(font.mime, "font/woff2"); + assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "preview"); + assert!(f.host.languages().iter().any(|l| l.id.as_str() == "excalidraw")); + assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2); +} + +/// A copy of the official plugin at `src` in `dir`, with a stand-in for its +/// built page (`web/` is built by plugins/build.sh, not checked in; build.sh +/// checks what the build produces): an entry loading a script and a font by +/// relative URL. +fn with_stand_in_page(src: &Path, dir: &Path) -> PathBuf { + let pkg = dir.join(src.file_name().unwrap()); + std::fs::create_dir_all(&pkg).unwrap(); + for entry in std::fs::read_dir(src).unwrap().flatten() { + let name = entry.file_name(); + if ["web", "web-src", "web.sources", "target"].iter().any(|n| name == *n) { + continue; + } + let to = pkg.join(&name); + if entry.path().is_dir() { + copy_dir(&entry.path(), &to) + } else { + std::fs::copy(entry.path(), to).map(|_| ()).unwrap() + } + } + std::fs::create_dir_all(pkg.join("web/assets")).unwrap(); + std::fs::create_dir_all(pkg.join("web/fonts/Virgil")).unwrap(); + std::fs::write( + pkg.join("web/index.html"), + r#""#, + ) + .unwrap(); + std::fs::write(pkg.join("web/assets/index.js"), "export {};").unwrap(); + std::fs::write(pkg.join("web/fonts/Virgil/Virgil-Regular.woff2"), b"wOF2").unwrap(); + pkg +} + +/// Graphviz: a web view with code. What the page reports about the graph +/// becomes the previewed file's problems. +#[test] +fn official_graphviz_turns_its_pages_reports_into_problems() { + let tmp = tempfile::tempdir().unwrap(); + let f = fixture(Limits::default()); + let info = f.install(&with_stand_in_page(&official("graphviz"), tmp.path())); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "split"); + assert!(f.host.languages().iter().any(|l| l.id.as_str() == "dot")); + assert!(f.host.web_page("openidea.graphviz", "preview").is_ok()); + + std::fs::write(f.project.join("flow.dot"), "digraph {\n a -> \n}\n").unwrap(); + let editor = f.workspace.open("flow.dot", &[]); + let inv = Invocation { editor: Some(editor), ..f.inv() }; + let report = + r#"{"op":"problems","items":[{"line":3,"message":"syntax error in line 3 near '}'","level":"error"}]}"#; + let event = ide_plugin::ViewEvent::Message { text: report.into() }; + f.host.view_event("openidea.graphviz", "preview", event, inv.clone()).unwrap(); + let path = f.project.join("flow.dot"); + let problems = + |f: &Fixture| f.workspace.diagnostics.lock().get(&("openidea.graphviz".into(), path.clone())).cloned(); + let d = problems(&f).expect("published"); + assert_eq!((d.len(), d[0].range.start.line), (1, 2)); + let cleared = ide_plugin::ViewEvent::Message { text: r#"{"op":"problems","items":[]}"#.into() }; + f.host.view_event("openidea.graphviz", "preview", cleared, inv).unwrap(); + assert_eq!(problems(&f).map(|d| d.len()), Some(0)); +} + +/// Zips `dir` as an archiver does: the folder itself at the top, plus macOS metadata. +fn zip_folder(dir: &Path, out: &Path) { + use std::io::Write; + let name = dir.file_name().unwrap().to_string_lossy().into_owned(); + let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap()); + let options = zip::write::SimpleFileOptions::default(); + let mut stack = vec![(dir.to_path_buf(), name.clone())]; + while let Some((real, rel)) = stack.pop() { + for entry in std::fs::read_dir(&real).unwrap().flatten() { + let (path, rel) = (entry.path(), format!("{rel}/{}", entry.file_name().to_string_lossy())); + if path.is_dir() { + stack.push((path, rel)); + } else { + zip.start_file(rel, options).unwrap(); + zip.write_all(&std::fs::read(&path).unwrap()).unwrap(); + } + } + } + zip.start_file(format!("__MACOSX/{name}/._plugin.toml"), options).unwrap(); + zip.write_all(b"metadata").unwrap(); + zip.finish().unwrap(); +} + +#[test] +fn a_zipped_plugin_folder_installs_like_the_folder() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let archive = tmp.path().join("language-toy.zip"); + zip_folder(&sample("language-toy"), &archive); + + let dir = f.host.unpack_package(&archive).unwrap(); + let preview = f.host.preview_install(&dir).unwrap(); + assert_eq!(preview.id, "language-toy"); + assert!(preview.unsigned); + let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap(); + assert_eq!(info.id, "language-toy"); + assert_eq!(f.host.languages().len(), 1); + + // A zip that isn't an OpenIDEA plugin says so. + let other = tmp.path().join("intellij-plugin.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&other).unwrap()); + zip.start_file("my-plugin/lib/my-plugin.jar", zip::write::SimpleFileOptions::default()).unwrap(); + zip.finish().unwrap(); + let err = f.host.unpack_package(&other).unwrap_err().message; + assert!(err.contains("is not an OpenIDEA plugin"), "{err}"); +} + +/// Secrets live outside the plugin's directory (the OS keychain on the +/// desktop): uninstalling the plugin deletes the ones it declares. +#[test] +fn uninstalling_deletes_the_plugins_secrets() { + #[derive(Default)] + struct Keychain(Mutex>); + impl SecretStore for Keychain { + fn get(&self, plugin: &str, name: &str) -> Option { + self.0.lock().get(&(plugin.into(), name.into())).cloned() + } + fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> { + let key = (plugin.to_owned(), name.to_owned()); + match value { + Some(v) => self.0.lock().insert(key, v.into()), + None => self.0.lock().remove(&key), + }; + Ok(()) + } + } + let f = fixture(Limits::default()); + let keychain = Arc::new(Keychain::default()); + f.host.set_secret_store(keychain.clone()); + f.install(&official("ai-completion")); + f.host.set_secret("openidea.ai-completion", "api-key", Some("sk-test")).unwrap(); + assert!(keychain.get("openidea.ai-completion", "api-key").is_some()); + f.host.uninstall("openidea.ai-completion").unwrap(); + assert!(keychain.0.lock().is_empty(), "{:?}", keychain.0.lock().keys().collect::>()); +} diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs new file mode 100644 index 00000000..dd3bd868 --- /dev/null +++ b/crates/ide-host/tests/remote_plugins.rs @@ -0,0 +1,304 @@ +//! Code plugins of a remote project run on the +//! server. The desktop's plugin host syncs its live +//! code plugins over the remote protocol; Checkstyle then checks the file on +//! the server's disk when it is saved, and secrets come from the desktop. + +#![cfg(unix)] + +use ide_core::IdeEvent; +use ide_host::plugins::remote_host::{ + EVENT_NOTIFICATION, PackageSyncReport, PluginEventMessage, RemotePlugins, SECRET_REQUEST, SYNC_PACKAGES, + SecretRequest, +}; +use ide_host::plugins::{Consent, PluginEvent, PluginHost, PluginStatus, WorkbenchEvent}; +use ide_host::rpc::{self, Dispatcher, EventMessage, Invoke}; +use ide_host::{Host, HostConfig}; +use ide_jsonrpc::{Handler, Peer}; +use ide_plugin::Limits; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +#[derive(Default)] +struct Client { + events: Mutex>, + 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 test fixture plugin (`crates/ide-plugin/tests/fixtures/`). +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures").join(name) +} + +/// A `checkstyle` that reports a tab on line 2 of the file it is given. +fn fake_checkstyle(bin: &Path) { + std::fs::create_dir_all(bin).unwrap(); + std::fs::write( + bin.join("checkstyle"), + "#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q '\t' \"$file\" || { echo ''; 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(); +} + +/// CSV Editor and a tool window with a terminal (the `jobs` fixture) in a +/// remote project. The table edits the document on the server; the jobs are +/// the server's `jobs` program's, and the terminal opens in the remote window. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn remote_projects_get_plugin_views_and_custom_editors() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("jobs"), "#!/bin/sh\n[ \"$1\" = list ] && echo remote-job\n").unwrap(); + std::fs::set_permissions(bin.join("jobs"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let path_var = { + let bin = bin.clone(); + Arc::new(move || Some(bin.clone().into_os_string())) + }; + let desktop = + PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + .unwrap(); + desktop.set_program_path(path_var.clone()); + for dir in [official("csv-editor"), fixture("jobs")] { + let info = desktop.install_from_dir(&dir, &Consent::all()).unwrap(); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + } + + let project = tmp.path().join("remote/demo"); + std::fs::create_dir_all(&project).unwrap(); + let project = std::fs::canonicalize(project).unwrap(); + std::fs::write(project.join("people.csv"), "name,city\nAda,London\n").unwrap(); + let base = tmp.path().join("server"); + let peer_slot = Arc::new(OnceLock::new()); + let sink_peer = peer_slot.clone(); + let host = Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap(); + plugins.host().set_program_path(path_var); + let mut dispatcher = Dispatcher::standard(); + plugins.register(&mut dispatcher); + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let client = Arc::new(Client::default()); + let peer = Peer::connect(cr, cw, client.clone()); + let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap(); + invoke(&peer, "open_project", json!({ "path": project })).await.unwrap(); + invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap(); + let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs"]).await; + assert_eq!(report.installed.len(), 2, "{report:?}"); + + // CSV Editor: the document is the server's. + let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap(); + let doc = opened["id"].as_u64().expect("doc id"); + let args = |extra: Value| { + let mut a = json!({ "pluginId": "openidea.csv-editor", "view": "table", "doc": doc }); + a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + a + }; + let view = + invoke(&peer, "plugin_view_render", args(json!({ "viewport": { "firstRow": 0, "rows": 50 } }))).await.unwrap(); + assert_eq!(view["body"]["kind"], "table"); + assert_eq!(view["body"]["value"]["rows"], json!([["Ada", "London"]])); + let cell = json!({ "event": { "kind": "cell", "row": 0, "column": 1, "value": "Oslo" } }); + invoke(&peer, "plugin_view_event", args(cell)).await.unwrap(); + let snap = invoke(&peer, "doc_snapshot", json!({ "doc": doc })).await.unwrap(); + assert_eq!(snap["text"], "name,city\nAda,Oslo\n"); + + // Jobs: the server's program; the terminal opens in this window. + let jobs = |event: Value| json!({ "pluginId": "jobs", "view": "jobs", "doc": null, "event": event }); + let view = + invoke(&peer, "plugin_view_render", json!({ "pluginId": "jobs", "view": "jobs", "doc": null })).await.unwrap(); + assert_eq!(view["body"]["value"][0]["label"], "remote-job", "{view}"); + invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "select", "key": "remote-job" }))).await.unwrap(); + invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "button", "id": "follow" }))).await.unwrap(); + until("the job's terminal", 5, || { + client.plugin_events.lock().iter().any(|m| { + m.window.as_deref() == Some("main") + && matches!(&m.event, PluginEvent::OpenTerminal { program, .. } if program.0.starts_with(bin.canonicalize().unwrap())) + }) + }) + .await; + plugins.host().shutdown(); +} diff --git a/crates/ide-http/src/client.rs b/crates/ide-http/src/client.rs index b7b9dfa5..05963a4c 100644 --- a/crates/ide-http/src/client.rs +++ b/crates/ide-http/src/client.rs @@ -35,8 +35,16 @@ pub fn build(proxy: &ProxySettings) -> IdeResult { .with_platform_verifier() .map_err(|e| fail(e.to_string()))? .with_no_client_auth(); + // `localhost` is tried on IPv4 first: local servers (Ollama, dev servers) + // usually listen there, and on Windows a refused `::1` costs ~2 s before + // the system resolver's order falls back. Ports come from the URL. + let loopback = [ + std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0)), + std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 0)), + ]; let builder = reqwest::Client::builder() .tls_backend_preconfigured(tls) + .resolve_to_addrs("localhost", &loopback) .connect_timeout(Duration::from_secs(30)) .user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION"))); with_proxy(builder, proxy).build().map_err(|e| fail(e.to_string())) @@ -113,7 +121,11 @@ mod tests { /// A one-shot HTTP server on loopback: answers `200` and hands back the request head. fn server() -> (u16, std::thread::JoinHandle) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + server_on("127.0.0.1:0") + } + + fn server_on(addr: &str) -> (u16, std::thread::JoinHandle) { + let listener = TcpListener::bind(addr).unwrap(); let port = listener.local_addr().unwrap().port(); let handle = std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); @@ -141,6 +153,21 @@ mod tests { check_connection(proxy, url, &CancellationToken::new()).await } + /// `localhost` reaches a server on either loopback address. + #[tokio::test] + async fn localhost_reaches_ipv4_and_ipv6_servers() { + let direct = ProxySettings::default(); + let (port, v4) = server(); + assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK"); + v4.join().unwrap(); + // Hosts without IPv6 loopback skip this half. + if TcpListener::bind("[::1]:0").is_ok() { + let (port, v6) = server_on("[::1]:0"); + assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK"); + v6.join().unwrap(); + } + } + #[tokio::test] async fn builds_for_every_mode() { for mode in [ProxyMode::None, ProxyMode::Auto, ProxyMode::Manual] { diff --git a/crates/ide-language/Cargo.toml b/crates/ide-language/Cargo.toml index b830b706..4bda809a 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 bd4fa6d5..cef38d97 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: +/// 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 00000000..b6558ff1 --- /dev/null +++ b/crates/ide-language/src/plugin_point.rs @@ -0,0 +1,294 @@ +//! 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: PathBuf, +} + +/// 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 = pkg.read_text(&decl.path)?; + let cfg = parse_definition(&src).map_err(|e| format!("{}: {e}", decl.path.display()))?; + let caps = required_capabilities(&cfg); + let (store, plugin, key, label) = ( + self.store.clone(), + pkg.id().to_owned(), + cfg.id.to_string(), + format!("{} (.{})", cfg.name, cfg.extensions.join(", .")), + ); + let mut prepared = Prepared::new(label, move || Ok(store.add(plugin, cfg))).key(key).file(decl.path); + for cap in caps { + prepared = prepared.capability(cap); + } + Ok(prepared) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_extension::{ExtensionRegistry, Manifest}; + + #[test] + fn languages_point_registers_and_requires_process_capabilities() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir(tmp.path().join("l")).unwrap(); + std::fs::write( + tmp.path().join("l/sol.toml"), + "id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\ + [[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\nargs = [\"--lsp\"]\n", + ) + .unwrap(); + let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\ + [[contributes.languages]]\npath = \"l/sol.toml\""; + let pkg = Package::new(Manifest::parse(src).unwrap(), tmp.path().to_path_buf()); + let store = PluginLanguages::new(); + let reg = ExtensionRegistry::new(); + reg.register(Arc::new(LanguagesPoint::new(store.clone()))).unwrap(); + let set = reg.prepare(&pkg).unwrap(); + assert_eq!(set.capabilities, [Capability::Process("solc".into())]); + assert_eq!(set.files, [PathBuf::from("l/sol.toml")]); + reg.commit(set).unwrap(); + assert_eq!(store.all()[0].id.as_str(), "solidity"); + reg.dispose_plugin("p"); + assert!(store.all().is_empty()); + + let bad = |def: &str| parse_definition(def).unwrap_err(); + assert!(bad("not toml [").contains("invalid language")); + assert!(bad("id = \"\"\nname = \"x\"").contains("must not be empty")); + assert!(bad("id = \"a b\"\nname = \"x\"").contains("invalid language id")); + } + + #[test] + fn registry_overlay_follows_the_store_and_the_grants() { + use crate::LanguageRegistry; + use ide_core::LanguageId; + use std::path::Path; + + let base = LanguageRegistry::load(None).unwrap(); + let store = PluginLanguages::new(); + base.attach_plugins(store.clone()); + // Projects fork before plugins load: the fork follows the store anyway. + let project = base.fork(); + assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text()); + + let sol = parse_definition( + "id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\ + [[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\n\ + [debugger]\nadapter = \"soldbg\"\ncommand = \"soldbg\"\n", + ) + .unwrap(); + let rust_clash = parse_definition("id = \"rust\"\nname = \"Evil Rust\"\nextensions = [\"rs\"]").unwrap(); + let ext_clash = parse_definition( + "id = \"rusty\"\nname = \"Rusty\"\nextensions = [\"rs\", \"rsy\"]\nfilenames = [\"Cargo.lock\"]", + ) + .unwrap(); + let _sol = store.add("acme.sol".into(), sol); + let _clash = store.add("acme.evil".into(), rust_clash); + let _ext = store.add("acme.evil".into(), ext_clash); + + assert_eq!(project.detect(Path::new("a.sol"), None).as_str(), "solidity"); + let rust = project.get(&LanguageId::new("rust")).unwrap(); + assert_eq!(rust.name, "Rust", "built-in definitions win on id clashes"); + assert_eq!(project.detect(Path::new("main.rs"), None).as_str(), "rust", "and keep their extensions"); + assert_ne!(project.detect(Path::new("Cargo.lock"), None).as_str(), "rusty", "and file names"); + assert_eq!(project.detect(Path::new("x.rsy"), None).as_str(), "rusty"); + // No policy: the language works, its programs do not start. + let cfg = project.get(&LanguageId::new("solidity")).unwrap(); + assert!(cfg.language_servers.is_empty() && cfg.debugger.is_none()); + + let pin = ProgramPin { path: "/opt/solc".into(), hash: "h".into() }; + let granted = pin.clone(); + store.set_process_policy(Arc::new(move |plugin, cap| { + (plugin == "acme.sol" && *cap == Capability::Process("solc".into())).then(|| granted.clone()) + })); + let cfg = project.get(&LanguageId::new("solidity")).unwrap(); + assert_eq!(cfg.language_servers[0].pinned.as_ref(), Some(&pin)); + assert!(cfg.debugger.is_none(), "soldbg was not granted"); + + drop(_sol); + assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text()); + assert!(base.get(&LanguageId::new("solidity")).is_none()); + } + + #[test] + fn program_pins_check_the_file() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("tool"); + std::fs::write(&bin, "v1").unwrap(); + let pin = ProgramPin { path: bin.clone(), hash: ide_extension::ledger::file_hash(&bin).unwrap() }; + assert_eq!(pin.verify().unwrap(), bin); + std::fs::write(&bin, "v2").unwrap(); + assert!(pin.verify().unwrap_err().contains("changed since it was approved")); + } +} diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 34caf951..83264595 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 038ace1c..393eb752 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 00000000..6399a2f0 --- /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`. 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 07c94249..fc43637b 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 e961eb35..8c0c9bf2 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 5c7d53c0..c8f68f0e 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 5a600ec8..fef3fb36 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 7ed98d3b..3bd92275 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, +} + +/// A call queued with [`Actor::submit`]. +pub struct Pending { + /// The outcome and when the plugin's thread produced it. + result: mpsc::Receiver<(Result, Instant)>, + give_up: Option, + alive: Arc, +} + +impl Pending { + /// Waits for the outcome, until the call's soft deadline if it has one. A + /// result produced after that deadline is dropped even when it is already + /// there (the caller was not scheduled in time, or waited on another call + /// first), so the deadline holds however busy the machine is; a failure is + /// still reported. + pub fn wait(self) -> Result { + let outcome = match self.give_up { + Some(t) => match self.result.recv_timeout(t.saturating_duration_since(Instant::now())) { + Ok((Ok(_), done)) if done > t => return Err(ActorError::SoftTimeout), + Ok((r, _)) => r, + Err(mpsc::RecvTimeoutError::Timeout) => return Err(ActorError::SoftTimeout), + Err(mpsc::RecvTimeoutError::Disconnected) => { + // Skipped as too late, or the actor ended. + let alive = self.alive.load(Ordering::Acquire); + return Err(if alive { ActorError::SoftTimeout } else { ActorError::Stopped }); + } + }, + None => self.result.recv().map_err(|_| ActorError::Stopped)?.0, + }; + outcome.map_err(ActorError::Call) + } +} + +pub struct Actor { + tx: Mutex>>, + 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 { + self.submit(wait, f)?.wait() + } + + /// Queues `f` like [`call`](Self::call) and returns at once, so a caller + /// can start calls on several plugins and then wait for all of them: the + /// wait is the slowest one's, not the sum. + pub fn submit( + &self, + wait: Option, + f: impl FnOnce(&mut Runtime) -> Result + Send + 'static, + ) -> Result, ActorError> { + let give_up = wait.map(|w| Instant::now() + w); + let (reply, result) = mpsc::sync_channel(1); + let (crash, alive) = (self.crash.clone(), self.alive.clone()); + let job: Job = Box::new(move |slot| { + if give_up.is_some_and(|t| Instant::now() >= t) { + return; + } + let Some(rt) = slot.as_mut() else { return }; + let outcome = f(rt); + if let Err(CallError::Trap(e)) = &outcome { + *crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone()); + // Before the reply: the caller sees the actor dead. + alive.store(false, Ordering::Release); + *slot = None; + } + let _ = reply.send((outcome, Instant::now())); + }); + { + let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner()); + let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) }; + tx.send(job).map_err(|_| ActorError::Stopped)?; + } + Ok(Pending { result, give_up, alive: self.alive.clone() }) + } + + /// Like [`call`](Self::call) without a soft deadline, but `cancel` (the + /// caller's request was superseded) cancels the call cooperatively, and + /// interrupts it if it has not returned `grace` later. Returns + /// [`ActorError::Cancelled`] then. + pub fn call_cancellable( + &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 dc6a3ed4..fd27cb24 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -1,771 +1,24 @@ -//! Sandboxed WebAssembly plugin host (Zed-style). +//! The WebAssembly runtime for plugins (wasmtime, component model, WASI +//! preview 2). Package handling, extension points and grants live in +//! `ide-extension`; the host that assembles them is `ide-host::plugins`. //! -//! A plugin is a directory with a `plugin.toml` manifest ([`PluginManifest`]) -//! and, optionally, a WebAssembly component implementing the -//! `openidea:plugin/plugin@0.1.0` world (`wit/plugin.wit`). Contribution-only -//! plugins (themes, languages) need no code. -//! -//! Each plugin with code runs in its own wasmtime `Store` with WASI preview 2 -//! (no files, env, network), a fuel budget and wall-clock deadline per call, -//! and a linear-memory cap. A trap (panic, budget exhausted, permission -//! violation) drops the instance and marks the plugin `Failed` for the session; -//! the IDE keeps running. -//! -//! Sources: `//` (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, Pending}; +pub use runtime::{ + API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api, + component_api, host_api, }; -pub use sandbox::resolve_in_root; -pub use theme::{ThemeDef, ThemeKind}; +pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root}; pub use types::*; - -use ide_core::{IdeError, IdeResult}; -use ide_language::LanguageConfig; -use parking_lot::{Condvar, Mutex, RwLock}; -use runtime::{CallEffects, CallError, PluginEngine, Registrations, Runtime, RuntimeSetup}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -const STATE_FILE: &str = "state.json"; -/// Top-level files copied with a package on install besides those the manifest references. -const LICENSE_FILES: &[&str] = - &["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING"]; -/// Dev plugins live here, relative to a project root. -pub const DEV_PLUGINS_DIR: &str = ".openidea/plugins"; - -#[derive(Default, Serialize, Deserialize)] -struct PersistedState { - #[serde(default)] - disabled: BTreeSet, - #[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 c32ac6ca..00000000 --- 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 cefb3fa9..00000000 --- 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 83962044..fdb48019 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -1,41 +1,116 @@ -//! 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, AtomicU64, 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, `major.minor` of [`host_api`]. +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 and edits to open +/// documents. 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); + /// 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 +140,200 @@ 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`: 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 + } + + /// Rejects a component this host can't run, with a message that says what + /// to do: one importing interfaces outside the plugin API and WASI, or one + /// built for a plugin API this host doesn't implement (another major, or a + /// newer minor). Returns the API version the component was built for + /// (`None`: it uses no plugin interface). + pub fn check_component(&self, component: &Component) -> Result, String> { + let ty = component.component_type(); + let imports: Vec = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect(); + let foreign: Vec<&str> = imports + .iter() + .map(String::as_str) + .filter(|i| !(i.starts_with(PLUGIN_PACKAGE_PREFIX) || i.starts_with("wasi:"))) + .collect(); + if !foreign.is_empty() { + return Err(format!("the component imports interfaces the IDE does not provide: {}", foreign.join(", "))); + } + let exports = ty.exports(&self.engine).map(|(name, _)| name.to_owned()).collect::>(); + let built_for = component_api(imports.iter().chain(&exports).map(String::as_str))?; + if let Some(v) = &built_for { + check_api(v, &host_api())?; + } + Ok(built_for) } } -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()); - } - Ok(cfg) +/// Interface names of the plugin API start with this (`openidea:plugin/log@1.0.0`). +const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/"; + +/// The plugin API this host implements: the version of the `wit/` package. +pub fn host_api() -> semver::Version { + semver::Version::parse(ide_extension::ENGINE_VERSION).expect("ENGINE_VERSION is a semantic version") } -/// Side effects of one call the host must act on afterwards. +/// The plugin API version a component was built for: the highest version on +/// its `openidea:plugin/*` imports and exports. An unversioned plugin +/// interface is an error, since nothing could tell compatible hosts apart. +pub fn component_api<'a>(names: impl IntoIterator) -> Result, String> { + let mut highest: Option = None; + for name in names { + let Some(rest) = name.strip_prefix(PLUGIN_PACKAGE_PREFIX) else { continue }; + let Some((_, version)) = rest.split_once('@') else { + return Err(format!("the component uses the unversioned plugin interface {name:?}")); + }; + let version = + semver::Version::parse(version).map_err(|e| format!("bad plugin API version in {name:?}: {e}"))?; + if highest.as_ref().is_none_or(|h| version > *h) { + highest = Some(version); + } + } + Ok(highest) +} + +/// A component built for `built_for` runs on a host implementing `host` when +/// the major versions match and the host's minor is at least as new (1.x only +/// adds; patches change no interface; 0.x makes no promise, so there every +/// minor is its own API). +pub fn check_api(built_for: &semver::Version, host: &semver::Version) -> Result<(), String> { + let (b, h) = ((built_for.major, built_for.minor), (host.major, host.minor)); + let same_line = b.0 == h.0 && (h.0 > 0 || b.1 == h.1); + if same_line && b <= h { + return Ok(()); + } + let newer = b > h; + let (b, h) = (format!("{}.{}", b.0, b.1), format!("{}.{}", h.0, h.1)); + Err(if newer { + format!("the component was built for plugin API {b}, newer than this IDE's {h}: update OpenIDEA") + } else { + format!("the component was built for plugin API {b}; this IDE runs {h}: rebuild it with the {h} SDK") + }) +} + +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); + } + // Unique per write: two threads may store the same component at once, and a + // shared temporary file could be renamed into place half written. + static WRITES: AtomicU64 = AtomicU64::new(0); + let tmp = file.with_extension(format!("tmp{}-{}", std::process::id(), WRITES.fetch_add(1, Ordering::Relaxed))); + if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, file)).is_err() { + let _ = std::fs::remove_file(&tmp); + } +} + +/// 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, + /// 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 +343,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 +352,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,182 +363,407 @@ 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 { - Guest(String), +pub enum CallError { + /// The plugin returned an error. + Guest(GuestError), + /// The instance trapped (panic, fuel, memory, timeout) and is unusable. Trap(String), } -pub(crate) struct RuntimeSetup { +impl CallError { + fn missing_export(interface: &str) -> Self { + Self::Guest(GuestError::Failed(format!("the plugin does not export {interface}"))) + } +} + +impl std::fmt::Display for CallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Guest(e) => e.fmt(f), + Self::Trap(e) => f.write_str(e), + } + } +} + +/// An error a plugin returned (the API's `error` variant). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GuestError { + /// A capability it needs is not granted. + Denied(String), + Stale, + NotFound(String), + Invalid(String), + /// The user cancelled, or the plugin is being stopped. + Cancelled, + Failed(String), +} + +impl GuestError { + /// Whether this is the plugin failing, as opposed to an outcome the + /// user or the host caused (cancelling, a missing grant, bad input). + pub fn is_failure(&self) -> bool { + matches!(self, Self::Failed(_)) + } +} + +impl std::fmt::Display for GuestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Denied(c) => write!(f, "permission {c} was not granted"), + Self::Stale => f.write_str("the document changed meanwhile"), + 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 From for ide_core::IdeError { + fn from(e: GuestError) -> Self { + use ide_core::error::ErrorKind; + let kind = match &e { + GuestError::Denied(_) => ErrorKind::PermissionDenied, + GuestError::Stale => ErrorKind::OutOfSync, + GuestError::NotFound(_) => ErrorKind::NotFound, + GuestError::Invalid(_) => ErrorKind::InvalidInput, + GuestError::Cancelled => ErrorKind::Cancelled, + GuestError::Failed(_) => ErrorKind::Internal, + }; + ide_core::IdeError::new(kind, e.to_string()) + } +} + +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, + }) } - pub fn activate(&mut self) -> Result { + /// 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::missing_export("syntax-provider")); + } + self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store)) + } + + /// 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::missing_export("providers")); + } + self.call(Invocation::default(), timeout, |e, store| f(e.providers.as_ref().expect("checked"), store)) + } + + 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| clip(k, 32)), + 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<(), CallError> { let data = self.store.data(); let ctx = wit_types::Context { 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| r.map_err(|e| CallError::Guest(e.into()))) } pub fn run_command( @@ -359,37 +771,118 @@ impl Runtime { id: &str, arg: Option<&str>, invocation: Invocation, - ) -> Result<(Option, CallEffects), CallError> { - let (result, fx) = self.call(invocation, |plugin, store| plugin.call_run_command(store, id, arg))?; - result.map(|v| (v, fx)).map_err(CallError::Guest) + ) -> Result, CallError> { + let result = 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_err(|e| CallError::Guest(e.into())) + } + + /// 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 { + if self.exports.views.is_none() { + return Err(CallError::missing_export("view-provider")); + } + let viewport = viewport.map(|v| wit_vt::Viewport { first_row: v.first_row, rows: v.rows.min(MAX_TABLE_ROWS) }); + let result = 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)), + Err(e) => Err(CallError::Guest(e.into())), + } + } + + /// A user action in a view or custom editor. + pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> { + if self.exports.views.is_none() { + return Err(CallError::missing_export("view-provider")); + } + let event = event_to_wit_view(event); + let result = 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_err(|e| CallError::Guest(e.into())) + } + + /// Delivers a batch of events (from the window in `invocation`). + pub fn handle_events(&mut self, events: Vec, invocation: Invocation) -> Result<(), CallError> { + 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 + }) } /// 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)) - { + if let Err(e) = 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, - ) -> Result<(R, CallEffects), CallError> { - arm(&mut self.store, &self.limits).map_err(|e| CallError::Trap(e.to_string()))?; + timeout: Duration, + f: impl FnOnce(&Exports, &mut Store) -> wasmtime::Result, + ) -> Result { + 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(); - let effects = std::mem::take(&mut data.effects); + // 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 stderr = data.drain_stderr(); if !stderr.trim().is_empty() { tracing::info!(plugin = %data.plugin_id, "stderr: {}", stderr.trim_end()); } match result { - Ok(r) => Ok((r, effects)), + Ok(r) => Ok(r), Err(e) => { let mut msg = describe_trap(&e, &self.limits); // Typically the panic message: "panicked at src/lib.rs:1:2:\nboom". @@ -405,17 +898,56 @@ 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(), + } +} + +/// 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 +960,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 47890f3d..9b4dfed0 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,457 @@ 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. #[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 00000000..265f992a --- /dev/null +++ b/crates/ide-plugin/src/wit_host.rs @@ -0,0 +1,662 @@ +//! The host side of the `openidea:plugin@1.0.0` world: bindings, resource +//! types and the implementation of every import. Capabilities are checked +//! here; a missing one returns `error::denied` (the plugin can degrade). + +use crate::runtime::{StoreData, clip}; +use crate::sandbox; +use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host}; +use ide_extension::Capability; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use wasmtime::component::Resource; + +pub(crate) mod bindings { + wasmtime::component::bindgen!({ + world: "plugin", + path: "../../wit", + imports: { default: trappable }, + with: { + "openidea:plugin/workspace.project": super::ProjectRes, + "openidea:plugin/editor.text-editor": super::EditorRes, + "openidea:plugin/invocation.invocation": super::InvocationRes, + "openidea:plugin/tasks.task": super::TaskRes, + }, + }); +} + +/// The optional `syntax-provider` export, bound on its own when present. +pub(crate) mod syntax_bindings { + wasmtime::component::bindgen!({ + world: "plugin-syntax", + path: "../../wit", + imports: { default: trappable }, + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/log": super::bindings::openidea::plugin::log, + "openidea:plugin/ui": super::bindings::openidea::plugin::ui, + "openidea:plugin/storage": super::bindings::openidea::plugin::storage, + "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, + "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, + "openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, + "openidea:plugin/events": super::bindings::openidea::plugin::events, + "openidea:plugin/tasks": super::bindings::openidea::plugin::tasks, + "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, + "openidea:plugin/net": super::bindings::openidea::plugin::net, + "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, + "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/views": super::bindings::openidea::plugin::views, + "openidea:plugin/settings": super::bindings::openidea::plugin::settings, + }, + }); +} + +/// The optional `providers` export. +pub(crate) mod providers_bindings { + wasmtime::component::bindgen!({ + world: "plugin-providers", + path: "../../wit", + imports: { default: trappable }, + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/log": super::bindings::openidea::plugin::log, + "openidea:plugin/ui": super::bindings::openidea::plugin::ui, + "openidea:plugin/storage": super::bindings::openidea::plugin::storage, + "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, + "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, + "openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, + "openidea:plugin/events": super::bindings::openidea::plugin::events, + "openidea:plugin/tasks": super::bindings::openidea::plugin::tasks, + "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, + "openidea:plugin/net": super::bindings::openidea::plugin::net, + "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, + "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/views": super::bindings::openidea::plugin::views, + "openidea:plugin/settings": super::bindings::openidea::plugin::settings, + }, + }); +} + +/// The optional `view-provider` export. +pub(crate) mod views_bindings { + wasmtime::component::bindgen!({ + world: "plugin-views", + path: "../../wit", + imports: { default: trappable }, + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/log": super::bindings::openidea::plugin::log, + "openidea:plugin/ui": super::bindings::openidea::plugin::ui, + "openidea:plugin/storage": super::bindings::openidea::plugin::storage, + "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, + "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, + "openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, + "openidea:plugin/events": super::bindings::openidea::plugin::events, + "openidea:plugin/tasks": super::bindings::openidea::plugin::tasks, + "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, + "openidea:plugin/net": super::bindings::openidea::plugin::net, + "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, + "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/views": super::bindings::openidea::plugin::views, + "openidea:plugin/settings": super::bindings::openidea::plugin::settings, + }, + }); +} + +use bindings::openidea::plugin::{ + diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui, + vcs, views, workspace, +}; + +/// A project handle: its root. +pub struct ProjectRes { + root: PathBuf, +} + +/// The invoking window's focused editor. +pub struct EditorRes { + ctx: EditorContext, +} + +/// The current call's invocation (read from [`StoreData`]). +pub struct InvocationRes; + +pub struct TaskRes { + id: u64, +} + +const MAX_EDITS: usize = 10_000; +const MAX_DIAGNOSTICS: usize = 5_000; +const MAX_REQUEST_BODY: usize = 8 << 20; +const MAX_FIND: u32 = 10_000; +const MAX_STATUS_TEXT: usize = 128; + +type Fallible = 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`, …). +impl From for crate::runtime::GuestError { + fn from(e: wit::Error) -> Self { + let clip = |m: String| clip(m, crate::runtime::MAX_TITLE * 16); + match e { + wit::Error::Denied(c) => Self::Denied(clip(c)), + wit::Error::Stale => Self::Stale, + wit::Error::NotFound(m) => Self::NotFound(clip(m)), + wit::Error::Invalid(m) => Self::Invalid(clip(m)), + wit::Error::Cancelled => Self::Cancelled, + wit::Error::Failed(m) => Self::Failed(clip(m)), + } + } +} + +fn pos(p: wit::Position) -> ide_core::Pos { + ide_core::Pos::new(p.line, p.character) +} + +fn range_to_wit(r: &ide_core::Range) -> wit::Range { + wit::Range { + start: wit::Position { line: r.start.line, character: r.start.col }, + end: wit::Position { line: r.end.line, character: r.end.col }, + } +} + +impl StoreData { + fn require(&self, cap: Capability) -> Result<(), wit::Error> { + if self.granted.iter().any(|g| g.covers(&cap)) { Ok(()) } else { Err(wit::Error::Denied(cap.to_string())) } + } + + fn client(&self) -> Option<&str> { + self.invocation.client.as_deref() + } + + fn push(&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 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/api_compat.rs b/crates/ide-plugin/tests/api_compat.rs new file mode 100644 index 00000000..7ec2a34e --- /dev/null +++ b/crates/ide-plugin/tests/api_compat.rs @@ -0,0 +1,270 @@ +//! Plugin API versioning (plugins/README.md, "Evolving the plugin API"). +//! +//! - The `wit/` package version, `ENGINE_VERSION` and `API_VERSION` agree. +//! - The version check accepts components the host can run and explains the rest. +//! - Components built for 1.0 link against a host of a later 1.x minor that +//! adds functions and interfaces (wasmtime resolves `@1.0.0` names to a +//! semver-compatible definition), and the host finds their exports under +//! the newer name; a 2.0 host does not link them. The newer host is +//! simulated with a linker that stubs every plugin import the component has +//! at the newer version, so no copy of the WIT is needed. +//! +//! Components: the checked-in `dist/plugin.wasm` of the `hello` fixture, the +//! official plugins (`plugins/build.sh` rebuilds them). + +use ide_plugin::{API_VERSION, PluginEngine, check_api, component_api, host_api}; +use std::path::{Path, PathBuf}; +use wasmtime::component::types::ComponentItem; +use wasmtime::component::{Component, Linker, ResourceTable, ResourceType}; +use wasmtime::{Config, Engine}; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; + +fn repo() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// Every component the repository builds for users: the official plugins with code, plus the `hello` fixture. +fn components() -> Vec { + let mut out = vec![repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm")]; + let official = repo().join("plugins/official"); + let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect(); + dirs.sort(); + out.extend(dirs.into_iter().map(|d| d.join("dist/plugin.wasm")).filter(|p| p.is_file())); + assert!(out.len() >= 9, "expected hello and the official plugins, found {out:?}"); + out +} + +fn v(s: &str) -> semver::Version { + semver::Version::parse(s).unwrap() +} + +#[test] +fn wit_package_engine_and_api_versions_agree() { + let host = host_api(); + assert_eq!(API_VERSION, format!("{}.{}", host.major, host.minor)); + let want = format!("package openidea:plugin@{host};"); + let mut files = 0; + for entry in std::fs::read_dir(repo().join("wit")).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|e| e == "wit") { + let src = std::fs::read_to_string(&path).unwrap(); + assert!(src.lines().any(|l| l.trim() == want), "{} does not declare `{want}`", path.display()); + files += 1; + } + } + assert!(files > 1); +} + +/// The SDK ships its own copy of `wit/` (it builds outside this repository); +/// it must be the API this host implements. +#[test] +fn the_sdk_carries_the_hosts_wit() { + let read = |dir: PathBuf| { + let mut files: Vec<(String, String)> = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "wit")) + .map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read_to_string(&p).unwrap())) + .collect(); + files.sort(); + files + }; + let host = read(repo().join("wit")); + let sdk = read(repo().join("sdk/rust/openidea-plugin/wit")); + let names = |f: &[(String, String)]| f.iter().map(|(n, _)| n.clone()).collect::>(); + assert_eq!(names(&sdk), names(&host), "sdk/rust/openidea-plugin/wit has other files than wit/"); + for ((name, a), (_, b)) in host.iter().zip(&sdk) { + assert!(a == b, "sdk/rust/openidea-plugin/wit/{name} differs from wit/{name}: copy wit/ into the SDK"); + } +} + +#[test] +fn the_version_check_runs_compatible_components_and_explains_the_rest() { + let host = v("1.3.0"); + for ok in ["1.0.0", "1.3.0", "1.3.7", "1.0.0-rc.1"] { + assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}"); + } + let newer = check_api(&v("1.4.0"), &host).unwrap_err(); + assert!(newer.contains("1.4") && newer.contains("update OpenIDEA"), "{newer}"); + assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA")); + let older = check_api(&v("0.2.0"), &host).unwrap_err(); + assert!(older.contains("rebuild it with the 1.3 SDK"), "{older}"); + // 0.x: every minor is its own API. + assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(())); + assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err()); + assert!(check_api(&v("0.3.0"), &v("0.2.0")).is_err()); +} + +#[test] +fn a_components_api_is_the_highest_plugin_interface_version() { + let names = ["wasi:io/poll@0.2.6", "openidea:plugin/log@1.0.0", "openidea:plugin/views@1.2.0"]; + assert_eq!(component_api(names), Ok(Some(v("1.2.0")))); + assert_eq!(component_api(["wasi:cli/stdout@0.2.6"]), Ok(None)); + assert!(component_api(["openidea:plugin/log"]).unwrap_err().contains("unversioned")); +} + +#[test] +fn shipped_components_are_built_for_this_api() { + let engine = PluginEngine::new().unwrap(); + let host = host_api(); + for wasm in components() { + let component = engine.compile(&wasm).unwrap(); + let built_for = engine.check_component(&component).unwrap_or_else(|e| panic!("{}: {e}", wasm.display())); + let built_for = built_for.unwrap_or_else(|| panic!("{} uses no plugin interface", wasm.display())); + assert_eq!((built_for.major, built_for.minor), (host.major, host.minor), "{}", wasm.display()); + } +} + +// --- a later host, simulated ------------------------------------------------------------- + +struct Data { + wasi: WasiCtx, + table: ResourceTable, +} + +impl WasiView for Data { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { ctx: &mut self.wasi, table: &mut self.table } + } +} + +/// Distinct host types for the stubbed resources (a component may import several). +struct Stub; + +fn stub_resource(n: usize) -> ResourceType { + macro_rules! pick { ($($i:literal)*) => { match n { $($i => ResourceType::host::>(),)* _ => panic!("more than 16 resources") } } } + pick!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15) +} + +enum Host<'a> { + /// Defines everything the component imports, plus a function and an + /// interface the component doesn't know. + Superset, + /// Leaves out one function, so linking must fail. + Without(&'a str), +} + +/// A linker with WASI and every `openidea:plugin/*` interface `component` +/// imports, defined at `version` with functions that are never called. +fn host_at(engine: &Engine, component: &Component, version: &str, host: Host<'_>) -> Linker { + let mut linker = Linker::new(engine); + wasmtime_wasi::p2::add_to_linker_sync(&mut linker).unwrap(); + // The component's resource → its stub. A resource re-exported through + // `use` (`invocation` uses `editor.text-editor`) is the same type, so it + // must get the same stub. + let mut resources: Vec<(ResourceType, ResourceType)> = Vec::new(); + for (name, item) in component.component_type().imports(engine) { + let Some(rest) = name.strip_prefix("openidea:plugin/") else { continue }; + let iface = rest.split_once('@').map_or(rest, |(i, _)| i); + let ComponentItem::ComponentInstance(instance) = item.ty else { panic!("{name} is not an instance") }; + let mut defined = linker.instance(&format!("openidea:plugin/{iface}@{version}")).unwrap(); + for (export, item) in instance.exports(engine) { + match item.ty { + ComponentItem::ComponentFunc(_) if !matches!(host, Host::Without(f) if f == export) => { + defined.func_new(export, |_, _, _, _| Err(wasmtime::Error::msg("stub"))).unwrap(); + } + ComponentItem::Resource(ty) => { + let stub = match resources.iter().find(|(r, _)| *r == ty) { + Some((_, stub)) => *stub, + None => { + let stub = stub_resource(resources.len()); + resources.push((ty, stub)); + stub + } + }; + defined.resource(export, stub, |_, _| Ok(())).unwrap(); + } + _ => {} + } + } + if matches!(host, Host::Superset) { + defined.func_new("added-in-a-later-minor", |_, _, _, _| Ok(())).unwrap(); + } + } + if matches!(host, Host::Superset) { + let mut added = linker.instance(&format!("openidea:plugin/added-interface@{version}")).unwrap(); + added.func_new("call", |_, _, _, _| Ok(())).unwrap(); + } + linker +} + +fn engine() -> Engine { + let mut config = Config::new(); + config.wasm_component_model(true); + Engine::new(&config).unwrap() +} + +/// The next minor of the API this host implements. +fn next_minor() -> String { + let host = host_api(); + format!("{}.{}.0", host.major, host.minor + 1) +} + +#[test] +fn components_link_against_a_later_minor_that_adds_functions_and_interfaces() { + let engine = engine(); + let next = next_minor(); + for wasm in components() { + let component = Component::from_file(&engine, &wasm).unwrap(); + let linker = host_at(&engine, &component, &next, Host::Superset); + linker.instantiate_pre(&component).unwrap_or_else(|e| panic!("{}: {e:?}", wasm.display())); + + let exports: Vec = component + .component_type() + .exports(&engine) + .map(|(n, _)| n.to_owned()) + .filter(|n| n.starts_with("openidea:plugin/")) + .collect(); + assert!(!exports.is_empty(), "{} exports no plugin interface", wasm.display()); + for name in exports { + let iface = name.split_once('@').unwrap().0; + let newer = format!("{iface}@{next}"); + assert!( + component.get_export_index(None, &newer).is_some(), + "{}: a {next} host can't find export {name}", + wasm.display() + ); + } + } +} + +#[test] +fn components_do_not_link_against_another_major() { + let engine = engine(); + let next_major = format!("{}.0.0", host_api().major + 1); + let wasm = repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"); + let component = Component::from_file(&engine, &wasm).unwrap(); + let linker = host_at(&engine, &component, &next_major, Host::Superset); + assert!(linker.instantiate_pre(&component).is_err()); + let iface = component + .component_type() + .exports(&engine) + .map(|(n, _)| n.to_owned()) + .find(|n| n.starts_with("openidea:plugin/")) + .unwrap(); + let newer = format!("{}@{next_major}", iface.split_once('@').unwrap().0); + assert!(component.get_export_index(None, &newer).is_none()); +} + +#[test] +fn a_later_minor_that_drops_a_function_does_not_link() { + // Keeps the test above honest: the stub host really is type-checked. + let engine = engine(); + let wasm = repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"); + let component = Component::from_file(&engine, &wasm).unwrap(); + let (iface, func) = component + .component_type() + .imports(&engine) + .filter(|(n, _)| n.starts_with("openidea:plugin/")) + .find_map(|(n, item)| match item.ty { + ComponentItem::ComponentInstance(i) => i + .exports(&engine) + .find(|(_, it)| matches!(it.ty, ComponentItem::ComponentFunc(_))) + .map(|(f, _)| (n.to_owned(), f.to_owned())), + _ => None, + }) + .expect("hello imports a plugin function"); + let linker = host_at(&engine, &component, &next_minor(), Host::Without(&func)); + let err = linker.instantiate_pre(&component).err().unwrap_or_else(|| panic!("{iface} without {func} linked")); + assert!(format!("{err:?}").contains(&func), "{err:?}"); +} diff --git a/plugins/hello/Cargo.lock b/crates/ide-plugin/tests/fixtures/hello/Cargo.lock similarity index 98% rename from plugins/hello/Cargo.lock rename to crates/ide-plugin/tests/fixtures/hello/Cargo.lock index 2e617884..2ba582e7 100644 --- a/plugins/hello/Cargo.lock +++ b/crates/ide-plugin/tests/fixtures/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/crates/ide-plugin/tests/fixtures/hello/Cargo.toml similarity index 73% rename from plugins/hello/Cargo.toml rename to crates/ide-plugin/tests/fixtures/hello/Cargo.toml index 10ed4602..d4abeda5 100644 --- a/plugins/hello/Cargo.toml +++ b/crates/ide-plugin/tests/fixtures/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/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..b0c8caeecc4070cc047258c9eb6b267b6a9daf8d GIT binary patch literal 132268 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�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 Bq@e%+ literal 0 HcmV?d00001 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources new file mode 100644 index 00000000..2edfa822 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -0,0 +1 @@ +86dfe70a30ab1f7955a16e96c1b32c8be663456b871cc4d3bed7dc6052c062da diff --git a/crates/ide-plugin/tests/fixtures/hello/plugin.toml b/crates/ide-plugin/tests/fixtures/hello/plugin.toml new file mode 100644 index 00000000..6145272b --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/hello/plugin.toml @@ -0,0 +1,52 @@ +id = "hello" +name = "Hello" +version = "0.3.0" +description = "Sample plugin: inserts today's date, counts words in the active file and shows a tool window with a tree." +authors = ["OpenIDEA contributors"] +change-notes = """ +0.3.0 — a Greetings tool window (tree view), an editor context menu entry, a shortcut and a status bar item. +0.2.0 — plugin API 0.2: edits are versioned, one undo step for every cursor. +0.1.0 — first release: Insert Date and Word Count of Active File commands. +""" +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +# Loaded the first time one of its commands runs. +activation = ["on-command:*", "on-view:greetings"] + +[capabilities] +required = ["editor.read", "editor.write"] + +# Static declarations make the commands visible before the plugin activates; +# a command must be declared here to exist. +[[contributes.commands]] +id = "insert-date" +title = "Hello: Insert Date" +category = "Hello" + +[[contributes.commands]] +id = "word-count" +title = "Hello: Word Count of Active File" +category = "Hello" + +# Declarative UI: shown without loading the plugin's code. +[[contributes.menus]] +menu = "editor.context" +command = "word-count" + +[[contributes.keymaps]] +command = "insert-date" +key = "Ctrl+Alt+D" +mac = "Meta+Alt+D" + +[[contributes.statusBar]] +id = "hello" +text = "Hello" +tooltip = "Word count of the active file" +command = "word-count" + +# A tool window whose tree the plugin renders (view-provider). +[[contributes.views]] +id = "greetings" +title = "Greetings" +icon = "plugin" +location = "right" diff --git a/crates/ide-plugin/tests/fixtures/hello/src/lib.rs b/crates/ide-plugin/tests/fixtures/hello/src/lib.rs new file mode 100644 index 00000000..26f75410 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/hello/src/lib.rs @@ -0,0 +1,180 @@ +//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK. +//! Build with `openidea-plugin build plugins/hello` (see plugins/README.md). + +use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin}; +use std::collections::BTreeSet; + +#[derive(Default)] +struct Hello { + /// Expanded languages of the Greetings tree. + expanded: BTreeSet, +} + +/// The Greetings tree: languages, then their greetings (loaded when expanded). +const GREETINGS: &[(&str, &[&str])] = &[ + ("English", &["Hello", "Good morning", "Hi"]), + ("Deutsch", &["Hallo", "Guten Morgen", "Servus"]), + ("日本語", &["こんにちは", "おはよう"]), +]; + +impl Plugin for Hello { + fn activate(&mut self, ctx: &Context) -> Result<(), Error> { + host::log(LogLevel::Info, &format!("hello {} activated", ctx.plugin_version)); + Ok(()) + } + + fn deactivate(&mut self) { + host::log(LogLevel::Info, "hello deactivated"); + } + + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + let editor = ctx.editor().ok_or("no file is open in the editor")?; + match id { + "insert-date" => { + // Replaces every selection (inserts at every cursor), as one undo step. + let date = today(); + editor.transform_selections(|_| date.clone())?; + Ok(Some(date)) + } + "word-count" => { + let text = editor.document()?.text; + let path = editor.path(); + let words = text.split_whitespace().count(); + let lines = text.lines().count(); + let name = path.rsplit(['/', '\\']).next().unwrap_or(&path); + let body = format!("{words} words, {lines} lines, {} characters", text.chars().count()); + host::notify(NotificationLevel::Info, &format!("Word count: {name}"), &body); + Ok(Some(body)) + } + other => Err(Error::NotFound(format!("command {other}"))), + } + } +} + +impl Views for Hello { + fn render(&mut self, view: &str, _viewport: Option, _ctx: &Invocation) -> Result { + if view != "greetings" { + return Err(Error::NotFound(view.into())); + } + let mut items = Vec::new(); + for (language, words) in GREETINGS { + let open = self.expanded.contains(*language); + items.push( + TreeItem::new(language, language).icon("folder").detail(&words.len().to_string()).expandable(open), + ); + if open { + for w in *words { + items.push(TreeItem::new(&format!("{language}/{w}"), w).parent(language).icon("text")); + } + } + } + Ok(View::new(Body::Tree(items)) + .button(Button::new("collapse", "Collapse All").icon("collapse-all").enabled(!self.expanded.is_empty())) + .status(&format!("{} languages", GREETINGS.len()))) + } + + fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + match event { + ViewEvent::Expand(key) => { + self.expanded.insert(key); + } + ViewEvent::Collapse(key) => { + self.expanded.remove(&key); + } + ViewEvent::Button(b) if b == "collapse" => self.expanded.clear(), + ViewEvent::Activate(key) => { + if let Some((language, word)) = key.split_once('/') { + host::notify(NotificationLevel::Info, word, &format!("A greeting in {language}")); + } + return Ok(()); + } + _ => return Ok(()), + } + host::refresh_view(view); + Ok(()) + } +} + +register_plugin!(Hello, views); + +/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock). +fn today() -> String { + let secs = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + let (y, m, d) = civil_from_days(secs.div_euclid(86_400)); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Howard Hinnant's days-to-civil algorithm. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = yoe + era * 400 + i64::from(m <= 2); + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + #[test] + fn civil_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + } + + #[test] + fn word_count_reads_the_focused_editor() { + let host = MockHost::install(); + host.open_editor("/project/notes.txt", "text", "one two\nthree\n", &[]); + let out = Hello::default().run_command("word-count", &host.invocation(), None).unwrap(); + assert_eq!(out.as_deref(), Some("3 words, 2 lines, 14 characters")); + assert_eq!(host.notifications()[0].title, "Word count: notes.txt"); + } + + #[test] + fn insert_date_replaces_every_selection_at_once() { + let host = MockHost::install(); + host.open_editor("/project/a.txt", "text", "a\nXX b", &[(0, 1, 0, 1), (1, 0, 1, 2)]); + let date = Hello::default().run_command("insert-date", &host.invocation(), None).unwrap().unwrap(); + assert_eq!(host.editor_text(), format!("a{date}\n{date} b")); + assert_eq!(host.editor_version(), 2, "one change"); + } + + #[test] + fn the_greetings_tree_loads_children_when_expanded() { + let host = MockHost::install(); + let mut p = Hello::default(); + let tree = |p: &mut Hello| match p.render("greetings", None, &host.invocation()).unwrap().body { + Body::Tree(items) => items, + other => panic!("{other:?}"), + }; + assert_eq!(tree(&mut p).len(), 3); + p.handle("greetings", ViewEvent::Expand("Deutsch".into()), &host.invocation()).unwrap(); + let items = tree(&mut p); + assert_eq!(items.len(), 6); + assert_eq!((items[2].label.as_str(), items[2].parent.as_deref()), ("Hallo", Some("Deutsch"))); + assert_eq!(host.refreshed_views(), ["greetings"]); + p.handle("greetings", ViewEvent::Activate("Deutsch/Servus".into()), &host.invocation()).unwrap(); + assert_eq!(host.notifications()[0].title, "Servus"); + p.handle("greetings", ViewEvent::Button("collapse".into()), &host.invocation()).unwrap(); + assert_eq!(tree(&mut p).len(), 3); + } + + #[test] + fn without_editor_write_the_command_fails_cleanly() { + let host = MockHost::install().with_capabilities(&["editor.read"]); + host.open_editor("/project/a.txt", "text", "", &[(0, 0, 0, 0)]); + let err = Hello::default().run_command("insert-date", &host.invocation(), None).unwrap_err(); + assert_eq!(err, Error::Denied("editor.write".into())); + assert_eq!(host.editor_text(), ""); + } +} diff --git a/crates/ide-plugin/tests/fixtures/jobs/Cargo.lock b/crates/ide-plugin/tests/fixtures/jobs/Cargo.lock new file mode 100644 index 00000000..2c35dac7 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/jobs/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-jobs" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/jobs/Cargo.toml b/crates/ide-plugin/tests/fixtures/jobs/Cargo.toml new file mode 100644 index 00000000..78b77753 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/jobs/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-jobs" +version = "0.1.0" +edition = "2024" +publish = false + +# Test fixture for the plugin host: built by `plugins/build.sh `, checked in as dist/plugin.wasm. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..aaa324d9aaf21cffc2cb688491c99fbc83aba501 GIT binary patch literal 123922 zcmdqK4VYb5UGKlw-ut}FoHLW1G_8@Ovd>BInOqw>`qZSBddf`9$QR)*BjbTkiJEySX&py>q^?cl%{C zd*{>LJNEC3S~L5$U$%E<=aDpOUUtR8!v1|pkF@=@H8Zv_H#4(o_rlEONtDo~wKTGYG#cBxdwwC5ZMXyTZhzdG zcL&pSVdn6Hn~%mWzhdvg?!CMB&2ZD+v1?|>p3C+hc1NP-j$QkA@0fAzD2+NV-aWH- z=cYs3_g*oRL}P9)sdw41=i_PAuwqHnaR*v%uF=}DfA1BS@0)LpFYI5~zIW5y{wwFN z8hi2XIRN7Ns#Fgq+A}+MFW3-iz%G(#(j7?Mfu=jqbqAZR%V*~2x4%U2r5d=)qEv%& zE2H$q`{yoqt3o}uB1(5HT)tNYqx7=TDR|5*nMdGf`-tXpV>RJLzr*+8P$85+`Ik2yxw~px^+>iX5HFoeD}Wj znK{FlyDIAJr24{);nJNN)o~Mz3))RHhoGk<`o8gJxhUU%U}oR$oip3dIcGtW)*pQK^s}cMQS0r_ZEJvLh;HBRnVkXw?ChP1qOpDZ7dE|k{}uc6u(f+1 z)4W@kV>`C*6L2#-qp0~}2-Rvadoy!$`{$A<5}D1VW4_N#@WW)dJTx;mzkC0_`OfwO zyEm0L-5uKxY`+W=T-ZG`AEyyyvrohcZI9Tw1{R_Kh=F(_Fbg+NlruqF>%ZDOJcf~%}9q+s7)TN`K z;tm8%nBG=%+##L=>@RZL+UmYN=st2=T`D%3cSxDm+{}yTX6ARfSTsM2Tz6ZOFpt9^ zB~c0isPjz=Gjo^k-nV_PYmB6CYdnB6)>`AXB^u~fXCgPE)8r*H3(k$NchUDP9nAd9 z!ou!-FDXYe-g-VGK#m=dfI*EU&h4@hAcbD+G=fup=JJ_^UHf=ky{?qkn>-4jK=-EHg=rkw5T)XP3b)negN;=

8tUpBL;tVpNd@1mY{xw}i`{fBuPI!8(o@gX1s-FBX4#p?%-Hvi&R{V%J1?dPm113zU>FfGNHw9_gP)pem zPFfE7o^@A>vR$gF8jDb`y>hCCKzX+5>`hnBVGlSo@Bb7TQZe=w(Je^ReGBv5Xd;c) zj&8zNo*eW>(G8=E8g)Qon_j&43g~`?+ua^iV;IjIU9Fl_w{rX$E;_lK6pxGfFfA*a ztDvFIFs@ragn|;1RxO(a#K>+q4!TOCakzrnkr;8wppPAUcc1Q`hCWU@ZXc^ps1K%Y zL?1j2eVp7}sc{SuQENI3V;ObTM==&gv3nmKe6NdE2cow@?A{mJ|DM(cw=E5}2K-D5 zX|zIBXZ9W1J-2@!%4fmF>fdZi$$ReS}3l z3l+b&b&B!XWGTuv8M~lEC1I-`hu?;}&uD#*)m>4&4U*nVwB?ok>ri`b^CU34a!JRn z|LV3iMI!6cXvr&SS!iTWEG#967h$RD+BAeqj{&y54IQ7_TusNTmO|ay8hZXdCZK9I zjA%*MCoSz-q?(!>Uq0#~ntkSzbe=zr&PBt=>3l%UJ@;E5Z$@}JE@D9Mci(fVJK~O9 z8Xq|laTQ&f9^rq{r3ruiWBxYv%l@-mvS? z2lv%G^>_8vQ7E8hj<~BY?UYsdwc1znzbL+M>>0}};iZP%_x=oe-0ZSVJLmQv_}Rhx zC|+iQ)>L$90YvUel(DpS)H+^M!`4C6KEA0UlO`G)q1~oeFBu9`xqmK-mn-e*?^#l^ zv;m@cq=24YyQF+wNgk`tx+QfyZv;IW#mAKv)2A+}TydE79L3+aoN;R6V*2}zRoxeg z;$ut4>80W{iaoA}MB%ha%8|G|hG8|bteI9(Yc$fIT+Ml<8HUxz;vb)T8i|C|Itb#8Xq4=D|Nc#F->um>WBYh!{`q9 z8ZK>g!4BWop$Cp{@@Ik!4#HcmR}WSWZ`trY@koWb!+@fcWW=M*42 z;9l^8M&hEC&4=7GkGNuS@o3aJ5*45PRsU-@PETU}`SRO7W>0Q=yZ^mA{3d#7#U2SI z+IsR(cs;J0&xD)Bclf%KdT4*X5#IdUTYaaQ-rN`(x+xTYJQP}~myf^2w{i5{{&#Wl zK7;Fz?C)bA_3iv|)M-7IbeosYT)uzqsyI4r2O*gk9oRp=@S>$Y=8pZBUrtmq_?YXZ zhW?))I(G5ByI(xBLOa=7$Jg&3RvVJu!-%gr$DwDOMxA zN*mD&`zJ7tpZzaO3t5a~lka*7AU`*?wD2*9`l4m+9%q1;o>1X1tnJGRF9m|=Ux+?N z&nMKztm4w{#fy$!9*^Bc3;<&MAFqjTagW45^@6j1`kn6e?zi2&?zh~-?qB0ixIc1# z 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%0AFg&eCn;QVSNmnSbjNNxZ0Y67ac$z` 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 for +//! the activated one, titled and sized by the plugin's settings. + +use openidea_plugin::host; +use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Error, Invocation, Plugin, register_plugin}; + +pub const VIEW: &str = "jobs"; + +#[derive(Default)] +pub struct Jobs { + selected: Option, +} + +fn list() -> Result, Error> { + let out = host::run_process("jobs", &["list".to_owned()], None)?; + if out.status != Some(0) { + return Err(Error::Failed(out.stderr.trim().to_owned())); + } + Ok(out.stdout.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_owned).collect()) +} + +impl Jobs { + fn follow(&self) -> Result<(), Error> { + let job = self.selected.as_deref().ok_or_else(|| Error::Invalid("select a job first".into()))?; + let title = host::setting_string("title").unwrap_or_else(|| "follow: ".into()); + let lines = host::setting_number("lines").map_or(20, |n| n.max(0.0) as u64); + let args = ["follow".to_owned(), job.to_owned(), format!("--lines={lines}")]; + host::open_terminal(&format!("{title}{job}"), "jobs", &args) + } +} + +impl Plugin for Jobs { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + match id { + "follow" => self.follow().map(|()| None), + _ => Err(Error::NotFound(format!("command {id}"))), + } + } +} + +impl Views for Jobs { + fn render(&mut self, view: &str, _viewport: Option, _ctx: &Invocation) -> Result { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + let follow = Button::new("follow", "Follow").icon("terminal").enabled(self.selected.is_some()); + Ok(match list() { + Ok(jobs) => View::new(Body::Tree(jobs.iter().map(|j| TreeItem::new(j, j).icon("run")).collect())), + Err(e) => View::message(&format!("jobs: {e}")), + } + .button(follow)) + } + + fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + match event { + ViewEvent::Select(key) => self.selected = Some(key), + ViewEvent::Activate(key) => { + self.selected = Some(key); + return self.follow(); + } + ViewEvent::Button(b) if b == "follow" => return self.follow(), + _ => return Ok(()), + } + host::refresh_view(VIEW); + Ok(()) + } +} + +register_plugin!(Jobs, views); diff --git a/plugins/language-toy/languages/toy.toml b/crates/ide-plugin/tests/fixtures/language-toy/languages/toy.toml similarity index 100% rename from plugins/language-toy/languages/toy.toml rename to crates/ide-plugin/tests/fixtures/language-toy/languages/toy.toml diff --git a/plugins/language-toy/plugin.toml b/crates/ide-plugin/tests/fixtures/language-toy/plugin.toml similarity index 100% rename from plugins/language-toy/plugin.toml rename to crates/ide-plugin/tests/fixtures/language-toy/plugin.toml diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index dfc65c7a8d3d79af90ef9417d02010a6c83f968d..d6018357a9baf8dc67ee202600c21e69cd5da317 100644 GIT binary patch literal 87772 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 OMgRY>jBoi@CjK8HSj@=) literal 47332 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 ZZrg}M^#5D;vRv%h^xxUKKk1#8{|64Hy1W1Y diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources new file mode 100644 index 00000000..1c5ed64f --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -0,0 +1 @@ +0ad2e4c6977a576b7eb27fc1e535fd056dd9da64bdfe6cfda9ca9f57537d0384 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml index daf0dbdd..29dba7d6 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml @@ -1,7 +1,42 @@ id = "misbehave" name = "Misbehave" -version = "0.0.1" -description = "Test fixture: traps, spins, allocates, probes permissions." -openidea-api = "0.1" +version = "0.0.2" +description = "Test fixture: traps, spins, allocates, probes capabilities." +engines.openidea = "^1.0" wasm = "dist/plugin.wasm" -permissions = ["project-read"] +activation = ["on-command:*"] + +[capabilities] +required = ["project.read"] + +[[contributes.commands]] +id = "echo" +title = "echo" + +[[contributes.commands]] +id = "panic" +title = "panic" + +[[contributes.commands]] +id = "spin" +title = "spin" + +[[contributes.commands]] +id = "alloc" +title = "alloc" + +[[contributes.commands]] +id = "read" +title = "read" + +[[contributes.commands]] +id = "root" +title = "root" + +[[contributes.commands]] +id = "insert" +title = "insert" + +[[contributes.commands]] +id = "cancel" +title = "cancel" diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 8f4ad071..cd6d6511 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -1,25 +1,44 @@ //! Test fixture for the ide-plugin sandbox. Never shipped. +// `plugin` with syntax and editor providers. wit_bindgen::generate!({ - world: "plugin", + inline: " + package fixture:misbehave; + world misbehave { + include openidea:plugin/plugin-syntax@1.0.0; + export openidea:plugin/providers@1.0.0; + } + ", path: "../../../../../wit", + world: "fixture:misbehave/misbehave", + generate_all, }); -use openidea::plugin::host; +use exports::openidea::plugin::providers::{self, CodeAction, CompletionItem, Document, InlineSuggestion, LineMarker}; +use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token}; +use exports::openidea::plugin::{commands, event_handler, lifecycle}; +use openidea::plugin::{invocation::Invocation, types}; struct Misbehave; -impl Guest for Misbehave { - fn activate(_ctx: Context) -> Result<(), String> { - for id in ["echo", "panic", "spin", "alloc", "read", "root", "insert", "language", "theme"] { - host::register_command(id, id, None); +fn failed(message: &str) -> types::Error { + types::Error::Failed(message.into()) +} + +impl lifecycle::Guest for Misbehave { + fn activate(ctx: types::Context) -> Result<(), types::Error> { + // A deliberately broken build (rollback tests): `version = "…-broken"`. + if ctx.plugin_version.ends_with("-broken") { + return Err(failed("this build is broken")); } Ok(()) } fn deactivate() {} +} - fn run_command(id: String, arg: Option) -> Result, String> { +impl commands::Guest for Misbehave { + fn run(id: String, ctx: &Invocation, arg: Option) -> Result, types::Error> { match id.as_str() { "echo" => Ok(arg), "panic" => panic!("boom"), @@ -36,18 +55,90 @@ impl Guest for Misbehave { std::hint::black_box(&chunks); } } - "read" => host::read_file(&arg.unwrap_or_default()).map(Some), - "root" => Ok(host::workspace_root()), - // Not permitted by the manifest: traps. - "insert" => { - host::insert_text("x"); - Ok(None) + "read" => { + let project = ctx.project().ok_or_else(|| failed("no project is open"))?; + project.read_file(&arg.unwrap_or_default()).map(Some) } - "language" => host::register_language(&arg.unwrap_or_default()).map(|_| None), - "theme" => host::register_theme(&arg.unwrap_or_default()).map(|_| None), - other => Err(format!("unknown command: {other}")), + "root" => ctx.project().map(|p| p.root()).transpose(), + // `editor.write` is not granted: an error, not a trap. + "insert" => { + let editor = ctx.editor().ok_or_else(|| failed("no editor"))?; + editor.apply_edits(0, &[]).map(|_| None) + } + // The user gave up: not the plugin failing. + "cancel" => Err(types::Error::Cancelled), + other => Err(types::Error::NotFound(format!("command {other}"))), } } } +impl event_handler::Guest for Misbehave { + fn handle(_events: Vec, _ctx: &Invocation) {} +} + +/// Edit-path budgets: the text says how to behave. `sleep:` busy-waits +/// that long, `spin` never returns, `panic` traps. +fn behave(text: &str) { + if let Some(ms) = text.strip_prefix("sleep:").and_then(|n| n.trim().parse::().ok()) { + let until = std::time::Instant::now() + std::time::Duration::from_millis(ms); + while std::time::Instant::now() < until { + std::hint::black_box(0); + } + } else if text == "spin" { + loop { + std::hint::black_box(0); + } + } else if text == "panic" { + panic!("boom"); + } +} + +/// One keyword token over the whole text, after [`behave`]. +impl syntax_provider::Guest for Misbehave { + fn highlights(_language: String, text: String) -> Vec { + behave(&text); + 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() + } +} + +/// Completion: one item, labelled with the document's path, after [`behave`]. +impl providers::Guest for Misbehave { + fn completion(doc: Document, _at: types::Position, _trigger: Option) -> Vec { + behave(&doc.text); + vec![CompletionItem { + label: doc.path.clone(), + detail: None, + kind: Some("keyword".into()), + insert_text: doc.path, + replace: None, + sort_text: None, + documentation: None, + }] + } + + fn inline_completion(_doc: Document, _at: types::Position) -> Option { + None + } + + fn code_actions(_doc: Document, _selection: types::Range) -> Vec { + Vec::new() + } + + fn definition(_doc: Document, _at: types::Position) -> Vec { + Vec::new() + } + + fn line_markers(_doc: Document) -> 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 00000000..a82021db --- /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 00000000..1cbb372c --- /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 00000000..41c48f07 --- /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 00000000..18ed6f7b --- /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 00000000..f12996fa --- /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 00000000..2c81ea01 --- /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 00000000..b1ff2c41 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/p0-probe/wit/probe.wit @@ -0,0 +1,59 @@ +/// Runtime probe: optional exports, async calls and +/// cancellation, and a WASI p2 task model built on `wasi:io/poll`. +/// Test fixture only; not part of the plugin API. +package openidea:probe@0.1.0; + +interface host-api { + use wasi:io/poll@0.2.0.{pollable}; + + /// A long-running job the host started for the guest. + resource task { + /// Progress report (`total` unknown while `none`). + report: func(done: u32, total: option); + /// 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 2c2572c0..00000000 --- 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 00000000..4e9e0bdf --- /dev/null +++ b/crates/ide-plugin/tests/p0_probe.rs @@ -0,0 +1,353 @@ +//! Runtime experiments, kept as regression tests: they pin the +//! wasmtime behaviour the plugin runtime is designed around. +//! +//! Components: `tests/fixtures/p0-probe/dist/{min,full}.wasm`, rebuilt with +//! `tests/fixtures/p0-probe/build.sh`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; +use wasmtime::component::{Component, HasSelf, Linker, Resource, ResourceTable}; +use wasmtime::{Config, Engine, Store}; +use wasmtime_wasi::p2::{DynPollable, Pollable, subscribe}; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; + +mod bindings { + wasmtime::component::bindgen!({ + world: "probe-host", + path: "tests/fixtures/p0-probe/wit", + imports: { + "openidea:probe/host-api.sleep-ms": async | trappable, + default: trappable, + }, + exports: { default: async }, + with: { + "wasi:io/poll": wasmtime_wasi::p2::bindings::io::poll, + "openidea:probe/host-api.task": super::Task, + }, + }); +} + +use bindings::openidea::probe::host_api; + +/// Host side of the `task` resource: a queue the host pushes into. +pub struct Task { + queue: Arc>>, + 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(); + // How often depends on how busy the machine is; that it ran at all shows the guest yielded. + assert!(ticks.load(Ordering::Relaxed) > 0, "the executor kept running other tasks while the guest spun"); +} + +/// P0-2b: dropping the call future cancels the call — and poisons the instance: +/// the next call fails with `CannotEnterComponent`. A fresh instance from the +/// same `InstancePre` is cheap (no compilation) and starts with fresh state. +#[tokio::test] +async fn cancelled_instance_cannot_be_reused_but_reinstantiates_fast() { + let env = env(); + let pre = env.linker.instantiate_pre(&Component::from_file(&env.engine, dist("min.wasm")).unwrap()).unwrap(); + let idx = bindings::exports::openidea::probe::base::GuestIndices::new(&pre).unwrap(); + let mut s = store(&env, TaskHandle::default()); + let inst = pre.instantiate_async(&mut s).await.unwrap(); + let base = idx.load(&mut s, &inst).unwrap(); + assert_eq!(base.call_run(&mut s, "count", "").await.unwrap().unwrap(), "1"); + + // Cancel while the guest computes. + let _ = tokio::time::timeout(Duration::from_millis(50), base.call_run(&mut s, "spin", "")).await; + let err = base.call_run(&mut s, "echo", "x").await.unwrap_err(); + assert_eq!(err.downcast_ref::(), 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 24cf12f6..c9195b40 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 00000000..340a714c --- /dev/null +++ b/crates/ide-run/src/external.rs @@ -0,0 +1,93 @@ +//! Run configurations detected outside this crate: plugins' `runDetectors`. +//! Their ids start with [`EXTERNAL_PREFIX`]; before +//! one is started the provider names the program to run (the file approved +//! for the plugin's `process:` capability) or refuses. + +use crate::RunConfig; +use parking_lot::RwLock; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Id prefix of externally detected configurations (`plugin::`). +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 1ca1a0a1..7e85a164 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 ce900eca..2899c026 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 3c1eb64f..600d8dd6 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 167efed1..3117fdda 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 dbcee862..7bb74199 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/plugins/themes.md b/docs/plugins/themes.md index 013041bf..b11082a3 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -17,7 +17,8 @@ my-theme/ └─ my-theme.scheme.json # editor colours, optional (IntelliJ: .icls) ``` -The quickest way to start is to copy [`templates/theme-plugin`](../../templates/theme-plugin). +The quickest way to start is `openidea-plugin new --template theme`, or a copy of +[`plugins/official/harbor-night`](../../plugins/official/harbor-night). It contains *Harbor Night*, a complete example dark theme that sets every themeable token. The rest of this page covers the file format, the token reference, porting themes from IntelliJ IDEA and VS Code, and testing. @@ -106,11 +107,13 @@ built-in theme returns. There is no build step. The package *is* the folder. -1. Copy `templates/theme-plugin` and rename `id`, `name` and the theme files. +1. Run `openidea-plugin new --template theme` (or copy + `plugins/official/harbor-night`) and rename `id`, `name` and the theme files. 2. Edit the colours. Keep `kind` truthful (`light` for light backgrounds). 3. **Install:** *Settings › Plugins › ⚙ › Install Plugin from Disk…* (or the - *Install Plugin from Disk…* action) and pick the package's `plugin.toml`, or - a `.zip` of the folder. The manifest and the files it references are copied to + *Install Plugin from Disk…* action) and pick the package folder, or + *Install Plugin from Package File…* and pick its `plugin.toml` or a `.zip` of + the folder. The manifest and the files it references are copied to `/plugins//`. 4. **Apply:** in *Settings › Plugins*, select the plugin and press *Apply* next to the theme. *Use Default Theme* switches back. diff --git a/knip.ts b/knip.ts index cce08398..600d5224 100644 --- a/knip.ts +++ b/knip.ts @@ -10,6 +10,8 @@ const config: KnipConfig = { "perf/**/*.ts", "e2e/**/*.ts", ], + // Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI). + ignore: ["plugins/**/web-src/**"], ignoreExportsUsedInFile: true, // lint-staged formats Rust files with the toolchain's rustfmt. ignoreBinaries: ["rustfmt"], diff --git a/perf/budgets.json b/perf/budgets.json index 25b40729..f831d10c 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 plugin runtime budgets).", "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 a79889f5..34538396 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 fd1a745b..4e994f5e 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,87 +1,210 @@ # 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. + +> **Preview.** The plugin system ships as a preview: until it is declared +> stable the API (`openidea:plugin@1.0`) may still change, and the official +> plugins are versioned `0.x`. See _Evolving the plugin API_ below. ``` plugins/ -├─ build.sh # builds Rust plugins into components -├─ hello/ # Rust plugin: two commands -└─ language-toy/ # manifest-only: a language definition +└─ official/ + ├─ harbor-night/ # data only: a colour theme (UI and editor scheme) + ├─ 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 + ├─ csv-editor/ # custom editor sharing the text document + ├─ excalidraw/ # web view custom editor, file icons, a template + ├─ graphviz/ # web view with code: live preview, problems from the page, a language + ├─ regex-tester/ # html view talking to its code, settings, the editor's selection + └─ license-header/ # file-opened events, quick fix, project scan in a task +sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host) +crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave ``` -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`). -## 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`), `web?` (see _Web views_) | without `web` | +| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` | +| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no | +| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no | -## 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` | +| `clipboard` | medium | the clipboard API in the plugin's web views (keyboard copy / paste work without it) | + +`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,62 +216,298 @@ panic = "abort" `src/lib.rs`: ```rust -wit_bindgen::generate!({ world: "plugin", path: "path/to/openidea/wit" }); +use openidea_plugin::{Error, 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() { - "shout" => { - host::replace_selection(&arg.unwrap_or_default().to_uppercase()); +impl Plugin for WordTools { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + 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) } - _ => Err(format!("unknown command {id}")), + // Every selection (a cursor: empty) in one undoable change. + "shout" => { + editor.transform_selections(|s| s.to_uppercase())?; + Ok(None) + } + _ => Err(Error::NotFound(format!("command {id}"))), } } } -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 -`run-command` receives an optional string argument: `executeAction("plugin.hello.insert-date", "arg")` +| Rust SDK | WIT | Notes | +| ------------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Plugin::run_command(id, ctx, arg)` | export `commands.run` | `ctx` is the window: `ctx.project()`, `ctx.editor()`. An error shows _" failed: …"_; the plugin keeps running. `"msg".into()` is `Error::Failed`; `Cancelled` and `Denied` don't count as the plugin failing. | +| `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. | +| `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.<plugin id>.<command id>", "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). A completion item's +`kind` (`CompletionKind` in the SDK, a string in the WIT) picks its icon. +Editor providers run under the edit-path budget: 50 ms, after which the +result is dropped, and an interruption at 500 ms. Every plugin providing a +kind is asked at the same time, so each has the whole budget. + +Providers run in the plugin's one instance, with its commands, events and +views: they see the same memory (Checkstyle answers quick fixes from the +results of its last check) but also wait behind them. Keep commands and event +handlers short where you can; a provider call queued behind a long one is +dropped at 50 ms like a slow one. 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. For a +tool window `ctx.editor()` is the window's active text editor, as for a +command, so a view's buttons can act on what the user is editing (a click in +the tool window doesn't change which editor that is). + +`layout` puts a custom editor beside the text editor instead of in a tab of +its own: with `"split"` or `"preview"` the file opens in the text editor, +with your editor next to it (the layout files start in) and the _Editor Only / +Editor and … / … Only_ switch in the tab strip, as for Markdown. `"tab"` +(the default) is the editor's own tab. + +`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`). + +The frame follows the IDE theme without reloading: its root carries +`data-theme="light|dark"` and these CSS variables, with concrete colour +values (the body already uses the text colour, UI font and size): + +| Variable | Meaning | +| ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `--openidea-bg`, `--openidea-bg-elevated`, `--openidea-bg-hover`, `--openidea-bg-selected`, `--openidea-bg-input` | Surfaces (the frame itself is transparent) | +| `--openidea-fg`, `--openidea-fg-strong`, `--openidea-fg-muted` | Text | +| `--openidea-border`, `--openidea-focus-ring`, `--openidea-link` | Lines, focus, links | +| `--openidea-accent`, `--openidea-fg-on-accent` | Accent and text on it | +| `--openidea-danger`, `--openidea-warning`, `--openidea-success`, `--openidea-info` | Status colours | +| `--openidea-font-ui`, `--openidea-font-mono`, `--openidea-font-size` | Fonts (web fonts the IDE bundles are not available in the frame) | + +Scripts get the same as `openidea.theme` (`{ dark, vars }`) and +`openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only +ever added. + +#### Web views + +A view or custom editor can instead be a web page of the package, for UIs the +declarative bodies can't express (a drawing canvas, a diagram editor) — no +code needed: + +```toml +[[contributes.customEditors]] +id = "canvas" +title = "Drawing" +patterns = ["*.excalidraw"] +layout = "preview" +web = "web/index.html" # the page; its directory is what it can load +``` + +The page's directory (never the package root) is part of the declaration: +every file under it is checked (at most 4096 files and 256 MiB, no links), +installed and signed with the package. The IDE loads the page from its own +`openidea-plugin` URL scheme into an `<iframe sandbox="allow-scripts">`: an +opaque origin, no network, no cookies or storage, no IDE API, and only the +page's own files (relative URLs, ES modules, fonts, WebAssembly) — so build +it with relative paths (Vite: `base: "./"`). The same `openidea` object as +in `html` bodies is there before the page's scripts run, plus: + +| `window.openidea` | What it does | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `document.get()` | `{ version, text, readOnly, name }` of the custom editor's document (`name`: the file's, without its directory; none in a tool window) | +| `document.edit(version, edits)` | Applies `[{ from, to, insert }]` (UTF-16 offsets) as one change of the document's undo history; rejects with `stale` when not at `version`; resolves `{ version }` | +| `document.replace(version, text)` | The same with the whole text (sent as the smallest edit) | +| `document.onChange(fn)` | `{ version, text }` after changes made elsewhere: the text editor, Undo, another window | +| `saveFile({ name, data, filters? })` | The IDE's save dialog; `data` is a string, bytes or a Blob; the chosen path, or `null`. The page never names a path itself | +| `openFile({ filters? })` | The IDE's open dialog; `{ name, data }` (bytes), or `null` | +| `postMessage` / `onMessage` | To and from the plugin's code, when it has some | + +Save, the dirty mark and Undo are the document's, as for every custom editor. +IDE shortcuts the page doesn't handle itself (Save, Close, Find Action…) still +work while it has focus; editing keys (⌘C, ⌘V, ⌘Z…) stay with the page. With +the `clipboard` capability the page may use `navigator.clipboard`. +`plugins/official/excalidraw` is a complete example: a Vite + React page in +`web-src/`, built into `web/` by `plugins/build.sh`. `plugins/official/graphviz` +pairs a page with code: the page renders (Graphviz compiled to WebAssembly) and +sends what Graphviz reports to the plugin, which publishes it as problems. The built page is not +checked in (it is tens of MB): run `plugins/build.sh official/excalidraw` +before installing the plugin from the repository. + +### 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 +516,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 +550,227 @@ 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 a copy of +[`official/harbor-night`](official/harbor-night)); 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); [`official/solidity`](official/solidity) is a +complete example. + +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`, a `.zip` with + the package at its root or in its only folder, or a folder's `plugin.toml`; + a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused). 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 -plugins/build.sh crates/ide-plugin/tests/fixtures/misbehave -cargo test -p ide-plugin +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 components of the official plugins and of the test fixtures +(`crates/ide-plugin/tests/fixtures/`). +Rebuild them after changing a plugin, the SDK or the WIT files: + +```sh +plugins/build.sh # all of them +plugins/build.sh official/solidity # one +plugins/build.sh --check # what CI runs: nothing stale, everything builds +cargo test -p ide-host --test plugins +``` + +Next to each `dist/plugin.wasm`, `dist/plugin.wasm.sources` fingerprints the +sources it was built from (the plugin, the SDK and `wit/`); builds are not +byte-reproducible across machines, so `--check` compares fingerprints. + +The SDK carries a copy of `wit/` in `sdk/rust/openidea-plugin/wit`; keep them +identical (a test in `crates/ide-plugin/tests/api_compat.rs` checks). Changing +the API itself follows _Evolving the plugin API_ below. + +## 10. Evolving the plugin API + +While the plugin system is a preview the API may still change freely. Once it +is declared stable wasmtime decides what stays compatible: it resolves interface names +by semver (a component importing `openidea:plugin/x@1.0.0` links against a +host defining `@1.1.0`, and the host finds its exports the same way), but +function types must match exactly — a record with another field count, or a +variant or enum with another case count, doesn't link. So a released 1.x +only grows: + +1. **Released types and signatures never change**, whichever way the data + flows: no new record fields, variant or enum cases, renames or parameters. + Extend with new types and new functions (or interfaces). `types.error` is + used everywhere and is no exception; new situations use `failed(string)` + or `invalid(string)`. Sets that keep growing (completion, token, symbol and + fold kinds, icons) are strings for this reason. +2. **Host interfaces (imports) may gain functions and interfaces**: older + components don't import them. +3. **Plugin interfaces (exports) never gain functions** once released: the + host would not find them in older components. New capabilities get new + interfaces (e.g. a `hover-provider`) in new optional worlds. +4. **The host knows each component's API version** (the highest version of + its `openidea:plugin/*` imports and exports, `PluginEngine::check_component`). + Installing and `openidea-plugin check` refuse another major or a newer + minor with a message that says to update the IDE or rebuild with the SDK. +5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and + `API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`. + +Checked automatically: + +- SDK `tests/api_frozen.rs`: `tests/api/<version>/` holds the WIT of each + released version; every interface, type, function and world import or + export in it must still exist unchanged in `wit/` (rules 1–3). Copy `wit/` + there when releasing; there is no snapshot before the first release. +- `crates/ide-plugin/tests/api_compat.rs`: the versions agree, the SDK's copy + matches, the version check (rules 4, 5); a simulated next-minor host (every + import stubbed at the newer version, plus extra functions and an extra + interface) links every shipped component and finds its exports; another + major doesn't link; a host missing one function doesn't link. + +A new host function touches: + +| Where | Checked by | +| --------------------------------------------------------- | ---------------------------------------------- | +| `wit/*.wit`, copied to `sdk/rust/openidea-plugin/wit` | `api_compat.rs::the_sdk_carries_the_hosts_wit` | +| `crates/ide-plugin/src/wit_host.rs`: binding, capability | the compiler (bindgen's traits) | +| `crates/ide-plugin/src/runtime.rs`: `HostApi` method | the compiler | +| `crates/ide-host/src/plugins/api.rs`: implementation | the compiler (desktop and `openidea-server`) | +| SDK `src/host.rs`: the wasm and the mock `imp` | the compiler (CI builds both targets) | +| SDK public wrapper | SDK `tests/wit_coverage.rs` | +| Capability: `ide-extension/src/capability.rs`, consent UI | review; a test that a missing grant is denied | +| The API table above | review | + +Then run `plugins/build.sh`; CI's `plugins` job runs `plugins/build.sh --check` +plus fmt, clippy (host and wasm) and the tests of the SDK, the official +plugins and the fixtures. diff --git a/plugins/build.sh b/plugins/build.sh index 2b30f45c..c8aeb382 100755 --- a/plugins/build.sh +++ b/plugins/build.sh @@ -1,30 +1,139 @@ #!/usr/bin/env bash -# Builds Rust plugins into WebAssembly components (wasm32-wasip2 emits a -# component directly; no cargo-component / wasm-tools needed). +# Builds the repository's plugins: Rust code into WebAssembly components +# (wasm32-wasip2 emits a component directly; no cargo-component / wasm-tools), +# and web pages (`web-src/`, a Vite project) into `web/`. # -# plugins/build.sh # all plugins with a Cargo.toml -# plugins/build.sh hello # one plugin +# plugins/build.sh # every plugin with code or a web page +# plugins/build.sh official/csv-editor # one plugin (a path under plugins/, or any path) +# plugins/build.sh --check [...] # change nothing tracked; fail if a checked-in +# # component is stale or a plugin doesn't build (CI) # -# Output: <plugin>/dist/plugin.wasm (referenced by `wasm = ...` in plugin.toml). +# Output: <plugin>/dist/plugin.wasm (referenced by `wasm = ...` in plugin.toml) +# and <plugin>/dist/plugin.wasm.sources, a fingerprint of the sources it was +# built from: the plugin (minus target/, dist/, web/ and web-src/), the SDK +# and wit/. Components are small and checked in; builds are not +# byte-reproducible across machines, so --check compares fingerprints, not +# bytes. A web page (web-src/, a pnpm project) is built into <plugin>/web/, +# which is not checked in (it can be tens of MB): build it before installing +# the plugin from the repository. web.sources (not checked in either) skips +# rebuilding an unchanged page; --check builds it too and checks the result. +# CARGO_TARGET_DIR, when set, is shared by all plugins. set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/.." && pwd)" rustup target list --installed | grep -qx wasm32-wasip2 || rustup target add wasm32-wasip2 +check=0 +if [ "${1:-}" = "--check" ]; then + check=1 + shift +fi + +sha256() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; } + +# Lists "<path> <sha256>" for the files git sees (tracked, or new and not +# ignored) under the given paths of directory $1, minus build output and Finder +# metadata; so a +# checkout fingerprints the same as the working tree that committed it. +list() { + local dir="$1" + shift + (cd "$dir" && git ls-files --cached --others --exclude-standard -- "$@") | + { grep -Ev '(^|/)(dist|target|node_modules)/|(^|/)\.DS_Store$|^web(-src)?/|^web\.sources$' || true; } | LC_ALL=C sort -u | while read -r f; do + if [ -f "$dir/$f" ]; then printf '%s %s\n' "$f" "$(sha256 <"$dir/$f" | cut -d' ' -f1)"; fi + done +} + +# Prints the fingerprint of everything plugin $1's component is built from. +fingerprint() { + { list "$1" .; list "$repo" sdk/rust/openidea-plugin/Cargo.toml sdk/rust/openidea-plugin/src sdk/rust/openidea-plugin/wit wit; } | + sha256 | cut -d' ' -f1 +} + +# Prints the fingerprint of plugin $1's web page sources. +web_fingerprint() { + (cd "$1/web-src" && git ls-files --cached --others --exclude-standard -- .) | + { grep -Ev '(^|/)node_modules/|(^|/)\.DS_Store$' || true; } | LC_ALL=C sort -u | while read -r f; do + if [ -f "$1/web-src/$f" ]; then printf '%s %s\n' "$f" "$(sha256 <"$1/web-src/$f" | cut -d' ' -f1)"; fi + done | sha256 | cut -d' ' -f1 +} + +# Builds plugin $1's web page into web/ unless it was built from the current +# web-src/, then checks what the manifest's `web = "…"` entries need: each +# page is there and loads its files by relative URL (it is served from the package). +build_web() { + local dir="$1" rel="${1#"$repo"/}" want entry + want="$(web_fingerprint "$dir")" + if [ "$want" != "$(cat "$dir/web.sources" 2>/dev/null || true)" ] || [ ! -d "$dir/web" ]; then + echo "building the web page of $rel" + if ! { pnpm --dir "$dir/web-src" install --frozen-lockfile --ignore-workspace --silent && + pnpm --dir "$dir/web-src" run build; }; then + echo "FAIL $rel: the web page does not build" + failed=1 + return + fi + echo "$want" >"$dir/web.sources" + fi + for entry in $(sed -n 's/^web *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | sort -u); do + if [ ! -f "$dir/$entry" ]; then + echo "FAIL $rel: $entry was not built" + failed=1 + elif grep -Eq '(src|href)="/' "$dir/$entry"; then + echo "FAIL $rel: $entry loads files by absolute URL; build it with relative paths (Vite: base: \"./\")" + failed=1 + fi + done + echo "ok $rel (web, $(du -sh "$dir/web" | cut -f1))" +} + +failed=0 build() { local dir="$1" - local name + if [ -f "$dir/web-src/package.json" ]; then build_web "$dir"; fi + [ -f "$dir/Cargo.toml" ] || return 0 + local name target out name="$(sed -n 's/^name *= *"\(.*\)"/\1/p' "$dir/Cargo.toml" | head -1)" - echo "building $name ($dir)" - CARGO_TARGET_DIR="$dir/target" cargo build --manifest-path "$dir/Cargo.toml" --release --target wasm32-wasip2 + target="${CARGO_TARGET_DIR:-$dir/target}" + out="$target/wasm32-wasip2/release/${name//-/_}.wasm" + local rel="${dir#"$repo"/}" + if [ "$check" = 1 ]; then + if ! CARGO_TARGET_DIR="$target" cargo build -q --locked --manifest-path "$dir/Cargo.toml" --release --target wasm32-wasip2; then + echo "FAIL $rel: does not build" + failed=1 + return + fi + local want have + want="$(fingerprint "$dir")" + have="$(cat "$dir/dist/plugin.wasm.sources" 2>/dev/null || true)" + if [ "$want" != "$have" ]; then + echo "STALE $rel: dist/plugin.wasm was not built from the current sources; run plugins/build.sh $rel" + failed=1 + else + echo "ok $rel" + fi + return + fi + echo "building $name ($rel)" + CARGO_TARGET_DIR="$target" cargo build --manifest-path "$dir/Cargo.toml" --release --target wasm32-wasip2 mkdir -p "$dir/dist" - cp "$dir/target/wasm32-wasip2/release/${name//-/_}.wasm" "$dir/dist/plugin.wasm" - echo " -> $dir/dist/plugin.wasm ($(wc -c < "$dir/dist/plugin.wasm" | tr -d " ") bytes)" + cp "$out" "$dir/dist/plugin.wasm" + fingerprint "$dir" >"$dir/dist/plugin.wasm.sources" + echo " -> $rel/dist/plugin.wasm ($(wc -c <"$dir/dist/plugin.wasm" | tr -d " ") bytes)" } if [ $# -gt 0 ]; then for p in "$@"; do - case "$p" in /*) build "$p" ;; *) build "$here/$p" ;; esac + if [ -d "$here/$p" ]; then build "$here/$p" + elif [ -d "$repo/$p" ]; then build "$(cd "$repo/$p" && pwd)" + elif [ -d "$p" ]; then build "$(cd "$p" && pwd)" + else echo "no plugin at $p" >&2; exit 2 + fi done else - for manifest in "$here"/*/Cargo.toml; do build "$(dirname "$manifest")"; done + # Every component (plugin.toml + Cargo.toml) and web page (plugin.toml + web-src/). + for manifest in "$here"/official/*/plugin.toml "$repo"/crates/ide-plugin/tests/fixtures/*/plugin.toml; do + dir="$(dirname "$manifest")" + if [ -f "$dir/Cargo.toml" ] || [ -f "$dir/web-src/package.json" ]; then build "$dir"; fi + done fi +exit "$failed" diff --git a/plugins/hello/dist/plugin.wasm b/plugins/hello/dist/plugin.wasm deleted file mode 100644 index 3cc43a85126ebc7d7e8f5fff4cba581231343d82..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70241 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 diff --git a/plugins/hello/plugin.toml b/plugins/hello/plugin.toml deleted file mode 100644 index 458abb4b..00000000 --- a/plugins/hello/plugin.toml +++ /dev/null @@ -1,23 +0,0 @@ -id = "hello" -name = "Hello" -version = "0.1.0" -description = "Sample plugin: inserts today's date and counts words in the active file." -authors = ["OpenIDEA contributors"] -change-notes = """ -0.1.0 — first release: Insert Date and Word Count of Active File commands. -""" -openidea-api = "0.1" -wasm = "dist/plugin.wasm" -permissions = ["project-read", "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`. -[[contributes.commands]] -id = "insert-date" -title = "Hello: Insert Date" -category = "Hello" - -[[contributes.commands]] -id = "word-count" -title = "Hello: Word Count of Active File" -category = "Hello" diff --git a/plugins/hello/src/lib.rs b/plugins/hello/src/lib.rs deleted file mode 100644 index e1009fd7..00000000 --- a/plugins/hello/src/lib.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Sample OpenIDEA plugin. Build with `../build.sh hello` (see plugins/README.md). - -wit_bindgen::generate!({ - world: "plugin", - path: "../../wit", -}); - -use openidea::plugin::host::{self, LogLevel, NotificationLevel}; - -struct Hello; - -impl Guest for Hello { - fn activate(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() { - host::log(LogLevel::Info, "hello deactivated"); - } - - fn run_command(id: String, _arg: Option<String>) -> Result<Option<String>, String> { - match id.as_str() { - "insert-date" => { - let date = today(); - host::insert_text(&date); - 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 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); - Ok(Some(body)) - } - other => Err(format!("unknown command: {other}")), - } - } -} - -/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock). -fn today() -> String { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let (y, m, d) = civil_from_days(secs.div_euclid(86_400)); - format!("{y:04}-{m:02}-{d:02}") -} - -/// Howard Hinnant's days-to-civil algorithm. -fn civil_from_days(z: i64) -> (i64, u32, u32) { - let z = z + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = yoe + era * 400 + i64::from(m <= 2); - (y, m, d) -} - -export!(Hello); diff --git a/plugins/official/ai-completion/.gitignore b/plugins/official/ai-completion/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /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 00000000..e7bc7ab9 --- /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 00000000..255622aa --- /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..ee11a42e850fc3b06e5979c68cdcb859b988659b GIT binary patch literal 166971 zcmeFa36x!Db?1AgJ5=3Ur6XCkWlO-h*XpcNVquYXTZ-{Z>eMnIhJLv7$ZEgWWJN+M zkV>*7SCx%P9IJ?BFkpxo?SLTy8|(xF2Fz@NAkZ;jf&*m4Nelr(f|&wo=rH*G{(FDt z4mHS<A$hNR#aga=hHu#4p7;KS6YZS4WGsrK#y69AYIS@`tu{ZqbJuij_w+L^dseph zz%vhK&)zwEpf)`_dvG?3M)n_k*5>`wmrw6cqbR;4wGs`L*n8kvRiQNp4$kj==H6X9 z=l32wP?b*NOY8OB(+BoW?~do9+T8rk{nJr2Leb67Jb2jwJ#6efaQV*tdv!UoYv+Mo z)BE>N?~bDSGk5Ogf~Q&JU8GSK&nDUay$7bBGqP*XP9UD2o_$UhH4hz}+pAvFs7?v> zGCSW$rw{CIP<M9cfoDypQB!a1dL)~lzG8lkVd;1C^zOa$X*8;)5AB@aGglRgn)?s> zVA7}^ADWA2vynslFMHPB1Dp5mo*NIBmru{m(eqqu=b^outDE+&oriWlWAA=|nw}fa zqQ<U+2h>*@tr{)jX#K%MU}X37&hx#IbLO9OXnJn)F^}Hz=q<IVJ|2VRKFS;C##336 zCULx&5R*yGh!Qt0nVy^5`K;-=7-XdoD^5lq8AoR<?uR#_m!y8WdRc~Mji-k0(rmfc zVt~ms^jn+XJHLN=E_=qo-OsVUSI_M^`0UMti~uB$h@*9jA-QaC03@rA3J|AoV`n_o z(ok!Cnn&@zxp*dK`0-?%?wfwjTm)q4v!>_cES`z<II+hyxahKJn2fh`ysh`j^8_B; zKRrJkH%7a0^oYgK%*`L11$_aedSgvI1+qtB%!&iz(f<%f#o~JX0-<W7@lSC))rc>t zH-YpS2Y1fy&Muiga9Lyi;KBXR*g4w{jIdb`>vPjPXLs$X9h~1YjgXDO&XSzDgO|<j zf?;jovsv9A&jno9{dL>~)3futE}KuXC=WFcP0v2_;Or&SyVEE|?pn`8fHn(e6QfbP zo!fc2-HnUw56&WmyDppKNwTyRP|&)3*W6}A4GA*&oZGc$diP}}pQ6y^(+A+`+Gr<^ zHY~=Cy{wqJIYi}mfumKDw*xz2+(0*Iv}bzf?&(>L{7@Vp`ptM>8t;2fJX@<@GCjZN z;O@Efvf2G}4KFp97}sT^^LsCuK6u&u=1b-vb7OY;Qg{HMPy}fmMKFKa+;0q5fqR;> z({qOqTB!xG+en|?M&Y%QxFz19twz-5)ibB(ckPMCmKCW3)AND$qm47-sf3C?tmrT! z+B>&dtQalNb<?|Mr{^mawHp5=o@$yh+rNM3GxkrXal8jvi?g}u{m+ag*{A(CN5rol zI_O0n{R~vpK4e4qdJbiiz39L!i!zmnYon*f(VA!PoZEZ;-h=1p(d3p#|NNtWq1LXC zA#_djrnl30vY7VSbiVttS<~MTL5LX}geOYky{Q0t_MYhjz=M96yX+ZryJq)3V|sH{ zkxqXmjyl%qE<rbU&Lsy?2nP@B-L=^s1Q^B}V<u%mx5uOF;^>qLR<k>y;Hvs*3HrIR z0RvFqpTtwAWl?+YfkQCB-2CkH&P!6bb_{+Pe4>%$eG=O+E=^9|zjJP0;^8CS9PABL z1589B0^@CObhE(&r>mvaLpW)~dtzAT*`{ucR3p)77Ii984T17##AnyO&Cj0Qiz;e~ z#Zh4Bsl+QbO*<TzpKC`Te*N-Ic;)emF-BB$<MKs~JFw83pSl0Cxjpf!cyF^`4TL;u z`D#6)#;ZrqjH6R4qBt(*0$JA7*T6%qVO%$UKAtipow9@qNcCQHHGG9;MSDZ9-5@7U z`+e-%zxNz}8v0m!+&)e{p*|qpGJWtg^l@5!wZ<{TM2#(B7^g4huE9}^gi-80KnG{U z(b~ZDHi*3kLi_6)8!@Qy!DMbOK7>5eLKdx3)#(G5@0~q(08=oJm3&kjo!M((;8kem zjK(A4sT6*ZN(hP#2M$r6zGCluJRm;8qMn6{XEshVIUC1Ux@7Nxo%=VNxTLY^q+XB1 zZ$sVnjnl2}s@_{?XDXi+M~_@u*<Xj+8|tTk+0~0WZv0|ARhJw+B8wKilIDj;_Mt>0 z8TmEBQm<<`159TtfNgI>$B(S9rQ<b=p>9luo*xAPdd-FrE$aG|#a)Y4vlT`?#Iwai zbl!Owor{N$(|MnnyYVg8jzl>3am0YGNglN$J{%w3ksLl8aTV>z4)b5MBjv9@=5Jlk z>|T$f9W}d+cC_s`{E2o%6pgRqPZaHF^qz*wq4ZVZIi*H)Y4_Z;dN<aORp%XVnxTfS z4LE)Q<0=Q0A3<{!P$hbnTxDQiIaITPxaudg$ZhTLRs0tvZykNaL%MEf{5O08^Sn0i zo;`TzSNpDAlq~fZ2Q7Cn@=<MNcUvxb)L798Ug|YR%_WWX9Sw6wqLHOg4SKP1kfX*! zl)*QNn#V5_9OtO9jBVbsb`jEG#DlX@vef0+a{8i@mFE#9%bJ%h>lc+DH1R9d*|4aN zOLp)JpyE<>Wv!ah*z$-)m3#WZ%0$WWmED#{u2kKDh?13c`<BJVAW9t9LyCNh>G@@G zyMkM|Y*}**qsH<`e`u#AikGi>JYOYBm#=d|PRmLiK9uVcEo+Up^i6g&>QF86IkpVA zCX0$xTDDh&2hr#<T-7&4Tb8k4%d*md1=+Hk=~<~#52huR1YGivZupjEJou=U-j%&S zYd0EcoHo<6(P)e{5^myV+~!e(kUNf(G)|89Up$_~<Kv0`ak(l_lGSUHBu^SiJvk+5 zH^$Ri(x}Ca8d00KQ*WHIdR3mUsWsNt+G}}{HcyR{W-}gbjMD9R+#GK<GV%ZvZzKf% zjb_^9btBHQ8bD^_aV@SzG+5(*piCQCqd{Pcn?%{uPUpYVnsL%-axW*9Vk*2YOR`#e z#+l7VV`xBeGEUM+Niu*KkK;C@t~KZmJT$mzG@4YY)f%-d1E5CIZYQ;Lyxkn{H$z{w zTHIy;Nu1W&jkQTljWFWTc0=(*4MR<0C(BY^P?k|gajN(|O$8jI;4lA3iO^-d7Py=3 zwd%6nZr6xW=#1tf{oz01A{reX#r(J0qZ%StLI`+~v#7g-$8lC`Cyf{)Ho#~*gA_tq zEnCls);2_Rit=A0+YmL<dTqmoM`T&E)@;^l)Nk+usMZ0H=|+#~JIWpj)Q95v`C1xB ztLxV$KXW)P7Z#31t;13IdtdfnyNUHA(I30I?lm8_$G3;yyTb4FAM!d2;r_@`zd!N@ z|GoYL;rBiM`_-HMcU&!>sz3LInrG<dOX23)o4mV6=%JNe2v6SlS#RJ`dUMO~c(I$p z%Ol~p(93VV%3t1l)PENiK59ewiv9fu`};Nf`#{uc9ILM(lX1zx+2<tD*}KRO{u+79 z`CnU{IN5dZl1p|T*d6i$XU$%AAi6gG`IXD>-}}tzUC-IIKcuhDQZnfBo%7St3*z%u zu61bk;N_$sXRB^@9o#*=Ii#AR7sltFOwCIUky|5m6}{+W>g}F>=H3IAwvDbk8Az4? z-F(T;*?j;Xy*Tck0RCPQ|Lo#YmY&*7W^4EU=~>U<oaJ}XZ^sjhORtFI>*KQ*mpv|K zU%C{MrR970eAz>n9>C(u<HtM{EPD?a_F%D!WUqMW0SqDhl}kZdj2sf%6$}3QV^5rq zmclki9vvwmt+Ss@QuNMr<R8g{p)Ka#pMElXZv6cC|NO1^R~C|clIO+O#2-(N#P7(S zo4hYRl3bm<Ao;E2W64{SyOTdk{yzC~@|EPd>8rADB;QM}PT!fnJpHTW1?ij8ThkY& zx23<Az9{|u^v&tZ($6QaPmiW=O<$2-pS~ph?exCn#px~S&FSswThiyH*Q7V5uS{Q+ z-jF_+T$lc3^4j!w(pRT9rLRk0lfE(i-Spb@4e3kM=cj+2yezveyCZ!^`nL4#>3h>J zq+d+`F8xya<@77*k?iX1!Swp<x3iaLFUdZb-Id*ty(@cn_P*?ovQK1h%s!g^Y4(}y zbJ=IJ(Z9d9@%}7{TKClA=HVhPl9^6gMx88~)W0Gbi;B3D@TAs`Te?gqi>y=gm)-2_ zDC*YB!%v}DR>m`(q^Oq<#+P>LDxGbP;_{a+wTF+ow9}xydiiTlrfj3E&lkyTr%o+; zOjJ72GpZz1ne8N2_|Q`+oKU<y?}gJvg;N#g{hE2y|F<G4s>zH}G+1Q&irS3ELch<R zDQi!pwR%}R-hQr$pEyP@_Of|-w^2UoNrLQgV=?WW_JpyFq6?e(Z6_@XHN0M<+!9{h zANjkpp1ZKHpmrJe%;pGdbLr#8Qbx4oMa&C>D}BOP)DljGX&ZZy0#qtsQXn}z(@g_r zfyrQOlvnYP=DW>f-H~!~JFqrOu-c@4DDj*95~Ed#RbB${uk^QN<5D4d)Ue!a(Fk`V zLw9w%t83JY@n(;NIfKgXz#V`HcTM50Dcq%oyT^mOR6T%ky#RCKkA?fA$Ah3`c?8AF zy+|${6F(KnzM=s^DFSs3i>(6AD{A}BOERuv=zSjC{AWpAKMZnHAWR+yXt5X@-qn3K z6M_>VxK<vn5S$6Yi4a^r1_=zobs<=+YzWRu5L}gLRwY2NGrJJ1w*kS;fJ6oif*V73 zHM^?`!L=S^){MdI(IA3rB?tx(A-FCC*B!w%m@^>QSXwU(!Ms?6;98G0#eo9|?!TxI z9PjIhGQyX5RM<@eYK7N7h{0<zi4FjZGzGMAk<AE?0i!5`3Zu0iMjOXq5yL1_cB!cn z!)UFCQAz}+Zj@lulud0BMx9A(EpP@#9lzCG#j>ykLx9UnpbY+O0Dh)8u2Rn>95InZ ztC8G=$o_O=V-p@Sth0Vg&&yk0@Zp7MCV%e{ZlYFrxZn?e>uP&g#G8_g{^Bz7Zj!C! zd0&rbefTVEx7@O?n{Bkq6}uHg!X%xGRm;Pmx_XjzYXPvP@mc<A)J;^j%PfDDuJoGj zi<-KJ3{)*D_&;Z$cI)RQ$az-gGexbaPbBTtrm+mBBHNVaQ$KpqHE((Ar$6`RKa8%? zgLPB4z5d((^6sM_c>h}uUvuOlb&!?KnIZ*1{mJrML69`QHObqFq^Upc{AOAO02>ST zK+y0!FApQ}1=pLBiLJ>xzbdvSxnHf@N_~5qZ&i(Q%Z#IJRfY9Che%OoWjlZVAX$*< zPrc?C)G8lA^^bb#_YamRhY9!9RzlYDsz9VWl6~EpcL@^L&EyYi9Dy#i&Cz3^3yPiL zNL$y+JK`BBAZeb7WI}(=&VT4tbaO{69V0VSD{7mPJL9d%HZI}BbEzz(O(Yxirzp?K zj~dEUFlF=)K+3b=tp)h$&Opcx-B9lV;qwmOm*0<O80BkTr2#uM`HPNF#jn-ymHM-w zvggj^Z}A?=V^wJ?-~C!uF56sw&M)))%|=ygKEFrT3HoVsv;_uCCIkC_$qP5~cc?%Q zEp$Zd`KQ&BbiSj=9+umFNTkRUL2b{yDNyt^Z)^-8zVDZ3^nh?+spIu&a!PEw@Wqj> z0!!H7uR6WcL3L{Bug3m>kpLa$MdcD0vAz|T*%Q#BPR%0AKVu+EkDP-~SKb}#E$E&| zjzI70?Rr!4!+2|YZMgm}*9+nL0j{s0N6D^JJa*KfLR}bZ<<UftGLroEfZ%KOWFomG z(cjyW{0D-eg8bT~JPh1r(jgll1q=sWn`}+i>T)8v9;i0>(>vfRSv?B8m&-N=&nm<c zJL}hB3BBKIBh478<gghA!DYl4C<Djc21HX7TYwXfavg|%*9)Zx#gM!{%Io@8dNihF za05(f<jp^7tU+}j9$2*NO*9D>xi(}t;w4U-l7$4n%>+dJtta1NK<ZA{-{<-+UDNGH z`3)FF2zwYsI}IQdD&{`|Ot3)xw7E68QG>Z!kIut`=lY0WBPX<Xw<PNdA8Zh|8#OLb zxfHRo@~cOVfr0>fC2S17hhafu7?xEZU<XuV*EsKbSya8TyX(-J`B#BxNQUUk8<Y*M zopp_XEWsvSchMe(1b!DO25OyG(PP~pIkNH_71#pLz%El^D(Hv&oq;38ohI_ahclTI zcoPKMn%-fSei<HD;rGIC%>q<yqMY=1Pg4d#kII+6PS`8Ag1@(c&ZzvHaTFI{vWxr! z&;uLG`*kR#nU47PSfx<xX&*1%yqB$jE;mvKVd>r9kpvUc`YKtm$`}^|#@O}6@jL+; z){{2InK1FMXN)W7q#O4QUVI`34<25|gh!HkY_AFZu}IV#Hr{1%;->h!|9fKkWL*oq zg4FcuI=@1_=3L2J%I`9H`>A66-T|!V`?~2y)P-9UarjB{H%`6jsHVB#OPoz!i@(jK z#=}|0*@X3RHiKt0<Y}gx(rC!j<Uw=T(|q{M{ZJT9LB1pA1YAnis}5?6lf!6&g2#p| zRvaFMVhol?EU$%FY&${j@enKT5nHy%iwBj%UOW#LdGQY)x2Y5dUc9;TmGA%12tNtM z@k$m{RVsRrg;!u+*W6}g;Z_jd1mXOUP&p3X6{Rm>Xz;G+={R`z=qUy1^`%IED27(+ zDgU6CS^Z{_GA3||I&f>cUKh>$_aw6bVvd8yO^UY4t2}NpePS^ZSEi*;ilKuYPWvV! zJrR@frlA<=-e_O9;WAV-loe?61LB%aj-`JY>#xs@H`3A3BpQuJN!e_QqN#uW$}7M2 zPcOdj9p8wqfzz8a|B+{3_)KhZ<_LL`jWYq-r2NiJ<~1}a_XEE?Ek9-ov@wxv>>?*5 zK=6OL+ahqdviMTZ04!=(eSYT?fAY1@-1C}ud@J<%pXl?OpLyTS4eONw6c%JR3S7+m zbw&!>jJhVYq#vPIH+8Vew>u<_{51-{iE5#6c=g@@Z}kd)B)s_-_kVsq|L+37Ju-lc zk9_^}Z~sDo>oLIfKm<^v#~PHGh@$elfAiW8)aO7!HggWQ4}2n?n;okgqC8418k?j4 zgA{~N7u+9`l;KiTp)NrU$m!!YEngbqIEZwkU)C1k_6cJ(UVh3T@6^jvJxjY%BVY6p zW=k5u7?xkD8d<BnteOBOa_nWCzoDY%1i~w_ma&M+)!F^*9qGg(Q4~pKQFfg(;97pL zH+h@r%{a2~*Bb_gI>1Cl>cD{!b>#emx2n74`3D*_s;FVYNhw6r{07|Yjr>X*moL9T zd}^J#kKHE7>LaTBr1M2B|A+>A;@v9)8yILw*sZR^yW4+P-It$?@~g#?)_D(V20*9e ziIVcWA&Tq4{imKl5TyBnRV8xo!%dX|34DzYB1qtg4??u*G2X*Qg<A13b<sRq21a${ zMIM`;?JO`M7ASwXc&Ps{FjNbJ6ktI}D6a;$>l)qBB`ThTBcwg}A#ju~k*>3(hOuS- z+#}#q`TKzX?>YX*^cI$!NY>@=HLfNj;O*#HPeBn{Th4z4&T1NGMTk4&c;3|_?Eq&H zF$p%|^xmk7a6UTVuMC<`SIH|FQ8%AUBb@(rJwSIyhfL_nczqun#$xQIt}<(2F~TGa zW{k-c`$LSpliIW`#WJ@XTs29jFYk5)Bh_>FDsBo%TX+sXI+k@=<aWf1{b(Mn<VQz+ ziyxhc#fRB7%8<E@e**L^b(&8Gea3`_KFtIy@!Bj5!H<%+9Kw)A==<q}AwL=VPRd35 zKKqd>7x);SRu()hMeU@!2aP2TV>`^i%6F8(G@mkOn&{rwed-Oh^CIr$v?ojJHO${s z9l|&=^Bs9V?&pY|mB$H|*0i89jZGO`tmJu0L6#t0S$o`KIb6bX(a#<f6d<86!5eo} z&(;+{D)XI9ksT`d!GR?-p293C&q!=+m&!g&gzH0F(eaGhTGx*TMEL9}stoh%m&v3C zajs`xGv&HJA<e7Vj$@U2Tt%kHhFU3ZbiGDi-CoDis0#CF&m<r|0)gg%;F7ncRI+{L z=b{5$<Q56hpJunV?>|cgsC{U3UzESZ^}ggDi$$5N0~#GmNU}wD1;e31h?C9aZ<B52 zvWiYyFR91l6*Tw8J#eI17_3_CGLZsEUDDW|PW0I_AcS~&Ujvf;GbB`#@>}=?#Y{&g zQI}`WbmUCya_vlKgv;AwxND{}iq%uR(Xn||5^$Cj-Xrb9YrENDHydUsbzX9@-K_OD z7un5(zu9g#g}<4!n{)lmHoH07-)ylPXk^Vz*xfdNRoKlv;nh03`)K6va>GI@+qapS z+BOrLN|hh(j44%~i07GuG9p-5IPODKaM-5wpcL`wj<_?XSGVJcD$e5a&bYG*Jhy2y z>a^8pTVk=Q7%$r9fbw{p0xfr`6JnZl&(`#A$f?U6Thn84F-ot;$hTUur;Iz}tpZ$c zN-t7+jjM^|Vlg(P*pzP3Gp;6*ZF&Z!Hl>q##??f!UC)wY{5)W^9sp3udODjR8*CYB zb<FFn=^=T^MqHn9sY?cmwuGl%UNBRv@~dq##h71#RdrXWyLt)6)#`-51H*cUeCpj_ z_&b~!-Q5?7yJ}kRp%CG7B&SLEKnTsZiuV;1aRH;S%0p~_C;-M}Q<Q(w=mV>o`VjZu z8~2hkg;C_rxRTC)h~v1Dhx^fswIH5!Y)Mvk)tDroDdPNX*0RkbJl&l$Kp~+mhE<+l zzSYzCy_?~r<x!g@<dy;Cx0)bY@*AWR5GrPWihRQhxaLO_coxWCMEU(RbYTJf<+hpp z?OwI7pK3k*M1hGRRTU<QGFM;9Vc%crsn$)bI<1CU^dukjtYvINN(e?ab>qPkL|f;v z68@+2rSbBH3WqcSDRSqU4bnbF6gbP#_EsAJgg`5r#uZ25`-(JG7N_-sVKo_Tq|!oV zbsd%6IDfM@<?X&+H{en{Ve{*L-R{y+)J-kbSfr~V+Cs$t(%Za2!G$-vYvwm7GJ&!B zI`V@9JfW;a&sK^~%xH=yn2)AHk0;C?1b;oyU9LlOCci};%OFe=+VN~6xlr7U?3Eit z=BIdj+xemO3;l;Nxrxc|^y->3u8dPJ4A(q%?<oT1C~M6vYj32#B{+sUb7C<y$_d8# zUt1p_1GCTV-`FhxNB}H4iTHu*_mvwCXykGJhhC$PvOgI>*_J_!CHd<mG>$h0hy^at zD@70=Lkadk(XJshsl+COHS*ikPcoU98Maw|z|!pEx~-{N;ws;o7KSZ~zg=BPsZxp= zaCOSnrev+fSr#o3@|q=blb%*`5@uC_p;{Wyx^-Yk@|c3hiI7%`Af@my&B1T}M#Bx( zZVLn_IfwtyjrFswoDjh;;2{jOO%2Iyv7apfs^;?dcx`cx(>p~xdzXx{dW!CSxb*&l zQ?H`;7wr~AEv5HFp!Y|;MxWkq9MB~~PUY1Q?*%QHEJ03^zp3hYi>YupPN?B$Te;0q zV~RtT+)!gbLX8^B-xq-Bp+=f3;3E;YIBKws4<9vuS3&5a@Flxl7B$-fYCaaA>UmhN zWx4w@YBm@yew4ni?3dgR@V?f6q(YU?GCtYBs4YmxFa3aZ61yy&Im~m|=lu2n1RmdA zy-RfXL@<Uz&%@=GFMER(w|vEJmqkg%Eq4bHdMLSB+=Afs6t%8!T~j2x|4$E<N22z^ z6?3I@RkVHmcr9X|MkaSx4z1BxDX4)vvP!x$wJ*6AM!XzJm?-}ffn!>s*MKTaXcm}h zl$mZU&!Y7KHXa;Ksuere;~Nj@eJM*z+gRjNae0P;9wvvq&hj^?Q;kiUXj?Ze6N{qc z1$7u#C+1D+#?;i<g<I1duFx*nnqG_^T44Q2Cda7f=AqY2cV4M-6AA0hlRaMLNZhS? zeGDsM&VC-tw#h%T*01c=)hblmloI>+0x29z3$<##I*P>)0ZX0%S~eg8q{`vo^7_iJ zYJGY@S;}M%=mVSvXK<FXwOK_1k8_0r7w==03ao)Y+qe}gV9(AzFFD(vF}aEOs~n?| zN3mGtCi(C8P|cXPxMDY_b=%f-J9WXymXLO{Isd+uaEk!9d`L~uc;ATr95yc~6jsAk zx+Ut{;YeA{m&)L@erZ|mPKbE|AJXy$$UeYrV%TznOK0mBaY12+rP8qy2*Zqfg4K`N z7&U_7>Q@fA3$d#S0fQf9a@tL0_-&ap>KQg>V8L+0!VC;|Mq-qigC3h0%k<inQ7y)t z^~VbudYa*cI-gk5`Dbk`D9RJ>yi$cH+xfO7oqsNLzU{<2-&O@BCpq$^Xn&13?k7h3 z6%UW*C);^`qd|MJC|^qNzxcPK_tMV)^53rWrOftMKNV&>d?NI|FIvLtpAWq&B0o_n zCruS1@IJGj5WlTk()~?=*-=?1)O&uzVt#x6a8$X1c3awW<z=J!j|Ci@C=e*RbxSdD zdgK~D1n9xGv|3L__hSrtK5f+ZX(OCALf#Ks|12x5n)Qaxc0(7DbPGwz7J5Aj#bsV9 zD9nC&-g5JDR?XX6SG89~nypqBn0rvqjAwBa#TF@7epsvZ*j|GxwZh=qGu<fvhz(-^ zuGaYhCGD!`%FN1XGXkvCW@|m&%2|x^KMT#SWx={f@{c!b+Jk&N!GEMh90=tIVl!!& zpxBF(y^S_US*gt)PKQq)kxg%Hx50Ze<O0XFwUr}$<T*0@4_MMbm3uyAxB;eXjSHjv z$5!a_9z1F+;FINb0Pb!sYx|dXbC)!a(=1$JmWO)HU#1R+O1D~lQm=$XS^?v)RNaR~ z3$q5T)~^e1RUqbl4f3WDQc*V)*j7=Rtd>8gY8AM7kqfILBo9Lwe{oUu0v|2BP)2LH z)H=>0TXS%_2%oslK=z`{EDL&Q6?c(MW%A3pvMG<kcWEd;q-<R5YXS%pJeA<H5g|}6 z)AJ+wjFm3~{>|%dS81`24M3+Dx$dqN9UD*${j|GHcc`=}Y{N`<L@_w4FKj}0o31Rw zvBj^LJ)ZC@+~R^vDFdtvfp-|kv5dj89_s+TZds4quW-9r8Kh^KvTng?8zFB$QZz$e z)$^SU$aApAdNK4X2I5)fFXGBU46`S~ocoEO9KKZbcuLHz2^W$@!@i%DAT6VUpsR4- z5?Ryrvc4r66aC@7We?dWpX9n9h$=#pq<A<<u5Yusy^8D=<g83kqYBd18NpW$q#Ye3 z?YbUm^B!rNJ<^f{haJN7$9B63Qzny8%<}GGrKgCRf@g@TE3*AYazss&YIQLL9{7a6 zoiH|Vk9`#4kFG@gei(Es=gOV%kbP;j0OSgDsaYT6Wk3kSgm0OI05cL%tun~%iltr* zSI@=_T@y21<|Hy8Y{1?;SL~;obv94Ka2teQsAWb`FWSXexz2N`^`fQK7Ccif)qeUY z`+H*nAUZ#~fOp^vfeKh6F<l5t0)4_Zl!WMFoG!*68c{zTDXD^#WuyeZbdi({1wqS8 z3A4VV75S9WcqopAXd)lc8oWEoRbf{xu2{j}X$NlJHq&K_#}t*Rr@+l%bC}FFGpG41 z#9Oq3klj_T$3^iC6)|<Q(Qleivw=@zc?`A2tkOK)Ylf&^eiz+ux=!gFwgNKoqPZ^= z+r^RryV@Zxw=2tUa5a$}k~Fl6(U3*p%G#HlITxTzRSqsNS<$%94P0>UjONFrHMhwP z9fKdnR^o?#Lgc5z4^`Z_3_lPmEaHcuxO91bxMo0?A76t>bP2Dag-e5!7*fe3y~wUt z`3EbbGb}KTm5|fdDnA|MoRqK(MUcxQ=cMONzB+7D{=sgS&5W)K$>L9WjlLP<q}4_x zpvU(Sz<JQGK?v8)bo(Y{MGY%MVmh|l)3HA9Bn^nJ+{O>O@?8JfV&;Wh``;R%W>ajU z&aSW@l1P^m)0Q^5t3}5a939uD^c{-d9bm$T?@;uk8$5|&hk|f!k!Y+G&MnUfcZ?*p z!)cWXz5~74cBd!*fU!KOEm;>j)l9D4UBC~sTNm+z1N9WY+s+T`Sf0+0O%dDO(~5Ta zRg_tqDf9e&-kuVvJYd?m_vENO(adouQ)J5Uwu|;eawFYesmo35AKZ$1w9BKuZO}^G zMu}@<)4L=1vP4+gYG}(1MD*KYs=z$A=$HGW{0124*L!4T0=ZWH+z+tGw3o>5FvhT_ zWhP*@b;mSeoqx)QGLh`C1q{5|?R%aLI8^a&`T{J*peFM_R(U#M4yYqAxY(kEc2%f| zvFciA(!~Pox1o&LstMb>J>E%6mgA?BdI1sS^0NorlI%)1$(6vt4vxl%d7*kX%2j4> zbM=G?;^}!{)b4`E#imTX3oA|xSuqwtCL=u>^Ja^#nZ=1Q32L>@)dlu!HCIF~dZ-<m zP*f$?aEbQQQ=DV12hh_~xIEh)Cg22JYc$Beuc#4Mq`fIc;36UnLOv!CWtC%v9Lzv( zO<o}wVKu=DY{PXKwyGCYx6SspqJnd;*f^tBB3dCL18ta4g+H2OtlC~3+J<qh?RAUW z)&q8bsBMg}Rka3z2O5g=44ae@M_L<9ln;%~0QSWnP_YgmtTIyoE*q<bV7kKY5)J7J zHiTJqb@ohwiH^#+Q&-pEtj<_$D6MKJ7xad*CJY4*H!xVkF$~5r$)UW!YAn?4BM0T+ z$bpI#c1{UR!ZT`eQF~hALVNPZ9XG1!$QfMCf)^2sB+PxTQuI}<(!N?#5{LK!gUE^r z$tNXkXFX4UDQrGh?A}OQd&EO17Pnr{kz)P1s|7UH$dJZD0JN)jL>g-yKisY5$L8Z% zFr?XcyDRvC@aOV_#xiT5nV1pHoA8$?-?h<*sjv~~*n5C$MhTKpp@j6+Hrg36@9Sy0 z+LWLMp5j;Ynvb%#S4t;T4T}6lGk>RPnGv`FC8HKtbhfV2G2CzKG+`PpE^r-VjZbc6 zZr#R4zf`SM48``NVyv@0(%4m*LfdaygwX{77Y0VOjT$VJutO+Js~8(d>MaB_sW&}= zE}<c`39`I3xq=&ztD{a}vk~)DU6My&FqDB-YZjw$23M5uK)h)A12dm>EqIbDArBDy z`4~mwo3!;zJYv+@?gL<~8svW;ViBl@TGEorh}X;^_E_X4T7GO<Gn;!P8xvL$$LOS_ zlctuTi4>VjCy^g}zce)HQg(84<g1Zgq4LO7Y*h$l!lSkQQs(OO$kc1E6b~$z<9Fkd zJ}&6jhS`9CQm#pR^{p2~st1fO;-jD|*Iq%Mpko<Vs@y>u=A{zZDbj>CuGW8jSm>Z- zXtku_b^Uh#cqADeIQRikYz>JbW<g#TnM28$-EHB=*8Z?QN&IPd7x059vM!HRTxPQE z{ub^vT>ffFgc;pd`9piqb0}J7%v<Fxl`*Fc#Bn=6EzTV<1;gd0<jSphCw7G+k?!&L zaxV^XiO#)Ay`zQE=$x~2o~#Z*yXn3bGz199!C(BB{M{zA_5`7&pR-vJS(^`z0r%TG zV;Y>6y;+*g`n^aR!LR^y<^cuAfN~R^ab9t;(Kgk$r1`Go32P;_L_&^1aB_S-WscHC zjB{9doSYw%gX8WPn3+R_H%A+x9kb6HB*sikcd*x5q>NkOc`SZd>n4CKS0>OPf<VLU zIeaeFV@1y3X1Z%YqZvapLhzeR#u}N6CUuc=H34ze1PWlV39HHiNLvQFMs|V~`D@^? z_|o&EwO$lVt}e47B%X3INEAaE@gOD!3!>5<xson(@;8wd?TpacYVw`nY_w~B{HU#u z(y3eKb_hMTe5ozA7tU%L(PH$V(gw$N3fd<FJs`pDYi~FMirjdjQ4Pmd<qh&gz#1G@ zX>t>qi|8H>1)+ob`)PyvyRoePHcf5%+co*Au8Ct5m#J`uSDY$+Y;?O#*g{4nZ~!k+ z3Z9wKvC6u6<Z~jbTiShuw&16W`C$mKhH?BKndFccQ<keKX~#P+)|zXuw!<dFTy=9Q zA&N(hiY|&KclTKBHN`11?Q4qFGW6p;$AY!N)IX2aBK#sh3;ar5<R2fh8-#$e8@6z_ zHRM{ntDZFbu7#3FjUj!8mn|2eAzV%H!yOyI#PZwtf&I?)JN8s*=oj+C0LcgG?gFAy zDKn*if--9;)5L?Gc59SJh=m&caEayh(q*>0YJu){>vB_ag>;!+9Wh;I@3lzg5S6`6 zvC?}DT@4`~;Udw#5ED3@TR9gz*lt3>MbJ~$rs*jg9X$`~Dau`mfLL!=C<3esfzGCu zwJAz}nrVlo#V>Xk-AE^eVUywbh2xc^JrrM50fff~y=X+~QMa8^ZJ1ZZ5v#Z|gKN%1 zZqIU_4f4B(9`ZZGYI&Jg7jVkCciSjXM%QX6kn)HUKIlBdevk8!@<#CenNw5vgMVUS z1&z3)E9!VFkl9*6?G3l02k!kFT6#eU`3(zj8pW#61T11rs9&)`0EF7wl5LRz&b`br zkpVoctBJ7wJe7e8nY#_{aJf@XHx6ndv0~Z*oR~{aT9d`~CU$%cM*H_*-;14~PXPe2 z&nJe&29qeUu{fn6Y3#XMHz;wwjJpNZ<!ULOllj06gAz6m)%UkHN1SwLkK1~O^ar0s zck68Gii-_)a+lN|?U9r-earLV_Ee?Wk&@+t*(NotTsz9VX|od%r=W#OJ*Thoaz^An zkw9SGJzhC>qw=2UEuQJJMqkL%yjP9hdoNSHWd6-x#ze5S-pkgTl>HGf(Mo=(V1+4r zUS2bJgEna}fD89QDQhza)eEb=u3y_+PzssnSrtOFi|AsPAqVl?-UwT-OPRMl3u-S! z5O&#sN?{VBqA<yHpoLHhFJK!*BBle)WmnLHILLE19XMk7?7?u!l$<&ARfI(hKOzr! zFrd1K1_)=Q)%NRh6XK!Fie0v~MZ?}>+=)k&1sHMlN=WrVjl|@Q2HLns%9VuB5`rnQ zV09<N^44ClHKjk<|GIwGf-Zj6Au;@Hzzv|3ny<72;3)KETb1u?!jFO5deJN6-vHl( zn|1tvx3&C$4cNPrw!B!uoT}U*zjHD5>kd*jBw&w<MCpo2kRapq9)VlmZf14-@TBjH zABBqPknKv<B5DkFW2vm~cEH7XMJySkd9JT#rGRGvfS4Sgp2<Phe5y@h`kDf;K~5r_ zEUy90S4iGg{--m~SWjTfTF10S!xK{IGp>!<yXY0XHtPYGJC*B=_m-Ag6ly>Rrr;Qk zg&3~_pstNOqrRq~(`dJUy3LZ2(xBRix}X*KEXl<JtN{CV2mL~aoXsvCHcDl4?dfQS z^fWJakr#UkuS{+!R`X!8qtvoyeo`eMsxg;evL^(8;Yl-plj0ysXX8n|!BXKB;Cfg% zJrV}5V`tDJ`D@je(rV}qCJkj%0@=d&Oi!IS3FS*K;IR~-!RUw|)v`mmK)Iew5S~Ej zqBEj(W`Edt_@17WHz0AcD*(=|h5_RuX|%10Fthx$NVp_5g0)-HXj|3jMHGZ1le zSYkjnr-ML{=Qa3}r#l~B+-WEsz;mpPerz9M3ftuWEEPm+SR|TO1V;whHfSo=B&}I+ z_8#k%Jp%}{n3G_q9NBt^f>*3m*$5Qm3ujcNhLK11a3L^9V<L;J5k1`4+u>EnquSV0 z>^=m11w)L*F-lTmuJT(%S#yzHF&Lim_X}zt(L(Rs#VqHk4W=Yx?n<TgD}9SGFt4En z5Wpad$B7hDG)L7BEfQ=tyCKU6kYKD@Is(MivXa)#HFA5slO(^@Uw05@Bgh!Z?W{|s zK7}9dm;hG-guilG+@P>`uNvTmm2N6hK5U@f{&mYX_Bb1Kl9UruVd7=WIP}=3inwE{ zNTvCEZfINe$<|hVVzazR{;q&JW{fVx;<$GkFjv7){c2i?-5_45F3&PL>-nnhwkaO3 z+ctnY#=#%UhXaXJ+t#bLDY>B2@b@4Vqh@o|vXF+C9l!-Ke6$|TMLq${_XrvT4QK6o zxT9%0QZsU-G(o|Y!o^gL;mHm*hCMTuZE|8rANUNh4KQJKS8fb%dr4zDxfBPxR~`Gf z5-i0s)eBYUAY{i_Mr9dSyJKLrl^er}UIGiV5zSB;Yh0He>bbj<u)Zy%lT)XEW||eu za72+9NgFNtD+r|l<d62v^=ImR;gO+k4jF2e5sCk(B>uCzE&SkMUC0kE1v3b`yMP~9 z^df#>*r)j2cC>{UwqbU@9qfElipzj4xZL4ZzNtk`Pp2A>_Q(;ku87`rZa1vLxEq>v zlCa|C=(St#F7@_s3k<vY;cf@LLK5rRMNmLaBu~dP=I3cR$Gk*J^E<CLO~S*A(e7>3 zB5!P?EDaUSO$k)wmv_>6Pp3<Ejw^MI&a0XKtx4Xr=VFK9avpXi`_1wdI1bHz2pvG{ zYX=+g(=AQF<)-9mTNQZO<ty+o_v><kjFB?j_7s?|@P`woF$V_w2QKxMU>i43xd>8v z*0p`9RxJ>y=xac}y@2FfLTPuE7=nc$hFk?P1RhggF3>=2ZfH{?-0LcnL{=@C3@+P} zw8N6dm>z0MgsbW%gUeS(A1tX(P;DaYu$T-YkyT?RgUdIDK3LM2=>v(A$g!s~7v35? z(d=8JB5plK`Z2i3ex-dH$CJYG$c;+=4$-Lfz`!}vS}qd2XE<>iZnO5yXs*%;U8I|% zZQaCo%!^7Fbd#_;mwkHG>RjS3OpDKsLBMGH)@X|?@}{WV8g25cU)UOTWRK5}!Y)yW zf6Pw@!XJWT^5>hor^p>aV<V*!6><4TUIz0o@RGA$pfM29C+qVc2u`-M*p1b$ZvN3n zAb{6(6J*i>^nU(aEl7K%()9F4+>`&V9_ljhgs!+=%g8Y}=(AoFaRGh_Z?;LZ;9pE6 zlNRvy&%)q<jOr{5n-T)Ja5xx++iKi38^R&YhU?5=h3tZzHhnm&wX}lM;F?r)!qui^ z8;V1~gEAY6t_j#&wX$B-0GzD2=#`!1^uD8^We@eB-^~ly;$FbnXnyn>3UFhWR2O<m zC}u;@@3~ha0FVDqCc7Xw><t3Q85B=9T7H6SIQhxE<_Fxh*I4>t=IkgcKg6dXRvY{8 z!`&n)!7??EH&M#C$@Vn{8XzQMFMrvf6<)_YK~r+2qy3e11NE`um{RGUl6cS0UxI+- zfRwjT*`nsNZ?^Jglqwuyc0|Mn)D0a>pk-qOJIjcv#09ic5@|%=l^CtM;OqxY-C5)5 zQ~A;WcgioB|3E3HdLL<Oc&*QoaFur%FKh28uAF5&OA^p8`M||^xJ+j|t9r@A7MPN< z)mJk(NMfcV^D9r@R)DZF+`gIt2JEnW^~<GwH^WZ8&t@50OW)0CiKjB1hEFB`ZboK> z?YkKY)AZd86$bG`g;RZjfcO3HW~daJp0Ts^T_1P=a-?@Vx4(30xo~(^A#_>4JcQl= zyh<#f&utuwXU~te>%i(_%)e*BWbEY_jjXEbP(Aoy8mgQhJ&~GedB4|8sX6X7Y47~# zN%qpa^`ddP=JN88b(2weRfkB6@hK}}eo)xs`Hy}EJXsvk0?NHz{7_L&blV}AH%AA? zn)=xfkUS)zV8=UNACtxB9>>F&P}5+-XORdW{D|9G*+BNzxSdZL5X{>1M4i|R|0oL9 zU*xqfEcgK}S)l7`9`X@{H59KoRo94ut3j~Hxmq`rb#kt@Qbx|zOk0VZL#3RKYX;~D zK&gb63}A-~%SQ4^2vHOYi6<6vT@&%?dLet_dPgRF7`K%z1OYe2-PM>PmcVdxW87IA zuym?^D=e|A^)$~>^YF^<>BT7$ty7;m#?*n91h8XergKIa73=nO&Ma2z;T7HW`#Kxs zh41M+q9+fJFuXk`imtuY)z0a~>Y2{zV1vr5i_?mA`v1x6yBW*dLHFt>uZIWL7O-or zI9)&RmlF(>=f&xASk4d)sVAv^Drp?5J(V+FqyVTzZc7Kk%j=~n&<&~hLnf(3i|gsV z*w8sm@q8%4DMe~kh2WL2S_HMi3?uEJU#r?@7Hb@K^%&UldjB55En>z<s_QiM;^=}F z?Iu`<R+n<@Biv=!WWGo_TChp<ENq@;n9$JAWQeB;!nOKYUqFPJ4oiGN?*ML#Ga1-= zhPtMEMvz(IIh_%nr{b&mtV2|sAw;h+{qD$;DGVbv#6HA``JjujOkSKJ?5vX}ry*K+ zJZDs{6Us5I4~_CO3f4~ujunx{%$XxZ_^N|1?Zrp(0*0@%QvmaKI))K{9L4bUgtAf! zedA!Onb7$x>X9vxrbpEgM(ZcU&m{-q4>$~9cQ?r7XNMeya56rm=gA(pG7Co=cHt%Y zkieErMA3OLR=vbAxD9h8Kj9Yra(|TH5T>&x=RWgUP)Iwl1414DETeO=Uvb2cG6+1| zp{cd1rUms!2^~C<@VCKN>a`P;W93{}wUR-uJg#tB<lwmOHMgysofBn`T{JGT!%N3i zzUOqRk6IYUTw<5;AfCom-I{y^eISNgKDT4H9}4$ZLNR?01_o5A$?Y(&tQ%1j{*+vd z8itydEU>aNR?|K+W4GRtJ{v=s;g;a6W{KX@>{&B^v)BYhN|G@6yD)=Ze8^uYWB9() z;WVh&*r&7Bbb)f<f&f-VjGvzCQyX-B08<BU@qS;2lw@7G;S2d8wKv%#+%dw+MKo91 z;Qt$%FT=}!c9Jv)c2--JUu}fXF4$0*YdG$RDfF*5V!Y0i;_~}?BW^x6B5lA)3{kW- zk_3_aT*~E6%Bf>f{yh2Bawc4M@hkrIMuqKIc~m2=3kPNQD`8X<QRBRbXkcJezRY-8 zoiw#%P^15vgJMEz@t{zSLD8By+(W4Fov(iIY6c;KkUBDgphj;AYV~Lw4vAb>Hp7RJ zaNgvT-yc;6fE@8pO)Qc-ZD?}2!>iL3x>odeU90ZU+D~Kr{Gfw%M;5n4$3J{MOykM9 zT6zSl5Qtm*%J!*6ERY5_g+<Wmh)1~&Hm>>#%WmEt<t+z->`@EKDJY-iKCg{9zeY?s zHeLX~ulZHPNv|yVC0FI<B7~gv{&ErvIc3nQkkbL&O&H)X5VgG4PmBJ!&|O^KZfHe! zf7iX7-lsgwH2!-_Arx5=PY`$j<0aGh)u*j~0_^w5)%)^Fy?h&v0*l)#a(&6gp(2-= ztfCos?K*#1`BSKl5}OxZ-Zb>`vvEH&%(P1XP#(N9_>xSoM2Bf3v3A?SBz>WgjKq&& z;s{&HVL3Y0k{@lhO^DJJAgAsqq~(q$o@p_>W~^8%iu<gLwTgLM#*a)f#vEU>XNtMj zVdIf<G|W8<272mE+ra9;$Dpdi+rQ-88%F$UZ6&1(MSwQ5fYWblB2xWuLX?FYX_r&i z@F0tr<`nE{T?PG6QLc^F=*id7o5bHBH9O>2=uz5xbf|j7AxWxli6R^b5U91!%3|XM z#d1~4sC83qa0Tn&Oje*zb!vD12TM~v$_U}}fM=rP-Y1xN$ZF&AH|5!I-Nu*ahc!Hk z!A{d%dD@VcRs~SP54s!dd(Sw?s>ZZot@QK4yR}Wx_||9~Cg7^M71L^0GOacf=oP_K z?}UFlS6?lGtKP}!VI<z+5cDFupl=9zd`Cf#vKI83T2Yk1bx#dxAZv=+dU_nRI%)#1 zjh~_MkQB0p^yNkQd?V#GvZ&>ElxLt7Ja6BEUd0;KvHHWP_jZTCR;VMeN6V}D;21ZS zcP+I@fCU2{39-ht*<}T~f(e`uAS0(X3h}z5K+`f4ap-FMxi|V{J-W3!GJ)^>m5;pr zHH|r-wY31{m!rAaGI~OX&knFe<oxIivETWTPMN}k=TKkys5w9QsBL~=L75B8tON%J z6ho`afp42t)1NTDp{<AE-;Y^86xQXyo9RY`e1Qf`@DqYuVN%o0NMC$8OgBgd605dw z49tkFETiii|NCiKamL^g#R3&iK%fG#rGtT#T&<n+uE2T#R-1e7P2;XL*`_AlGPpmH z_uu*tHdPixX<-3EBZPJyrh4)NydoWkxy&%Dfh`?Vg1QQ$V)$ax8jntRRL2{|84Ndo zd-v-EZduFku%SF(pFs`k%kUpkdnNk`h<uV#Ng$pn38dfriQQ~{{uXHsZimx;Ho;1! zgWv|h?SO7Vo%16_RP1<HA}?%avechG$Z1)_jM5|vJ@scb^aTanCsj;K%K(y%^3RUi z`_$j7n9^Hn#Y^{+fUF0ybJa^yT#O?QAWJg?V4=lQu0qWKp)$7{P2mY2o9OK)roxOB z8LW0n?H+eoso{p2(OEAPKet9<o&0<K%m3NG{8s<+f&S&!`<LIgOEI51!;GO4^Z#KF z$HT+?yLP*7s0{u<Bmcg=c;u27(oV%_{tbIg%L8qzp1&?y`9E5LGnXlle{JPg|75RD zTU0CmraeZW=`H`J_-0jWRVb8y+wWKRD&_y=PeS?c`dvz$ykF*hHrx8)q<?Zqu~>^p zOY8Hq2$CyN#D9a(XzN}y6=X9`Ng3t8tCWc3ZLMu%<AUXESZtD1>?9KA>t!;}0>&Ft z;wG(r(xmNBb7Yd1HYXcHV<eK8CLw{u_+5pfP^Cg?qOU?twM*T>sqYs+o_N3=lG3H3 zYWR7811d%~z!WrkTCS7yG$TA-6)C&kL6Sc!&W5S&0BfB-6T`8uQ}-RN2B!RCJLIUZ zvJ+R?0L;*J28~)-n}5ns3ByWR>OhwKkF7C4?`hZ~YJEhX2sVR9$|!yrE)rAbz}4R0 zt?#2}+&bk)K>SR39ohu%O>&0dBE20Ds@2`;B(US}6g5`0VZ?=M+zoy*8TWEtS=)xD zPNX9^3qe!;OdTLi{Qt!D{$)iLz;d^*sIQlqc@l6&+xKarg<Gr(IwCQ5((EF(PbNf5 zg<E&Z`|~lx{W0*-SsMeuYYY}7`WP@)J*XQ7wYeU0Xn|AiEl@WEHF7AZ@qz@k;DwK% z4F3;6&7DDw)BPkyTR{!~7}OvJC)l9IaQC1-8mC%_)LRwXx4v|fO#RaHyV*5G_M1rR zb&|aB34vtl@UIADWBl?I^Pxy#m77h8`ow3s76tVpRT2r>;vXXkxZ=vZ`yngK^1xt_ zUh$F-W03M&WR9ZNpBjW$_7+Pa*@4-TN&ja^A5WtgD(FcO|6JParXQSou=+2$y1M?} zgVBFAOvF=%eex*LS|A-l$#Gpbi#aiL@f5Qjro?}N^>K)h!h3)Y<|>+EsSppe@W%$D z?J%i!Dh)sDQqQVS{nE8mfOYzk_8WG-WEeoSS&K*Gc%{Rr`dUCS1AdTl<|e0I^9IZ| zMd+u<hd>$I(Z)7o{18(%EHEKRG)>ZaJ3U?1!(R55B7VzN-P%-f?X4o}tz8Z(<-aS6 zbf0|tY%FH%^6y|EnNIYIO|6Ej>RTIzdOAaXQctJ&*PnFzJ0|ss0x*>@r6i2fs(sf6 zec;MZe4%a>Ltv<l29mB{#u)dtO)Y2}JHj2hxIxT6tzPJYQ`m_oJYd00O6@$?nF*Gy z9J|&X`g%qW3)qK3sG@V5QKe0grvqf-{C}!loiHA>3CO1x>6fnghI~+460iB;nvGeO zg4Mf;sDoTedRg0ZPJF!#Mnasz-%z6!SXOxRa(vcSoP<`Al__sZnS1gE6&&8ETbc~9 zoxnxaNuF46Vx-{?g91irfL&pf))h;TOm&J!E7zAw==dOYcU@;v_Z~-7c=*Gihd&Du zJY|+l0!S}wMm!F8-I(@r5K-$HD*AhlHvxAevuI`XSg_<!UYmfygaJDPqpuzdcAz92 zaV=qU6@hcx2k}0iun8azL38(FXzm__=C9MZS+S4SykyQlMn-cCjDBTHA+??Qj_T!$ z;A}T-;59Y@l~T>_NLlj|%oDRdHY;`Tujc(JVQr0+zcIrq>KH3AQa*>RVP&0wQd5## z)|A>GrR5f@Y2g6WFw>Eug;vSZff;IO#=aI5eg0-)PkEk*3>sFCCLto^WOtHnuoqZn zY4GACs1O<`#Ryz!9hC((l;S-*ykv<+<p62rHbq7~3SFiaW@p|0Kp1+})<P}gR<j{; z4Ns8=t&K#<*%e9<MXG1$M~S8EmeE{jWs8n~W5Nt3x=L(%0Y9+EHgUX0O(h!0Z@00B zWjj>Hm(WxQS2}Mv-KN65MK5;NZhosVTN0Rz-@r&Tn7<=5$Fp;31YNU*A3|l|oM?S5 zhC9rNpfo@<2^u4J-SAE1l1$?9!Kzqb!}o~3;9}p}Fl=)*h;0@U>?1@6+bzR3Sb%Pn zZ#nXosYrkh!fiPsd?AFx5v)*AHLnzIS)Eg1+|VZ-ssL7_F$Q9twMrNz?8+#?BJ4nk z);&r8aGLuOg|Hk*gJhUCa^*!1&sd5|%-53*C0CBY@pw%p8>9+4qf|0vvGP>1VTns* z!afbsVUL?iSkrM|He$&J>q0uCE*sR<j>r}*x8<TRF(lcr`7hR1xQJ-M4$UGMZ*{>C z-#neSY+Qk^n)GD(99QJsZJ{)LBrN7{bK>)~;y}DZ*Kg?4SG?gE&PMs4c>RE@mscJ^ z?vUVEoH*Emx#Sw!{nc`-)+YzBxuxfrI4`B*l};A4ATz@z3HHf!G@Log7=^%_hGf8z zcWUUWmn}2Cj@{<IqG8{dH~pW1^ajSE;utNR52m8x7jXlQG1ftjvGj|FERoiCnW`mh zHVJ{MnsC>@`V%#iT8FzWI)S;*F3d(x@FRf3p-`6lks#5+7G7+b*(iqrMO`8nWxFyS zj?Y&+J_E+%^5c&22|&8Z%}c=e*M&@ZHSnCtVO=-PF5v`v&(T8QE;)2`1vwP|N$WAP zVL61o`*H|-ESAG16$j+77UZxdU#c&MlA{h}^QR!wzITHHw5uR?JTtMY8MS2TMwH8K zP>CPmm#{n9CkmQE#-SH=;5VaVrJ1W+*+6=PY%fb`i-F41Fi6Y^otNt^H(>-yOBz@Q zX?Sv*YM+vV<CNJW7f2OEo3ru1p7u3?HpA7Oh`>THY|NkkzC_2_Q9-aJBQZ#<$Cq8& zhBsKZLqk+0@CMH>l7_G?xB7VsmXe=ILNR_W6hm5zxKB7prXu?X(Gl3lOVWaC1wUbq zO2-)?>4M8XBoumWgD}!Kt_=cw*2zP_*9S#DoPaQf5AA;yZQ#eF5j6TVJoW+$piC#r zJ>;QmlSRTK)E~6A6eD3T`WC6nZ>s=ei>7=r+|MopP^N$uP>~wW4sTDR;?NlW+Spiw zsUf{xEyzzZwr0_xYC~~q_f%JT`;cqKYdX5fooJ>Zx7#YwdVhCa>+^AlI8X+NmBLY} zcoVnC%vx5qqzSMn60%y)a3g(0#V#zbQ1IzucAeZbb20IPE0(t_69c<4-#{LR*c-&j z4J>FGSC2?S`ls?gk@9!nd`zks(_G&4Vsl1=VhE}uDqrO#3QF8QRO0uQv<&e9dN>WN z?0I~J0&}jD&0ng<CeK{vUcR2jh+hT5wI7O|=Lj$kHFDr1w7t#gguRn>E&Pg7>k`an z*_KF}1CO54{eIt;o0#V&UX~BgG-GZBu=jQ>4IuJT8m3nHR$0Kj*>SV&@D<zrW$byz zR~B-=Ds<ANJ)1Cyh81zGtX%u+99KaR|HVKSnqWHM%4E`*AtcGfBiaG6Op+`{=STTb z<8wgc^oV3qyzgl;h(o3$LKoSbksDFt0A<X&o^TWKX<1_9E4>Gr^!jR0A8g?VH_|g^ zc*{anM+tju8#0oXRqm>YTK^9mZoDle6F9pH!yF#M1XwU$Pv~-kfgfVCM4Pr1ues?f z7nf`O&nmma3|3HJ5(s7>`N2V#0Eq>(>NsXKvL4eOxP-tK_=(%R*`(inB6C7`)=@Uc zoJ$%BA)x~0)Xhy6R)cb1g@n++Hi=vJZfK;22n*}9Aj2;Og2*WahyoMKfciE+Xf@dl zC?KtZ4Yx$!QsAtJW-XF|y=ntqP9o~X`MZS3WYU5UIlOdd=M>u&i>lQd{toORcAC(& z=BQG!Y%D%dQY=MZ4S5p5hq!J?%l(Y{QL@DPF$T@wYjhmQO}1K(C-@9R5=TGW)54Wk z?`K|G*T((yaz9N5rqS%UrB!MyfJUsYE2u7{vr%t9<s?%|NrE_|s8;LCeP>kS_K=?z zUj^O3>*`x({bX6|<x=H+Z5O+?(}<nv;T*uy|GrI2^xp{kfoq|hJC?HQPODc;&Uu7C zrZ*@Q`tKxVEb+2OXRDON)qe2l<%It*h)?DFwO*ziZU74O#152TaIn@QD>R*VGsy{8 zHk*Z{AmTHSw0t(KTb@oXv4oN5%a<=KWX$`~C-}l187qToj21>vlb@$K18cWd{wD%Q zbNBsCmPOL~NZd~w=_Jm?fr8N%$mMO6&Jiw>DY>sKoH37r;R{d5t$CBuu!%<e2Z7JU zf41V1=S5`kuRd<Y6?Or4SXbBu7+tmQQY~NiuUa8tD995|weWPE5`*oTKC5witQTVZ z%Xrf0@8FQ57Dkaw>Yo&iP|Ht1t;nMkkuc2eb$HM4OHc=fo5C=}Et)+H%K|ONF!<$w z2SrtIfPJvY5ex}?gMc8?bq>by{$k(09BEBMqwKN@mY>b`x%Tq&c%m^q#G50n`|dV1 zzij)HS<RZ1d^z$V`(Le;Vx#;{X#Xp$#>RY?-v}*hT*mD|oqXG4k2L>+k%g4r=;9Hv z6Q#jPZ(7vcD1X2&tE(I3MKhWMC`X^jLiI$e`xZfl9(=8_;1UekBuaoo<O+~{cL^j2 z#V~HEnUv2>9ve5cN#BXYU<Mlbo;Q;9hqI&+&I*FuYJ&*7*9OnD-v=xHML{k1t(ZFe zU%*`=rLMR68}x={<|-DHbik&uCvqF-f9JL3*Q4a|^5HTT){N?UyJky`I(7`Cu@Q{s zD)pAC;?qfpQTSrbup#%l-qFj}K4cxEj;h!Mvz<0&e>8QOx<7@wH5r%ltvr=YwlRxM z(x07(LnuKy7vOiP>?=#<x)Y83Iv$yVU_-w)P&!b2FP=GyFKHMFqhy@P=ICOE|6|R! zcp>fUAu{L?^LJ7dDhq&MCGn9KY-0ymT^Fs|8a>soP#@d<3iY8DZJO&yz@ARZ`ErFd z4u;T)2<$IFq7M^#NUuPFO?(Hox?&q1yC``UJ#}leiiQEf)F=Z|HV%8VWGMYXSd1uY zJiU)v?&!G5D4JoOfn1gHL8+vdWmcv(0JJ~Xz(`3$aEyjLHqS^v<4tPEK5Ba;Rx&hp z6%fxz0f7JfD;lU&{vmEhwx&DyfqqZt2PJVaxWUWeFMaZeVej!zk8!ob(~(?Vj0-RR z)&51B;t?mH;!U5~RF4>-|CSCo``~fto-}wBX@AC3n=AsDLz?PwD1<?=LUAK7aekkX zD%9u??C%E$28<W!ggzU=b{{3rCcBJSV;s4)$1FpGrIOG@Q7Vb2RY`*#8qSizG6IT1 zQOANKh4Es5ml-eo1#WH2GLDO((6oWlJvPFg=GY?5LCdZ=*r4p1gUOr3=GL?gZ;@bI z%Y4qD-!ju}2#}P&qEh<B&sa&N+O!a34v`R7#F>hYxI&0*s{F*vhn!>|8nrR|f?^<M zp6a^~eUDE%%s55vlEsKN4j$u&e9*`~;H&MCtHvzo0&}PIVeUE^&GN|2#}){7`4o_9 zB$MXWhS5`uq67r_N36Nvaag$CTiH9@K&$oV$&h=RxvOKE$yB1U>o$?(-#Ua9()>r> zw<|C!nn45HFzF|N@_$8rJd0Azt}5-(&Odwvi1CtEGpdEi{vb1ImU%lcs6ry;S&v6H z!edD0k8?dnad~MRHhLTxdd6V}jxQ?H67h^I@f|iP9A*SVny)V7PliQ_XLPBZ`$h5{ zD2(ICF$81#UY}780kFDkmN=47zm|VmqalzCk)OF}Kt)2vG1z*K<_Y_bZOtDWCGB*e zs1qlXDYzoBBIebFTz@h$Mnfa8AgY8&YZJ7+3B@6etm*#7<|yipob@6hRL-zvb~QS5 zr_71BB~|bI(&rX;z(CMHE3F4(-S>|ler|dms=s#Db!x5~llfb9saich8Y{5c$^>5S z_ibxg)2L<LTSiY)279tfZBfP6gL6GTQzl*n>=ey?m=E&KT1C-wh_wlJV1o50WBrew zIP3pAg<!RZL!krxI1>Ff5Lbx1Lvq8zH9NKo){ze(t>>D|BMTPViE9%7{Ew6ummFd& z0_t&3lAgALI#n9MdJOA@P)&4iA#qqQ6eB!?vD1Kp-F=c{cvz6%P#fwc4LU$7pEVaB ztJJ7=bdm&=AZi3*&3UCq2Y*$vMq={o3S5x$to(w7FquLm&DWo!5J?|wC!mr0pOvrU zn1@<6Iyzdzlh4YRaoenojEvMr8qE>u3XEp6izX!HAqbNEc}JWAWE~1@O;=7L;i;3b zBwTKvP(5jZ#TA<;(&0Hv>)AO{4+SVL-s1jD)jw3d_R@N_6M=JdX}#=3>WwX}m!3#H zmQybtA2z`RC;zy3$@4a3l?FVEPxuR^JS%}B!||fA{<oZlleximW2mz(<QH#fhE|W! z-?e?E?W>QJf06^w&su)kGr)n{JClD>{S_=ilWfbd5Ni-SA^PwaGfrLG*4#Mj#WFI) z0Nmo!(>U`}WjsG~P8^-}5;H)2kt{rz{czdTSu>PP^ytxXG|RLJx9AnWW$G{5F8R@X zDekyt3&sV4VPhE(d2>$8p+gG%xf=I8+@P!lOxU&zcCTMS`1N4Q65;pqZ8BNAazSS> z__OcpEQf-n8n%M4w67cogg61BHv7Vbg3c&$kT8V;_#?v=BE42{0MX{?R7Cd)I67GF zpfZ<~tqRI=Lm>(>8DUdC*w-hHjRsORPnJ~mvK~k^ih=GnvHYEpKqmrY(Mep|Crh9c z2N@61$;u`bVVJU{YM)L_E)UVEc|vp=gQO6NsC9uz$&vox_h3b$KHI1(?d6)R5ynmA z#|dkSsorR4mKIRZsOPB}Bc8DCfeD=Q>t-a#L1;IxmVw(6bFmnwP?YC{2XR46j%&4u z?h0QCK#Vsy`XB>GeQ4@EXxPWM7#c-Zf&mwlFd{UOYa4!3{Z7tySj$DmmvV3$3s=0z zD6-H8ViwjX=gH>+hB{`R*d$eodr8N#uX^4`0~-&-Nt}G~Egw7U<aOpy9~qKmfT|u) zpYrYg1JXTAuwY|gSSf`?>~AI`HluYC*rAI7T8Tdct;F<0w2~(?M60NTA@aLLd*HGa ztg7T{>-1$HG~2Wo!V^K`0E9wgfG}DNVL)Jyn+#v%83sjtIk-;5_9?wFY#VfP0Zkht zRWyTv%Z2;VVUTTym@8E}2yN|^IdUL4X)FvNJ2XIjgOwPK2&~9Lbac<q^dRba#aB2r z>umB(?oGaA{%!k^f0qO&DN$a45C*Y5(e>azp`-ZNSP>IhJk88h#<KO3HA<|%T%#|} z1{RT;!bXwF<eJPX+V4o7NVPrAPc&e!iPo3pFC@m*dQO!~C!t6Dp9!LUMKucB&M@M9 zGZb~X@LNYPnL5&0fnoW=SKM}QG{=IV*>d5zzjXw2W<P|9$7=^Y!S2CJ_i5=yIl`d^ z6mFn#n%l=x?*X$XvAjYjGq2pu6EL$i3b8WM;Hvvk(^RbBsxN_y;FlRmAgJQ;`>y*i zac*A4<HWz7nlLP=eNt?4FB}Ut+XHaE3|5HJSO$wI?(E^z&RN03DePFLl1ynbSrA{4 zEM?{iV!->IhU3cmc)U%QV5K`sG|+B?TmU`rz@$nur0^47`94_67CFIKL3kb*8p@N9 zfZhsO9gy}asV&=qnw4vN>tjjm`a%Pn93+B9HcX!}cVz1}i&S%0gPX;|>KwplfdK=m zF%2fKNc;q1@$8^0(MSxOVmr*XRwoq=j=*V<kHvH_q~|uDxLzZB{Q9oeOEvXU-eCSc z!AY|$BB7+h2d!Jdt>Xu4tFG({H2W$RS_2v7VOe6dlRK)uzNIg6w+$>Y8V)e&N>FME z&Dt*5)a+GF6N6k;(^j?-5#U#vwjx0WjkKVEtp>KI7CuNyP%Cdr+52i@P>{y95|@4( z<zFj40+aZ%ifa|U3a5gJ_zfT$<=JG1RE1`Go$wx10VKRbzRU77v7l>cQjKCoqc^Fh z<VdgLAauf7u>?9#58=E>Suh_iuym{$V*sbiROk;U_Tf<S)Z>+8s8#buFtZeWO8x3k zd^@bVEhvCaLbmZL6Zs4;V)Mxf?(g}R<M4s7{;$LdWWbj}olzSS|7bB6B*q0N%mdCc z#x@l<u*%2hIiF3kj6i~HD8p-%J7&hy%o3=La?EEae?(w!^yS+g%K<5|>QT%S`^(c8 zBXQEaHo#;CA2|o~Te$)#icJj^XUHJyi{wk&HOvQc2d2+i->>!4rvr2Fee6|NmPZ_~ zM{zAlv#3e}3ocH66pH}GOKv`f;XtoiQk7xwnj<PLUoCCN1i&_%5~2V6bHQa4Di!oZ zA+IdRt0vR(h1MjF!Zm&otw&eZifH|EeK3u&d>@Jc`~XcYM=*u1KkfVv1;j9*ETdn( z5hximNVL|U2GBX7)N|pmh!kTAmZ@HWZ%fyH{075Ge<q=a5!tL|F@gh_+Kdfos7?y` z;ZQSuw8kqnVi|2)0rY19+5{^vlZkoe$bWt?Z<a4miW?(ULPGgQ(Ry1qtk-AroY|BK z#>CG?-IdW3H1k(@{S97#`7RY;t%fG?awXikRBkbUlX|yEt;@O~I&yzg0a^a%ddG*x z?ZmN!Dw~PljU2LQ6lwOjLKQ&=07mHFVQdW3kIt4wt5|@bb|Xm-0E$`{vdhX0dM6w- zlT6xtqWc8eG&ATEL{n_oXcu&@&HD(Pc^$1crG4?~o6sI7BNy5S$q#G9ges|0rD=aO zM9xIUjlpXQ!em_oV3wKbVXze>#aD9_GJudS_%~PIJjh)q)CPtxdIIwTS~&^fv&Si- zg<d3JV!(ihS<TnuYloS_FFV<uV8FQCgVzRma2m<Od{*Qp9AbM6UJg^!xCKnbXuS~6 zw1;A#Yq4aY_V6IB=tCk))UINSB5S=T)hf9w!#vS4iq859yA;JhRA!5$Rt?#;@2?CS zCVK+jOF%5E@n~gPMhtpd>o{0etS<7`3p3nTdQYsc#+l928-~o=N|OpR%jymJNkw$r z-IdxGDg}#RzE~lS+U@sh@YvgZC)sh}m%-MKZw@HA>>w6P=?%${pPg3Xiy(y~5HTLO za)?cUfyhELlS%_D;#u;3m4uOu3=*c*eghJQXtQn9{6m;t9y_wDKTWq4l8_T<riYIJ zOH{smNPbSR8IY7!Vxx@D07t*J(O=NF3_nYy_`m->4fnC7a&ZEN`!7|C6bTj<-Gae( zU!IlG($XSxo1j~BXknL(mOoYUK`d4;AHF%akPNe{=E9kbi|p75h<M+s|5X@@S#`z( zr~LoKs()UP9oMRNNnr`qT1u|98WLAc(<2p3{)ctyj&~QN@&`lqvO<*Bq*1piYbFz$ znITiFm9HU%N?4e>I;-lTER^(s7FtihcEzE-j^|v+Ejb~*bxp0meW&jjxF=xCLFIIz z*w2r&^jpl>RY2Njyq>!tTlp~D1rM7^+ta!)EoTizz~-<)1?vxGPIVL)M#v_TB{N#( z&oq*j%mqysgBSB|NI?W@aq*y$4MoZU4J_+tmm915ofbyz(6162+Gn7UA54G;A;*0u zewgJ-@lQlnoD}enH$hC-r)kjYqNs?o*mJUj#E6LVzc9{>;TnsWa)!iQ4%|bw1sG5Y zP6>6@rv%x-YG~OaM(m?bLl}bCeX0Pnu(LeOa(?<KG=7w66c&f6B=q;`RPo`Gp^BG# zRwDj)zei_GaH_=ra(>VE2ez)FKZ4&AI7RLW%*IAc{n6qs*N<ETi3?5<XFr+HaRih( zk`g+2Hh3`NSxp?1bk->h5<4<z5<3#w61x$^PCL<jqYO*AG|L#ouI~jQb|>IYv1)0+ zmooF+heScoifs%GTH^(x=iYowd4{my=>(gRR0R#77z#6w?v(~u<XF9@mid`0=~W?I z(5n8*@x*y{_s{}BIgc2Zzm{Hc+zJ>l*d@`)Sp!jQD+iP{wLFr`w*mlEzbv2qD4rxF z_lz++=%GTY*V=()Q8ngGUs|Pat!xKX`QKDMns*n%M*B_~b(r+_Z@5Kyb$&F|Iy6|S z+S!nng?$YsrUoAu5;N3HbES%vmmAt-HMAN!h=E-gRzu)W^TjFPyvX5vinmz7DxMPr zGTGFcw|zDo&;Wae6O@D#Ti(Y2l_ZvvW<0SFck5b*Yd2SP8(N8L*N3_+C%ssTRbMoN z!T9V8KYTpKxhXkh`|bqU725HkN{PPvX5T;2YH9nFiFyOtBKt*yJ>#U6Uu2s)c5YL` z%2jpD6<w)Y+jT;xL%3!fj&a9#HCE$l`*z&TB9|Sj?v$n>Kv}yrniDlQMTfRVphbw; za9;Eq{_NQ-5ErKyDF@Lc0_Pu4i+b7n0&45lc!dc*MKSqjdK`n%#UPu(vK)(Z=2av# zJon0brZ1pI_1PF@>Lt5T{#9>8)zAwXAG9*&#&TnN6X<VjY>zz^S*qnhv();Jz?ops zrM!~-AJpb@TX1xk4pYmvv8VOUSOI(`kz0C)grE%s9OZ864N$&dc~}FKbErSLJQj}p zOEtYjj>>!l0bkQe7(CaC1A7jcH58r`fU|PBdJZGVQRBHCB$OdH-gTP)4|QBIuhxET z2}!0CI#KWzs}1Ez6eW{l0gq;N^;0}i@K`+Lk5=joE20S=ewg6FQLgbZg1T<o9h=BX zWiArS$=RVo*cw*lfCxFOI#rF79|i(xW}-^|8&fnaXDMomr`(UTg(;-dja%6yO>eL* zSyVr`%nlezwVP@23vqYPmC0Ivm8P}j&%$!eD}K^g_z9C$c>aQtWl^>(he2sN%_fE* z68xcx7&V*OBDm9=fvrU7x2S-#I6M36B9bYj(!SS?u!cQtAZzNdn20Gk(y4?MG?|r8 z^$khP<Z7_JIzHs3b*ORC%v+`&jIZuAdmv$a`#=#L=#;aOa+HY+iZrKw=bc}<NB^Jk zH2N60{bg1wjg&Q}yp*rj@+QLoMMkeGGVA0@8Tg_O2C&^WM$Ed8(G(8E(9<-KXfeD} z{6Y+s{;4N)uq#frYZWmu$lwew9j`+VDM_hcNSjm4p6OP*00ybs`t<M&RNzjLr~eOh zKw3+uVluvKVxtFs9!%D)Yh@;*;*%iYhc`@<YtEcyQ7qoJrJ6dH!4`08vej40!eY*O z3|CBfk5L*Zkp>nSF-WltBmJ6p*sBP4SL<GEJ1FDQ+@=^oyv(u?pimx<4Cg~O2Ijn~ zt5DUh>X?UMt=a^)cH>sCGZPAI>P(YNB`B8&4Gjby04450l{dZU!?;b=$!(JM-m823 zRW;>5`0DnRU)?qQsxPjscMaGg7Ct5$NxCnfY2`1)`Mo`^!Pc;b-nqsN2}59$mU~0O zAo~wMU~mm=TEN<nH8RWKWwbLTn4{xtJTYEe)Qv1twMqSiJ}0V!+CJ?AFoskX!@Gv( z`bU}dce=KYru|eM$cP;o;%sSJzGLS4*LJ9z>b0Y&E6#^biRmnkEztC3jXCX-uNQ^+ z0KPorPsebrq@rqNp*hSN6VFoBweoD6%Crr+S}qpLeFz(J#W<phWMM77aAKJ;d!SRQ zQJelxw$W$SD`4uHS+7;wRr%O>*bvNE`qfbr_C@jnJ;YW8O$cb&sHhm13v8Wa7m>X| zP1&qJkC{eY0;QE_Kk?!hYKBd`?=hKGYHVrODx#4r8n_-;RA-&;gYnQbPW*2~A<Z%` zqL4|j6w&c0WLR2Ka%BXBnfBvs#lCV`*Aa4QiZB+RYkh(6=+2-P9mM_pr6jYAoslh; zQkppX38}D~^pgo*Y_4K{zo<3j5B}k@d(#8wttcS_aseya-gFpwU~l@etSG`SgAO1} zCXto%gDM)32xC5M`(k{nrJj3|v^pp$;VCQ%=ZYn`<_?wV<C#&7TDw@p(Gw?yloESt z$%bY7bnFmr6?z=Y64uh{RX7H-K)nHp?G@uO2~>H#VH2&@gF4ODHOuaXLb8=bWQSS9 z$YdL!fH3k?P?#}MEQ+VRpK?4W@~1evhqSVY{nd72q19BLz>iMwJb^vLKLmMZ=y3Kp z)9VH&G4KWAqc!$>-xO5zO<^`V^$Uwb?3Uw@Z&Ym`cHO>XW~X#0hiM*-jBJaQO}6eb zEWd#~Qj8TjS~nyKxUQlF@+VneqMZc(*BIL!2-X8T{qPy&G1SUOyKsICA;qyMzpP*Q z7HFolV;0q^IRi8!`?S9ym=$SgG8;kczxihGjfP=m<QoOI5q*nO#02aVPSS678&lE$ zJr;4iDM!-yKDqS$ADoiywEqqt;h~ZTmhK*Cg=d+9m{g7FJ;!$_j3I7D)CP9Ty*Q0q z%k7qPM3}P(N`u~q@GwK(Z3;CkN7MHod0>emPL1LLV$xA!xSdR<lgT7s3^FIWJBB;k z9XF44m3HNeKbYAEi@3tVjnYni54!SxBxQ1a_(nPFtc$U-m<8>={N6APZy7#8z6e;P zQ#V##@WnKr_MPHihQC(H3zqm|UbG^aGwTE}`~Kt#j|W#cE%;`bl3Z=*Yy1u$h^z)i z{w>i@Bn1I7^CL)}@4BdY&~n_t(0kQdJ^!|vl7a5==_iN%-kNi133~wos9xLEDO=ig zpa=#{EOO{emnvcy5IxvN0LPZU!ZM~fi4qY6Bx<!Js}EJzkYx?nS2AOk9Mh^3+9=x9 zRs^--2Pv@rUqo9(E)QNB_+r6XmGC)cXf{qo{>ES{Ed?uN`~jrbouDW`N!$|YTZ+w6 zAq*6b+1$XFLqQi^?=M;kCHdH>TpCT(1RV0Qp8PvT4;X$J{mTRAD+h}@U1?J!*nHGw zr9|n9InlG^EM{Ej?Q`pW4JBo?TyRmD?Y%NRK+sZ4iFueEz7<W@2Bp#^907QUB=rZ1 z6q0~wFdWXT;A~Jx&1gW8#nWhNjF9F90MG=$G=D$=(;n8dB}W3Ax=gfQohWg6<-j@z z6klMeB7MIzi~uwsu^<ez%Qqz&!ppnxC?nvjUl@h|cG^B*zIMXA(S=9v{@OL)x#tUq zZ-z6o@=o|_QT;oUCo7sf>$gEJ%7SlW&-`4f#gb%Vp2P^z7St8DuoF@`K;tnjV3ZRl zL3~QRp<Dl*0K>NqKZBYpm2Z{IKdBOGuBgA7Zw?b-!ul8qsJ+M_d{7RaUfS4AjF@7P zfZ2I}rWYWMwkMmEaV8L%P*pI!Mlfhpt5;Mr@u<s0qzOnXWh8*Zw3Yc`v1<?~jU6)L zvD#XhS<zY_nSsI%ZdH0IY0a>-?uv&NfYyesS&@prP19*a#O6{H><l#>QmxnZV|=QU zQd}@oL4uLiNw>YDdsad?_}0PCMHI*r^)42A$hWc6;E*-`W-Y0cxe@C7|J?7vBI0gz z+3JPwZcD<=DT2GIn8y}BYOgazKy}zAR<J}~bjIVe^oCXfLpx+lGoH{*`kRb^9xyZ= zoT>dk-I}lL_dPp}4&2pA;CiA=7CV>pMMKt?mi=HW&|Wr$hd?&u2{Bhljj-ll3;wh6 z4IjPsr3nlEv*CsHT8TeYoZX`D{q`4Y%a=qFV01PBbW`6a-(cbXlg2QW{E7*^OZ;oW zS#_B%?vgD^+Ks>vLRU*mFfO(VvRVL#6EiCBfN{d=2<`WoENid{0iFga_?qAcLVzYs zbTIZrKj37OG}x5D(Er_QrXKv!55D)!zx~jIS6xY->HZHr_+#Dw+LcGR6DS{h(G73C zFB&YSH;1pedP<ukuD$-~v4z7|cbgYUB(Ms<eruUtPRATXi<4uju2EibDFig1C;w+N zq&$bpA-8dlFZ93V$ihMt>4Y~UX)KZ=4@OdfZzN4cQe?(-ETch1R~3v0Xw@n(nJo6A zgk%-%1O^&D-J!W8+am%t%u9F12WB8_{a<DY>;;Tx9L{=UbM;vZC)9GlfLPA<6Dv(A z_SUcX8VnEuz_J=_)p_fC@z4Ph11T~ScV(GUC`(lql}Vo{G$uy7L%4DagdoTe;qJ@D z#|=_=VRQOsptLZr77l8zEAr!1Ql3d!BRi65)TAALIB>?K#S8ctcJidpy-Ob}r7joL zu9Q2cZ4+1&bDIHT>Exd(ION9~rx(Rcy|mnnE4kJilQfm~Sn9TxI6<6R6JnaT6ba|# zH^Vvk&0D*OsVseA=j78r`duN=h;)#j0l~1NaOk#&K%5#*oFZ(on1teRyFT2c*aT-M z8qi(}gupp1DG=&5q`$C~DJBV4uydYC4_2X!;sz0jw@J%)pon+FIs8~G#YDUhX3*@H zt&?nVK9Pbjz&F3Y!Ky(~BH(PmmDCpt`o~}-eG8${7z1!(fPzfPFbtAfocVxp2Wo|v znlg!1nm9sYC}{W*GSp?#b6XKSWC;XRCqeCGjb1V-MrR@?r1PkyF{!o0-|?w8FeXW} zZ?j0sTn@pnEw2?vFgiLl50j@bbO{9OTQ5$AxPM@HP7^^{NTzMH$f>0+BofFjcj9~d z@mUs)$)}ujc4I2^A{FR6po0tj%Q1smLin=p4KS4IivwngJROq1+wchZbmXjEO@>u# z#LK&0{9$EZ!}0j^NPr=k01uK0G)j<nVL;91cdAig0U6}CPk`3BaV&2}h}DEk>0hh^ z*G>aY*Wr5tKw|^vYvAI))|EpmDP!jl5QWl+@_$iZYEGCT#*M&)SBEh;990LU_9r0) zwE76=ObxaXS}=v#Pmr{J9f<qvnJs2d3Bw&n06}_8r_Yp|f?~hzL&rc{Ig)=?>s7Tr zte@*u%v()35^QSIjI*t*S}(}5gChH%*LZi0ah~iy!|V7NQOxFtTAdReMqBN%@m2Zi zHK(jS^|aI1L7rG;;9aQTLy_Gwjhug6BjdJOwf%M<)4E={>cz+X#rh>LzT_{?T=L?_ z{^E=!FK#$ycxx|takIZTWyy<ouk_;M{$hMdjX(c?*?Sv!yRN#<d%v7>?#sUS<`m*Q z851A&If+iH#6&-(t*uJ-RZ447Q9F#E89$GNHl@8cq)l>(<#|GSOViR(l%R+O3WOJJ zRiYI^s}#M(iXAL%br7pm><9=djs;()BZWTS-~YeX-uv8pZW1bfobh=Gy=U*e-v8_W zzW!^i^%6em32Phw?0O}hTrc5A@3C>L?d}OrSPPP0_k?d<)7{5CVdI*FSH0ibIA=}5 ziYGjCO~Pk9VcVL7<G*E9t%cQ_Jz?!Qe#sNItf}flo)EC4cu&@F_*THLVdel@7nk6q z%Xws;4$I`I%RoVo7~@emNj86iM4}~12smS%ngc~M_%_d*7Wz-#ux#|_#aPz(q<7#v zlAjHasQ%6>;^CMd*z{hZwHTAw9><0GS!dZUTRY!+oTgA#!zL2Q(NCA<!!}Cj5-Qt8 zvh^nAtL!WazHIT<H6WUuR1d{bjIT>D{NAlAumkV#t!m$f4!@As)b7tAZKbu%W7UO# z_o+<Lx?k_MdsSh5zuud5RJ9SYxGTV6p+X8`<;IVHkgu@f!aWIQlrH7beS3&a(sP>; zE~#>lt~jOo_S1qc96>w+mAX?`E=S9noB5h{&?W3jK5dU!btRwTk+tXidd@Ml0^v$N zD}nB&r4`vd6O~`dC)c5+$+bnAvP^^;JZEzqpA0<)21}*S68Fpm)^+iJ!N=8g+B419 zMf@-iqct5nj@H3wHX{;6qE6i1ATV+%U#t4Iq1#$F1OpVNi9n(kq4^x3a|SInMof`n zsiRswVx9<OlmPVGOyWHyg<xCJKz5#%F#>VC_U)SffV+lobuAt(s!dCPVC_N1V1$NL z)yqg};aBikq)2`FRJdGfyG!g`aFfKAkB7pNy(kpFEaZ^ZmB-*1iIET#1I7%J95M~k zHryT+4O8w4*bG!hh`nUEeIt)zg0wPSEbO8`kWrhY^#!9?TE%Pfu1CA2NAv5ejWTEa zV9<;P=RZ9`a6#2a7e+y%3+#w;$AL`9(Bf|x0TG4RExYr_A4rymxqgnm{C3<*pWnW( zdir6!U4G=ug^32ukrKM@;kN7d;V*2Zbj;SJmM7o&XPp<&m0Zy`vI!OBgAzlZ^7-li zD9D*6&3jfI#h0RPX-w!S*T=iTy<cT6Lm+gcXKhN6S)%`JI+Vr$g}_gk6TS3Kx`&+_ z!|?8NUiS{?b=8q|60+U;S0<RS^fCD!A&rMls&A>clHXqK(xsXtu}>Xvk8R`Dr}dyQ zE`7_6asyUe#CVJqqih=}zf70yDz`FuAD|AGs7>6+Br&O=+wB&^=N+<6<@4v<GXLa; z8;8AlyHmFFd@L2Q|FSdRy?NM~Pv+nM@S~so*o{B(`6C3od_+SfT@g+sz2_G{|C#r# z-2Q7HKu53BurV?5wh!TDXXm?)C<gU13^4Y09$^vVf}6R|ym!knt9m*XtL}dJAk|6- z|7f3edj09stD7|}{qOoCW$&_+M`+jIxvMq#p3RIZy>b3Eue<A!|MBRbz4_TU4dGue z;49g#zv%_T$<5^it(B7}kKoroVla9A&-~>tems2v$g4)dTv5wF@R>eJhUMiqScdO2 zH5EjoOK*rP39-NG9V>J5hmV%IYE8bE^^g2^yB4SE;p8z4#@_AKhggK1tZsVS)7eyK zPzQFpz&AsF1e6>W)t&!E?56q!ndjK1>@j=kkcpl06KA&U=SGwcxH+SiXXg{V;t&0f zN??FaS6lXR)uGP6!MREVo2Ij#5W)aUN+ED5)g+|VPk!`s6Z`D%Le)QPCk|Dd=irj6 z2QW0uThb#;W$sgWD=*XnYy|k58S1ldVd05iUS`YQQG3T|&8kP~nHuEwq?0#~^oL;5 z`UH~`_uHV~!Jrp`L4LS;0<29B=qQV_ILzjNlP@3Xb*9^+)UWFt!Cu%xu&dTbFgmoN zX3M<7iL`j~7KV}DGVD?Le2s-Q+DN|jE<1?7mxjJyf1XeNKu%9SnH&*3j&S|5k}lRC zQ1^TFWm>aApd8B`!n@++5qCt>?RBR56tg_X$j^9|AC0pNa<yhThYlc-!7NyHiuq=| zVZI-3&-bTKHQ#e1^L;1nkDu=qQsRj<Phq}KP#g%~m->X?NtbGtau+Y_@sxV1%UO^% zFa)_LIxk>mD`PWvq^kNv%Ivlojm>UD$Hr+!rc~=M=^@>C1v`5GAMRYH7eWshX+Hnn z&;H6eN9VWSGXK~w@g;DG&u>3E|8!U_%<HiYH_qSoWF8Vel^mU)zGeQ^%Xcq7cEifV zjr0G&-eY4b=idxKm%o_bIKTboV|rYEg5*v1b$R)5YMH$Gm=Y#$5l`d;&ztm2K7FSP zD$djSw;Vfm%vKIN^fPe4A*%5PaXwT%8^zI=(|b5mT75t$l-@j_9(&pCi%{iYX8z7c zS8llR=2zXi%cWqq+9fTqTiy9cBGIi2?oFYhwf|KC0K=|Fab%Edlww@<DADl-Gps_1 z6LR4nQczp4KpDcV`RTPvpNF1yRyk8nZMMvkAd1f9KD34ED{Ot#4r>D}UoZ8!TNY9w zRI@PWQse;=In^p#_Yg<=5mGSq9cr{2^^qxSJ%#>3|8Dj1M9D!XxcLLl_p7^(>h_tI znlxE`@*@~$e=I%V`tR7Ri7C*j%#&-9r$8qvQ?0LRRZ)xfz6QseI53~Y{ViU@qAudK zH(_mMSD8LhpreFgK?XkQu?%{i`di<YLqLs2#q#Qu^m~oJ+Eq?@9VN$&^M3%O6xMa) zzBInNe%pncrhXVaOL8MwC1M^+7q&?mV6<m;&FG0&keR-kUltWiOG{WiEq5jTubo3T zk9DhKXfo&ywMb@FoAwX8Zq=Du-V4T#Vrtk$-~TbS=*AA~?y_$Nh)#<K7AKOzjZr8k zADjq|g=_D)70H`^JkaU~(d~H{GlsQ1(fpu*(;c8-OJ#EP+0{pA$<q5cKaEZ@vowVT zBtLf#tw+z;4L^EJ9n7n3yvRQCcUu5>rksvEot<sdwh6j-cD6;^DeP&Zc2wBYjHPOi z9u|Ur4EvZu)1X)%m5VqBpS|P7vvF3azoTYMMxV0&{Wf@6YE;&kG1SW`*!|yCYOw== zhK$x_E8`V%8HHe=UU-heu9#fgT0(1UD_Pt9GwSS?F`eBKIxE|%|2oG4!w4V4x0<cI z8U|~6_1aKy6;KQFTMECtw(uI;d}Rw^Sm#dz!T|6Y4=80-RY(!ASSzeThyYU|M*T>` zpegKah|3;lJm>_zIlgKFg(6%~%<iDr8D47&x|C4@Q+Cn&ymHr1^H_o*{hzP_;v|m6 zeX5=r=kb{f{SVrt7t{5u7hh37QaPP9C>1%z)1zr~UF=#Pft*D{y!y0ZzHD2dBJkhH zMuPzBpdB-3!F*oBnO=I?D3#PGf?+ENjw>Mi#4qkSkdpv=RL9u^5PRBW8;?W|<m3mX z#W#`?@BrBZ%Klq?cAX7CBBB$q!j2}#AlrYvWyz}ONhscuj&;>1a`Bmqe@0X&)&O5o z+&9EHFDALdAth_(%5vb<@BneB+0Y^ZKs=ugEqpVzqeV04?;q`8Z<}#w+$_Ep*JW)L z`&V8gaPgmf`q|G3K6LlXd-_?2dHCP)uI&qr-dsz-wQ>IgE3yySYt6+aTv?eq3d_gC zB`B}H#}fTpQrMW^JIXnNQ$d;oFc$f=;@OS%!7ir)O{$0rwDWZ@<L(vCQCxm+b1W9& zn7|^gKx^7s5p!yka^NZkm=PpCUks&f&jei-E=%f1?!uypRaP%G+6ek0Azvv}`Q(h^ z_QidoZonWWPan7cg^6^!wP@9?v7B{qw!ahI6o966O}+BpR4yn=x8jE#GDJn`2S_Mj zG(|UhW|CCDdHwUWJ!dMYXVS2AJ{A#{)6}8x1uSaxRGX6NvREXdlJGb+GDp3$_B4ZM z(^{J;=M+s~$1&2g8LdFimUGgu<`lU=shM(iVCOX3WdhSEwX^+|V~~|8MuR!ime7mI zI!gnDc)}P6PUU%yo($(oLiuFpH!Ts!#H9nM9E#K<cqk+9WPk(UvBK7WGzcln{3x6c zA9Mo+BE$5`$ZLn}yA5UgzpTz#*C#ZbX&(+ZVA{zIUdI4TF_>k$ff%}b12OJ+{_;U= z6}UPME`TcV04iW?a3a1az^Q1%X-?BS)f$Cv^hL~On3jTBY1f0}W?+JOH*C>^VhW0g zEr5+25@N(n*Eq!bLyFZ;6(TjI7+%gWjne{(!C!R%4Ny*o0uwezWG&pnqgc_jwEAEl zpTRd8o~?dRXv;y>OXVDE3zWjVF!Y(zH;S+#bl{-lVs))B13y7Ev*ejE5=S!U%1rvx zmhgE!mQYYt;?ZSC2#0^jJt`arW}rXgft?U@NM6=d6bTG|Gu3lJv~r)jkVp}$c6ZUN zaV5i{sbD+ZJAy+cxaUr9+lSXE|5}k9!MzWUl>7e!Pk=0#1hAoe{4=h)Sg=k5#Ny=W zmV=-RFn_q!^AXwLhm{&mx+oVq^Ta`>V4aw#|1p!pEkdh>;Ix*6v0Avr9XaF@#%ciF zOS_fP{!Gi_tIb+&Ny4oJ&o8y;lA>qzf8|El3)8=y#YhB$HU~=t=>WTFUkpOso8b+8 zP2DjBVlSYnT9i<f(ssa-R_Ci3M%$8D_=DTlruDTJVDpIYt*$Piwg=%`i&L(AB!shn zld)zbhit{44(efSp*Y*^iqzC}H!qsiMno~f*i6!*m`wT)s&sL&Bqmj}*-_h=(-5Ae zC$^z8ZcF7d&@G-?o|l*YZF?A~Z<#K>)+!20u@!1mgjf+r7;siz@r~Ba3ce(jJX@-E zaA9QV2SGA3){=INBMBL*<oMGqxzB3YvUdC(;H0|ZX?)YOXtmE^7|pMBGKGW%Ft#pT z1YkPxN5+WTxS71~e841$AMTa5#JuA!w1}YX7Rd4*fyq#lv|yRIe><(PPku$oVl?b_ zJ~w-z1P=i|=ev1~inBYcAlmr?hDLnU`2sp1k(}Qi*4(*p+n2%l_QLbBXDW4jcGbDr zIXnd|i#F89tpBUAPu4H;efu!?eeIO0ICNm>rxCE~uMsg3NRRE=#RH4!%XX$eAWh6Z z4FMi)Yp8x@o)G&MK1zQ&7JircHJ83Yt^Z~<ukvSS&p$8Q#}ii{U*{Em|GC+cCtY=3 zuJ)<@+R*+L18eMs-q{oxSEXedr{A&yuB)uL&I1LSAd+)vtSi7WDSR-nfUM0i1ZQ(> zM7PNsOeu=kJXjH%2b3C6nmOvf!H3W~B;j>w6GegX82v1EXa>NLX#}^CuITzKF_N3i zgyw-QaAF|1SU9I4#=eQiY4XGA4WPBTd=!Lhaduh%)z%4kn~|T!hL6P$sTeW&I%D_( z(|USsJF{swZfHPIh$pJz8pWD?(!aw`se|%H)No;UWHM_M?>G0-nb^U0_z6ASf<3?w zaMF*COyGyGbE>m1+lO2cvDE*NPX^2YmC?`uE>Joab^NAoZfi%>fTh0EXq&6U<>X)n z1#d=L!UCF1Cz|Xt0`V+;4*Y0_N3;dqlz}Z?Fj+dGUE?*H=3S~V|Cyc}teJDrElA%* z0=kFxAYhyW8lAvBOQV*`4svB8CR5(vtPg))c6OnaV>%7G+8Gi%Q$^Nh)j1F_KwxRQ zjZJ(yUs}D=&M_UEtV|TaI{Ng@F<b_~z*8(pmEP(&1@(GXK)_)k2%?iRcs)(<)Ul_u zoH5{1a#q!C7q({A_J#f(5%kZrAOrNoA;Xd|D`1Thd3s_|*92Ta8}XZP*i#Yba2P5C z;#d~wQoCr`0WJ>+8>340AR|>-tzQ}{Iu=OMx40dUqGxB@<wxWRfIEKnE==tXU+r9A z*pfsf206tCSvkxC-@ZALoTjw-r?vaeu$b%Sy(~>SD3*f)TdT_XA+j^$C)0cv(q_5? zDc-_Pt8!8ko|H5rsmPEdlz0=77RHf``$$cv&?XJZLZyCB@D#bM#7HBHuZUtwETE5u zK^_ac0CO5CiHk;`snU$$q)fpl-!Un9{g74d#f(j;FF`UCZ_gU;wQJEMlp-XApDh2P zHfwuR1wX2ii#O}UX2mSh+r*PJhtvoRMr1GqpHvdO*PSW@ojX+qlS5ofBB@88Q3Iq= z@x)z_rJm5iT>iP_5+88jp!n=awjntLVZ3zPOs%4yTm)@h6UCjfYd3`q@}AWyR-45e zpnuaSK|c5p-vw7r2wIj)IosV9RQNb-+BPwpATB4Z^@E@i{5V1eH=w*MRIo{CigMtx zN<*-cLMAII4JU7S{U_7hV{RHfFv=lt0Uxb~LdD?q=oH7P&UP!&*beJ>*Rv%78p)u5 zNrF>(q)Rw~PtKPtBR(r5LcugFG%XXX4PLXS1&Yw7f9ON|R-qQv1?^}jB$Q+~l&d;s zh96j5fPuy~8pLl39K(zfq_|~t9=h8Y6~jV0XplO;PjjI$$@`$q5cMQ<qPD<VlOgIR zignLMS@cOTmnT)#lR&P~Nzn3D?N7b4)sxnqxX=%_a%f#R^u*y>YaQxd^RMiR*W_(p zlRK}`rItWX+jD`XMFw`oYt?<^B0rOENYQn}3uiLC84f{n>TwObW33*q!QtPk;l`Ap zSvPR2m2A^SAaEfs+D3$1FyT&tyd#ME&X%$S+(wb~l{L%)F0KbN;qfqS!%V&~a7=qU zfmw{ubN&n&8NPucf?2c;1T%ouAh3xg^|prDUuF&6H>OoHd3rLFsl^j<qr(a`3E9@y zXh6)4I}rxZF09D!ZW3!1qhhYAldoG@mU<4C@({a$_ED8Fg!@hAkF7#vFlmh7DYWvy zv9+y$PEVnxN5<3ykBI%f$NM|w;m7os2}sST)SnQ+JuqPxAzfMkEvd<nKfr{EVx{Bv zV9`l&d~|6b;*2;hFiWb&RqXT2obV8oSDX1RpDa?;{usM(X8<$e#1j~g6OVRb!z^n& zFwIpJMRnnyEu`0tYz-gu`Bxv(+gj!Rl0tf0!Kt4J%MmC%9-L}xn!*TQ;zVM!ji7)# z@Kaya)-poG4a2^k6f$fNVFgO}KJsZYSR9k_#m*kNS$%EV(F}duih!_oXcP2Aa#T<o z9P+i-reoh{t_<75mFOY>Hgm*#gs38o_Xw$OIyA!JgJCOU70gT-`YS~zNdd>_|If0T zgWvN0H{JtlF$olzQ2$LPG?U8HbZ8|;&AF(7Ms|agfoaUPXUEA#OF?C3;TQZWH3=h} zO8SpUb|l5yBwqwg{VQ_}qts&9!rru;De-#{fESWq1CuMk&l~Yfl~jArr08y?ia+Be zFm|dpL4$-ax;uVSqwD{$hA-V@K0juVtY{$8Dwg+51kiVmu=Col=uCiRU6%$uW;%lJ zHEIA)o>q0$ANt(wzt+M1HL8ulFT=E|fB5hUIjg^+gVlYnwQjxMvz=@S-bPgbMocsQ zmQwpT;bKYbutGo<48~#AT>~aqhc_O$z$w%1PVyLXsS{tyRNqYf`9l}u(hW$KttKtO zG=`>jlYxLSNN$5|OHcTfl!H#vqs(5j6S8}<%;Qc-CJyouy)p#$MCNV-u=>pIggPYy z2}n14L==(klLZ`hFKYWCiQovU<`d{Q5l0{#n||FfHDQ>q-bsF7!}CP-J=?!%A8-ac zLdbtiq&!t7V0xu(=O%>1Ro~EvFw;Tm7Lxd@XfYT`|CF-BcI8#7D>6kr?dnh4enFZ3 zpE?~~Abae`g;h}5Al=F-Sj(u=*85Pi=6v&6vaAyRTjmf=FKX5NH-ElkRA{E#EN6rQ z)&xo?VX`7aUeo|IQ>BMRX&{vV8|C?}KQ46kw&@hhoa~qrsHgfL_r@5kPWE(Su&Si> zsGjQGr!D;IY#fUg6Na<BMl_V|);|e#Q%yC!JWq;K`DZS??SBgEm@{^4>ccUkmfY=s z)>1O#NV?oMU|UvmB$z&|NT86XCTpn%QVtb%!H+;9M}!6SgGGvetWn5N)Q%;sw@rN7 zSciPv6t~LhGfy?v0q&^&5W1f-*c&w1q<D3&iPd;0X%5_gisaquD)4KII?BE$4YB=? z+Ltw4`3>f{p(GKdZGEk<M9}LgY!9v=pLVx;Fo}Hb;on;k_xf9o9ny?ihW^8P)J;@6 z%R*NbTs+(HsuYwW4AF{-&L9%|07R&>8(U~e7r8*-IsD`oB;W2G-p!BOSm%eTgo@rF znYg1hpv{xtD&90&v2i&1sX?Wjh4oDGF&of@oB>_m0)`R(dX1cOFtJ;^xUEheI}eYS zvc936u+0+ACks0m+HiT_8&-t+f>#ZhT?+v+sOU{YZ*14M<x6hp=ihjWhS2x5$o?fk z7;mt~L%%<xX)GeNjoM}4VWNQ46^G_TI)B)71w-)6x$_!4dDnkcH>;11!4sy6iR%Bg zbA%ulyVYxc-%5b5)g#6V`oDgR{>1CPh+zGbKd>Ah64rg#1$vO2k*_^Cfk^7c#hFJt z^&(fq{MrdEbb!qI9tSEHJYN)Ln`1P2pQ^`gnA+IDu9Z0UJ0G%6NGHLRo&2871A@ya zm5(W9vCgu|wI~uQjpeugr0{^s%TBCACRSeI>;*M(5!P(=-PWXiX0cNL>cYdW?eqwB zAHPlfmpXN7hXT^SXErK8o`e=aK=P3xc&%Y^b`7mV797=8_RwG@^9rxrjwe=+bnGzG zzllxV)p7^buFOzF^AqAw%SheZI!;4_v`n6b0?c>Ier|Rv-0)dQ4Af&|fencbyT^2v z_I=8WHr1lBV>)kph3{KmC-@jWYGvT$F&(&lmkPkoz;Qm%vpe|!2fHA-Ln$oB+yRF_ zFMlX(2cS>t%&oGdA6C|fP?*x{HsiQF9CKpIsykWh^evr~*pWQMj0(<Rx2XZSxFr7( zL=y0oA`coOoTNbYDyy&`u#rhrcC-tn(PY&*=o*H&zjgB=>*gH_a<NuAx2p(j$WEh? z7Hx&MXD4u9R5uW(px)C<30D+drlU;tjMsh3S@7}Yg#VVAn;prW9W||Sznf7#C?YZ+ zU3EvRC(s~;iOL1-HFJ`Zl;#}sFe^R_Ixg3N9$+m1#{GV6=#}ZJUOZ&I5aal8>;+@5 z9_=h_vz}tb{DE_T3T^-<1ss0NLHP!CJ1KkzZKD2xox#k13tr`B2^JSs=2e=P#Mgsl zwz($c>y+YPTYpLr5=IICQPaiaH<3+mLMTIYN9MLT$dp3ZW_{kv@#Piq`iw#i(p1so zN38fO^YI-FL%h|<beN-p(oJ+J>?+@~tj+~XQzux!0-O^F<-NZmMW!IDo)IPT>s<1P zNP^^14wpRrzZ8gF{Slt^pU}Hd&8{1hQ%3^XYBR@gWhNh_-v2A*wB#Vf<mMfpIbEAs zcwT3wCK`R8GtM`qbua0#X%48}CQF<;&S1!&iyN`W3|S4wsVFcyzo2m>#obvhV`YqU zKEMcGg`=JILp<*8yQCu<d1F1-V&S&ls$ocO^eTl*F>wUDC;d;T0d<|Idu~6L#-$Yg zY+3Nc!igMaA~IvvBk(O>DnYxghPb{p5O3#*U~c88rOG>hLY!avw%Bdrx~ID+)R!o{ z>N&7B+Z5YWTC1*k1IHg5wGk4hw}8&CcXVbA#f$?(=MvPpAlbl>Gczwe>mY;H6ZQmc zHBms?dtK<?BTV~@OZ^Y~gvAo%kIFB3yshGeBj$N0kUz9^LBdgITTNv%(a~YQp_4C6 zCiM4n?v&I?A7$2tLF?R(Tk}S<-c?7qLpWL#kwv30NM1sfA2v-v8bk_aIF-re&fH#r zEs)y8yP+ct(rc&xMGcfRiDF{8LsgGOKMd@cw5kB3N_)~N23zb&#}{yTg8r2{wZx0! zTkyhR2C9dws-anJ0x;G9O<UNb$&YGh<RNz&`t<)Ipt9E|^Gx|HzW#qIX_!0jdAOCm z%pKX-Eo04j<A^<PT&5Tm<uXMyAZTkl)k0g_f}_>-OzB?MJ@-bWNG5Tye^j&L54(cc zDE2o~-{So6`jUGllv_qvi`O&mPUP)2;|=Hh{nxKRG-PP85m>tMq^ME;TH3USPbQ+8 zc&N4&A(Z3_a^@i|DvGgpyNbccYZ1{Aft*II=(N;|&MDLimbqIh1<e``nb{esr8qFs z^B^Mw)*$f2!qPJNPT_;$1}dlVic02c=hDr18lI4ah=L3Dpe>+*4lKMU>HnQ4IG2yo zSL`-87;H1e|4^plO=+`k+(0n&BlCU$CiSBd|HW_?rQnRy)JW}7UvlruED9_=k;gI3 zFg+Ln|6Q`#j(3v8ua70}TkB<Jo>+bF|D*L8Tdbk;vRj;=X+3nS=_D2XOT%bP8pzjS zy#aA$odcJJ{-eUOr1;^{ah(djj7w4LR?~PGGcG|`)`-SsM-d6^iMvwGiv}Tb{F4nL z2=36;yzr^)|AY5z=eUV)lnJ^a4H8WMnCJhS5q%06gt4sBr%bqrj{t+utE3i~S(T-~ z^j&(Y-Fw0j3hbEAeg8SK?7?Hg0-tZPMgsY~8;EDgLBr`>XOU|;u~}d+jP!t}I`G6- zxy$rnX0eqIP~i<hg91<lbNlM2y<w?h2;WXZbllPFNEh75`dHSW<cYjqT&(+uNf860 z<?FEkEhfm_Jsz%w<QM(t)oXK!dIDK8d>{QgG+0K-pkA9BE31x*{mJ7)SXLNM%j2UE z6VHEZ9z0ViqJ?#J3gMknys`QPcl)fSewdlyk*jl!cM1;+Xr>+mg}S?9xe5`(R8xEy zZ+0<89U}H;HkEW(i^Md<sTPWjWo#Bi0c%qknGhuAJCYf9o608@=9cLSezb~aqjQS> zpU4-s*_0Ls8-TZIOdJuSb(ulgF)9PP4D(p43`t96NLHx~<f|t+DgFagK#xsh(=IM) zZf49$p^y-6utVA6#uJHVp)%4)xMaR@w)@qgM8%J{Zd7Q-64oV_TTv6(G~l1FQrId6 zc<MTUEmp|pp$$#00Lxqd9?dZGt6K8iK?--an#V|Tw7c~-QHdTED=%L8kSR?}SU@d* z@4sJoD1Y3&9B?cy#uUQ8Azk6AErfo==3NmmM5Ahbnfs8e10WDfS1{Iqakw?*jo`3a zHt`elA(9h23$!KIn<GGp6G2*6@MDhV!Zl@uI7Zn{CS<=KRq=L%x|%@Af<Snob#tb| z0zg;u7r`Y&LQI=N3glsJX1jRDXdHOrSXbqP6kiGBTu!P-qh7_-;?*((U0Xx61|PDV zbY*m||F}S7(_+{FA>v7{6rUm|ss9sV>FDmcPLjb+%$}YcO2+GY9}M|HsuHYS*rDv? zCUrZhfBu)h^l@E(L#kk@9LBp+UPTR{N~+2&{=m$`u=p@C5JeEvmZq-EHybN*R&_nc z(11-k5=xzFC?b=1UJ-^R#plHF>*HO(;@ly|6s0bRIxR1%VsXke*rmF^YW|VW-2UWm ze{}itzjpmi<^`Mo?2W(m@t^sPhacT`(=j`RhHBb1SSAb;6&{2OXibxzn19dT{{2Ju z{n6cDd@{L-4e$@X>yO^`*lRy{@~@Mdj_D*Dcu(~+CMkdpI}hPPhQ%BjYcVbK);t&( z@uCXYzHuutEfh0&SUvvYk4JH&<0IB&hBZcbgmz~7^y+Vg4+Sc?+xRXHaVE{@fFaa0 zIXOy`L~fpjCT(HBjI0Y2#Vs0n*++JP_U{ojF!^U0-?Tq4b=fMuWky#qUBIG>my6vI zUDEJzTTavg>`<b|r4k;Z0hT@CIz+6U#Jq9Nu0Y9*)I#YK?okbnml8~#bWY!mY@TH} z7(-Bf+iFzt60vu49pGVvmuU4x+Z15lB0(lGVYv%zk2aWlkU`)GQdq8kD}C9da+4}l z7b=5dnEql5R7)G+7(Y@6&obYI0jv175#=Whc%5#2Gs*5wlr)h|7+L0xZVyshbl_=Q z@m8x&8e!CiGMNu?0Kf0P=%W9#nlJ{4I(54q2cTq)baeKYT9=5Q;GBY{HhB-K<w-O= zN4ZiZ`M5mUe@rN6Fyqs!WT(0|j5=jnK>s#xb$j-zNhyp1F&YP_b{UjCMq+z*93B$1 z1XxWYilB017Sa>q8kX5H5R9*38S!M*iOC?qHw5=J^ZE%_#=e?9-8Hg&4SJyv9>-&N z>!c$lllmjRUi5Y3zE$9>ZnZKz+~I}atC5KPqu~VZKf(M~n=dM)$5vl!2!w#ayHR~G z!D<P7@a~VS$a=Gw1;>l8CV%amyw4iu-aUo8Tj-V(L@|=<uJv+VPF-PIP*B9Y4dv`< z^?`FPNtNV!x{MPnBY~KYuyFZm>%~H(Ae6y=&ElT4<EIO=h;Ejl6n-@YPIvH8>WEPe zHnMYH;Encs+2-3hGQZ?JVb=1sZ<GHpf9yaFib3~f_ap)1T$f5#HSA|)h+*iIDm<AW z3x$X!2!M310cTcQsP0du@Le)V-**lm85x-XEg*d%WyF47Ts=Z5wG<Jhm?-H#>7rD^ zN*9ybO<AX?TT3b?+#ALX=HLx4ezn`|yoV@T?Axb_N=?a<c_J?!2UX#y<CH6od}7~W z*fN`=H322Kr5&)$c_B*F_WSGDK(7Gu30)IM1pXzcP5ej|=7;0mfi(<q$%4%ppPHG@ zcdK#a1ZOl?ef@XI=Kgz4UUb{?VzQAJy+&Si>9Y=_JGBnCT3!%xChHhBNxa5bd4cSu zgBZT7$Tspq<$}Br3UnFRWRw_|bxK2$`~q@$136jcXq$pu$*L=?UFU>Pss+If@Y!1U zq5&N!N=lT4gb)kCmvE5-$mnpRRX$1eK?%@~xxh?W+WV-hUJoYB%M<PgjEgB#A_j(f z338C)nhD+3)3w6{r%IA3Bv~Ut*GR4Dh_DlFsZ}rT(dH}M0{tDe`6~Bl#Wpe3YPoou zCq=o)f_51+m}*rL6$Ksx*rO#<e7n&Sy>tsBb)FzPZ%$p+7^H$LP5UTnX4475YOO%F zhmCJGY`U0%Ye*|*Q7$R|6O!1flFCL+gWLrGSV}BV5CM`BphygMD&bCM?SwFyz!%mu zbW=J)?Lm@>0@QChErwwR6D-q2HYSHx2Je2rC8iuYcy^NN6UXm?fA>#{|A)}e(C`OC z&KMRt;$=#T4aOxIa{nQfExyCpzQ!s@GiWgyq<5e5Ir(7ErOxV9k>64g1@-g%+pXvO zttytAQX7j+lDA*{goX|LOdEvCVNEra=WQy_8!FElD(|Qd6goAPLlYM!v@L@NG;r?% z?AQGawhLk8km(KpF63o1eK0A0q}|D&vi^&84*v?LNA70eFawC)0<+-A2kcstp1yc0 zW(xC?u~Gt6=XNw-a=^|qfWg@*LKDm@Ea^J4*e9xM!|zr0+gad$SfdkFsS3o7I2_dO z7B%)F12|`|pfT$SIGEK2ms1h=mrmtJuYeVz*GggxCg?Ltks38b>e_KjNDC-hDwK(O z@R2>qa6e_S!(33lj7i$4B!PHGI$J$W6%$Prp{SOP*mvfCtw|zWbVBKbt7U3<!tUmA zj~&r_lkm?*3l9jx;ToXBe{-<A95iqXid{kSbl|p35kTvKyJz60a}nH%KOE{bS>%k3 zf<xNYfE$xTMGV}ngMMmo+aWmt+<p}u@=Jmxg4<zbq3l2%3r)K2y5MfASOacD!`0x% zBhmslh`~ujax`T07;xL@8l$I0af`+k9I!sqhA~b1`I;fvDxm?&{XydR^2pRq>!5x+ zO_zd0E?u#>;-8_dHH{n|@zK?G3M@w96AdfRtCP;4IeMe|m0$;fh<WvC*X{G_o|wUS zeO^88y8bVS$(k!XN=H=4f3^_$K&yx*WX`|(5c#KSgnXN!meJTL#OAv+b3tr=sP+6C zdM<u3wMgJ#GB?X|FjKKWlaYFwiOV253S0Iy+t}Eohxa^4{$*4BJbqIvhk6Ps&{skx z(ES!|Q`HG^7<Syat-uT%U?Nzk#ViL{i9A2iy1B*N8WO4*Y-^iY^~FzQUt)&|6Nw#G z1V;bNJG@C9hMlOxT#ThP!c`p>+W;`;9cCCPa>yq?Qw#VY9yLD2sZh9IYjaHmylZoH z=sMK{Cw8O>Bz|JpIA{bgwSe+empcE>6ghSCUu>!K*Rqkw;F@=t!#&$AJgk;e=^FKE z_X2qDux+m=&2Hdi1Q#UiUPjj2Y4<Z##(JlD9A^$=-gLrw^`THW@!m?!sgxJ)R(9e& z%Oqad(l>azvUNi(p1VHvp852}RrK}?SY%1@X{g`QM8`WH5RkQqR3<EP6==I1jQ!Rw z9)>JwV)*NXfD@u+S%irMYl7ABRyOZi&`f)Q^j`~bT`j7h4FOn;2A7Nc55sWOZW(^f z<$|C#mkX3B1u?o@j7gzJ%H@JxYAzQ8dhoi)WUaj}lpE)Dp_w!Ax=gs&1+!Z0$EUCC zwWlveHP+L2@)T6z3iugQ1p#Q0D!63%OsT>GKQBmBI8X&afi0?#PX($`wuUN{yQT`_ z99mR?4lYlhR5LP3&&G<29ws6m@qeP#;Ie@Z*7IJ$5#7Vo0;9!Swm})9HyTQq0!_qB zbP2_nyRN)1N&lE!KVS$9SgUIM)SDRT60a;Riay1B#w|^ckYf;rO(#UE)poNFgS@=a ze4y%9(?MRo8kt@Tz;{}nF^<rS0F(Ze2oTu}99hCn5p^yLegZi%#s6$bV(&_41)8<z z5LQ=a#rM!yko?VZDC1*}jJ8;N?Y~EGQA@_l;!>+Lg6Q+5K*Z|e&FKh?I|12s5rgRy z&?!YF&{I><wdKih%(gt~I}sz==+ysJZ<m(kQWfO2Trhxy#V>7xxjiu-w#dy7vKA}F z*3P6zV2gW7%cFDp5pVhuNBqmKKvgN$8fSRetaN-U)r2mUqvx=zu$5{;Td8!4{lxnF zs9-hwDy)aFk4l?a0D`P>s%J{$R7aPP+;zA=rRy_h*(vyW1qtj&?W6J~rFtKgw!LZx z;a29JEFJ0wa=yd_0`)&FxIaf5s5|3BcrSOYGqW`gO3PWE?^ZQTvW#alkP@42>a!&{ zZDEud$yC`IZm@%aOiXvDOT|%A=$yq}X`>XNlLqHBbF1zq{ohg_hl;e(tFeD!r-CN^ z0|OBf_gbq<Rq|bckQzi4bp*w|s>z%j6vVxJvENcooeY4^(nG{d@2s0qJ`+<wEpzDY z1%f4&MdcKbecgDH5(|1RArgRL12<T!RZ7y^zzrUI-95{Rpl1qq|2LJPD87(2hvz_` ziBppo8Kl(7%GE>4n(fv<fr9uda3QTuSY&5d)DW@3yi!e(haqW_s6eE|><<iCu+B9} zbONg%DwU@fWW?pi^AMFZ%rB?FOmniI2a*jr?}X+{;+O<MW`OEaRYjo?$fbfb({|W+ z3h@UNvg5zya-MR}2}c$#45zAZVP%|DTOOHW1lamPGINUG0w3JbCgwxs;xOpml12>H z9UBIp-!@Q=fLV+p0eX>ZnTrlkTGr|VN&kI5KRI)>p!sAWWSM)c;v5Y!hc;u)`kzr= zT^bZ{g7ayZ>kS%MO0uo_(IsgQ76DS-D_Nu*rHcQRiVuVf%(mTF85gfYtM9Oh#64Rt z`uG<Myy-ux;=bQYI13>7Va*>l;N`JS6b{Gn_V6Mc%bnY`C`ae=>W&c0F8X2Czy;hv zq1BLPEAu(I@sERBq$6Syn0Cdo+-4l{Gy6O?PxurgYC~Cy$n`)8gSJpj#6ofI1w_J9 z4JxgoB(!Wi+Agz0e#<~Z8d2O!RQK8}%~%;Zyef<AWYOKuSP@(d5)OFH417qkJUPpz z)Cd}$VBlvnrGx%V01xL(1MpcMuYv8LWp-_cC=}zcU@W?LI3R$ILvjom88p2L!;$_| zqf5+9ny5&N=&$JJ^t8F!bXUzG9WU7%WlV`b+ZXyD7K#{WRH|`mROM0|qcoe)%t=Hr zD)MEUW#g9m_N|J9mkhOg-n>4-iLJgrP<<-8BiXJcgS=XSVd%UH`i+|RTw;xwGIfF0 zKVrF=<&Zn`+(!7yUyl=!CHCB#>4|ixfJ^KFiB|St4XUvJhpH<e(?(&tBwznNeRaXB zg0cc^Xr2DYECuhh<e}o|a!6kG_(Uwk7nV;nR&uaoKrz#j4gLSBqU8G>`-L_*3S1mh zR1%id*cS;3>i|``u3?!I5^dzlXJFl7t~DyJ@xQ1h8z766z5eg()triI&JqFOn3=Ql zbfFuLBI|#^>i8<g?bWN-UZ-T5P3rBeoU%XJ_yB%v^_fL4R*2a>rd8;P8(BwI!!{29 z%qPayN>AB@<lL@>pwIOR-5cxk6!c>Hj(#~v48eq}yh|GByK{TSXNMrDm<tt4cac+P z;ni}H2gWW@22@dVu^mDlTRZ9BBA)Dz|Jfexi04g7=+vsFJ>w5I8RZS`|H!KMS2cD1 zs6FbKg{?d>bE_2OzTG@n(!X#hUrPzr@f16fq;Hqnhdv&+`kp;fUo^)BBEd=OFUas3 zWfh+aH8<8y@k7~Y#B(0z103tyc5;!p{%Km1IV3X-Lx!>RIeFP~P}W!M(6OJD%@Fc8 zPvLLC{^~q`bJ@|+3h*{D>RLP^ntouU-%YC$@~+V5%y|DN)N^lKj6l<dM5Zf~igM!# zI)YD+RKEIpjv6QcgDP!TGgV?DT9x8Fsotzgo??{^l<2R`uI<v)*=V*MQ4c?5lfZ-R z2O@A~-P!d`E;crL%r6ZZGDe*N^h_^0RSmY0rTnMVU~y`vuffJ@5>7+>qF6-nu2$p{ z`g7uFBE-mc<9gZ{ltAVu@F~egHyy<EYXkIgQ+dQ8K~Ou6tq%)J89Wih?>BBF@i$Uw zHd-m1Dbt=2Dv&jHs=5*N!vA&ZUE5irG`-%_^jR29pF_nc1#mw`0Y7Rq_tZ8fWxGX( zGj8dp&i#Wp3fqlGKL*~H_CIJ~_u}CK#<YLGeWYREO>q_I<~hU|1K?#sRM(rjC=OuS zd(wYcC5Kws7u)+?y(uVw4r~9=Mwp_>LC{}<E!C!8>E~!aX!6A?(z+$q)1{dEKjX!) z)4425@B{p@SBpKgsxSRN@)Q8129Q@-|3j@lNQj=U5AZ-b$qA~&$kC;zCtB8D#%Z;@ zTv|Ui7p8D+)#o;MjhzjUiu>(khV(zGPUtKG_|8oK0lifz?2LFOSuXuYJRLg+G2MT# zP6r2NgY^Hz(~T%Qt@K(oyHK-X?5|<xu#-2+9PR&x)dwo6Yt^^(|3dHhDiR4#v9|<0 zYyrN@-m<r7RI*1%!cBqxErDcDvX_Io64Wu3&HJBIbtLhOYlLd5Z@Fleo~w`)cLv}U zGGRtjK^we;?QZlrr&p?4b(;7YV!Kg`Y#{)GE<F(b@Iw>92)CNh{0J$9Cl{X)*NE_^ zh_*FbZwY4L5RpvdR*m=~O4%=Rg^@?~yE&7%xwGKtOk2a8lH|@^<{$|S5=bI1iuR;R zu_n(nC$Kqvozw`h6jf9$zv7r#5cGPn%nw$}H@xE5bqD-M=1f-cVRGLf%R^pVXotq; z1$V73svb&WkI4igFg4IKXAv+9xj{f<25Es&1eY@9#Xn}ILJ8g_i5$IY2eoL<LbN*h z{Tn4!3wtaQG7F~+-kMBJyC!#-)sQYT_q#HZSW1`ye(F4aF95*Lr~qg3dja9(nY19d z(;<FfvIa2e2*Yf#%+>&3i0U3XpRbT#0A8n10I+tO7$k�UCzkjIjlIAHM}9XMz=} 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 azbyDa{jxvjgkm&B{rzw0m%sYu%l`-Ebx<?_ literal 0 HcmV?d00001 diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources new file mode 100644 index 00000000..43ce4cdf --- /dev/null +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -0,0 +1 @@ +bf264a97dc918f24ac00f0e9e0d0373b68391a96921b44345b466c2a087c5283 diff --git a/plugins/official/ai-completion/plugin.toml b/plugins/official/ai-completion/plugin.toml new file mode 100644 index 00000000..8cf1c563 --- /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 00000000..c07d60f6 --- /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>, Error> { + 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(Error::NotFound(format!("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 00000000..ea8c4bf7 --- /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 00000000..5c3d4f38 --- /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 00000000..2a04ad51 --- /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..b7508504180eb7431eca93351e0cd453684f8492 GIT binary patch literal 159016 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⁡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 literal 0 HcmV?d00001 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources new file mode 100644 index 00000000..6192fb92 --- /dev/null +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -0,0 +1 @@ +6095c7e30658b93d79270e6355d42941a4cfe13eeb8bada07acc7ae462194853 diff --git a/plugins/official/checkstyle/plugin.toml b/plugins/official/checkstyle/plugin.toml new file mode 100644 index 00000000..444157b6 --- /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 00000000..74f17cc4 --- /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>, Error> { + 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(Error::NotFound(format!("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 00000000..e0f64ffb --- /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 00000000..9e381e43 --- /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..539a1dce2bc8803bf0e4ce71e50f111fd4c8e620 GIT binary patch literal 130963 zcmdqK4Y*xbS?@dNm~(yXz4lIK(zFB`bgq@?-MOcMtxs)wYVR>Kr<B&>L-lf=%k|)~ zA=!}Zq)EE>ZfTF4W@FP*kdFdIt9+FYZ54_IQHxZKShPn#3Zho!ND=U$q85Q8=gP77 z_kZ6p=UQv;oir)sxzBScJ8OQ7k9WM^;~no9Gug9v^>mUZo!7O}-DP@kF<r>Rfqjdu zLwjC0cW5y`u=mK}q%(JT&kGOD?K_qy?H69Vv~=WfmZa%ICp$QI!(#jBo`t!?OUYP` z_P)8LJqHdYlXKS}-E(-KY9H(RwKX%bv@kcf<G|9~)mf6!rM0wX3wbhe=)mGqEZa(t zE~ZC9XED7d&zI(|UrHB~iL0+Yv~=Lmfx~m$bocI`+k5bZN3KtgCGEZYj~v)Lmv)mp z>AmQ{+@XCtuG@3y+PN&5NEfovE?f3|GEZ7oEK7Rn(N4P1>g+vo=-R6fFLoxEjx6mt zv}57OOBQdKc+r6c0OI<FTo0zZbNdb~*$`>KF0y1@dNfauw$r0i={4=n)pLuBdtNN~ zat%B!l3asIHzfIsjx1cAZjANP^+~>e>FPr&nB*@!vhRj0IV(LH0JpPr<jA2H?pZk7 z9&c(dF6~*m){0$sVD2SZa!$GsRzh||o^<v0=#hn`EZM9<3+lFy>INV6A{gE~cj(X# zjUr2)26`74)1x<}*GxuY3$1(hE*-dT&yt4FUYt8Lw^x{NJ4N-LCJ*g7x~TV#!*olM ztJ(D2Wb(k_#kmE;Sb9U!+eh`KIm2c8^kft_$)up&F?SvOlqKIW`CVyJ9yvO9_`tro zJr^B4bnS}|9Db&iSi0fp+~Th9dB*HBW?M<;>uI{X2by8J!w2T}2?VfnXf8=64j)<C z@uDNw9@fLofy0pbfG#KY?l~;r=Jq8?`$aIS)q;9+3kydUvLpeSQ0Va74HJ9!?^$5* za|=S|(IblomJW#hAxTg#AU2yjyf35f!k)t~hPQIPvFny`sPHRJjrfHnBx4gkoE?bT zx_Eir+`=MlFZT8vJ+Py`ncBPO=$;qC-Af1N7Q1-@M;=yd@bF}rCg-k1&nRZn_EQ3a zS`R86g3<XVDBFNkG=MQ`Bsz3rzb~ml@9sOY_u8wC9%;xz8iq*9bkD$t&1Oj}-Jhhp z(i|3fQ72*OoB!k0b4&Y=?EA5io}O`Z&(i+1o6e^p&4;BY@?^_erE3V%NeR?^`Vv*j zyo(0jmbiDZmrUi!cdpgQo})((-C!hGOs6ONX|iP{=G4HEg~(KSyGSAOD1JIWn(l@G zF!Oas_U<t*FpRWTpb^ao4i1F$U^6!Gk|$5C!5TL34DT~-ugU4dauKL@=bs>3C%wAe zMe)Ay$ex9LIi$I^Rt-~;H+JYDQo3hh@BY@2rTudYc`_{lK5}@+;*o0?_QIu!tNOM- zUTiHLSUNPf*!I_HcX2-|WAC*~kh_RAkIpSfSIzB{#=fSFLOnFMLr65)nY7!*J=fXY zIv*IKxA)pbo`lt{q@eA(y^A}LFT}=#auMCV?^+W|ahYp@*4o6<%UT0;I-AqonFh2g zHLaeYJ+l|jEv4z?)-?H!m6TqbTUt7B_{DYgZs&)MvPd^N3`>P147oT>Y3!Vuc6E_w zyztPGy$9jnqp4Kot~4A#DdZ?^l}KBYPf3%FFWIwr;GzRZo~cK>W}orB&-k~kiS{&n z)o!}#Tp@zK1l6+d+68B-B#~TUi<&~xHU}~Z%1ido9R{8}*|>P^3m5k;9C+c}j=Cb9 zes`Mmt<$}NZt<B{A3^^eIecL64to$`=yj&k-Jn0}g>-Urnw(X`Y6>tGoNAwg{^-{F zV+86)Lc05$JefLh_~^AuI~K8Nd#=uCa{9=%OQTOTlIf-=AI3G|>_dC7pHRg|ydx@L zoD0B&T|~YqZ*)gg$St+BZVV@#bbo5vpk1qwi3F_mYf_DY@(gnecD!T(mm|fv|Ce+= zNvRl2jZ9#^(5i6MR06o?uHA%JUN`KGqTALkYTN;f?Re3lYZv#Y>(c|>VYOmJMU4a5 z5H;N}`Lr}SyC#a`Vlk3seS0H3G%=3r&iAIf4N0^<{zkHXAWe6{S7<M!0($ME-!vQc zvG>q{XZq9F$A(k(vGKI}fOKp0!PD5sS?vuP#~2fJX5%n6t>muJQB1^995_q|Pfe3e zk?Cy^2M)*f&+cqXcjwWa!79;0o~&2Zxx?2TSU7T6W-Seq-<c*)YZ@4N6`MJy^K@yK zd8+S9Yqv{EA)mYcfK*H)KEk4&#fneqoMUpf4(H+O1BY=RO<b~ca*!rZZ+aYm8|!ZF zJjLp+Z{9kmP+1ynb!C4YYd^Jp7MNYXqT|lLOLw;=M^DR>6|W+X91Ow|CViu@)N~DJ zfa&}sVB6c+@wx3ybX=^2y0a_xyafU@&BhTqVVkbcTG_Q&H9yIy$9VSXkJI^gJb})| z!>8zc$jslF-u%z)gvnHzFrc@mXJ3^bOOIU@jvY(5O0LR}@xSD%jDP(x|F-qa?pw!h z<icMkS0%hlZ{(jOsmml+wXAIY%S&sK->$vzcGMy*@s#3RB*VAKRlT~Pl?~sR+_K8K zUg>f?^*=7mxl6EP-@=ijKQ?@ygi|_DPg_xO#QRIaX>=Q%#$kWaRZ7xRc6(B9^Kt5( z((innx{v2>CY{G|K;)?;-8C%m?4}i1gx!uTBw@8<G5eGiC2Kz=32PdW*>hKvA2sAB ztFvWA9hcu|{gZG?jW+xA6_pz;U_B?{)GBTEJ5E;Jfk?v1HTdjGm7fI1^_aSzHEq5o zZcpO;ty$LGzoff1(jVV7Nz%1zp2{OhvbF1+mTPjd4jZN(Bl0sE2PIk4=FSccb28yj zt?(>nM;wq9MQUBz6yZTKu?ANS4bJQuc4|#l8nF|zYZ;i6Rcc^bRY|~w$91-6*YMMm z$?Tr&-B~Z~cBi_5|93;`|C{P%8FLRN*<=5k3f<{-S-0EGy6gF0k+uVM+Mx)&EaiDC zZD%258>Xn+nzp7AHPG%BMZ48q-=f*+Zg;FryGf_$ivOm=RF)GZOQ)x^^~uKWM%qZy zZlKyU&T`tK>$D5NbldBN)>LnLs%fn|owizOuRDQbk+pi=^`WI2KrqqkGV$dWgP7)d zp7DaRtyY$#nI@50)@tQE%=nl8(pJ7cWYfL%tvsinUW<7E-Oy5^fA}A*w33O52~6x{ zZz9kXSGj)pKMtAh0*}+Y)eGGeeDu2Xn0NEDx~<lF5ZLWfhW~Z*bxAjCx7MxOl;_i} z>FHLBid}wMSvJ8K^}hgA0s<J1ra$_ltt?G8v_F-8=drX}UOtiZjwO}-lj<tyAG^wR zW&eEUPrTIaf9k*QioY$DcqqPnAU>Sb!;i(AN8|50J^WI<dE;BWnL-cmjn$7o><_;j z3vJNL&-{@;y!SKyyS)5>LHdXG@1MNapMKKr{wV2nzM4(7ub#X5$ifXFId3oVrXMHL zwe;gFC(e71Tzxgs+&FRGx^V5`<UfD!$%`L4@S?fBH|#w$=kbfJ3M43onEb@~C$D7j zy`u|9t|RCdqcK|v5$q$J_tG=0Mp&9QlK(O|jd9%gzppH0@rNDW^$I|a&8#eZ(xEP= zs~SGV2w!%3rQ^VUa&_sIa1c1Hk$x4SL<q}N{SeWC<b7e{R}gbp_W%C%@Zs!5cV`PX z{QIQ`(!WXnd-|pHrtqrl(e!_&H;4CyH-`U~{#3X-{9<@j_@(gf@apiMa8I~B{AK#i z@Q!eA`04Qaa9em?_-y*x@C)JR!+XOohg-wT!_S4E33r5_4KE9?37<*d7Ty%z9Nrr4 z3~vc{g|~-ShIfTGgja-L4SyLv8y*Rt3x5?pAHEr0p52nYBD*>JmF)f5tI?D1$$mL| zQ}&_k_p;y3K9W6<P5#-f?RRH^fPR|Z7<PqSRZ^xiA=$9Z{}TIWkUneLA3ke3-TE>; zufF-w6U)hb@!p%bNqX_&gZ}XNPujyW-5&CODAOvrAmn9IhMg<~ybV2dY^$|fl?Mme zHoH{EkJtHhC<B1?b6Pxh0V<3d0yQ6m2w%6~no4`c%K<T|W~y#+ldkldI%N<PK~*=G z^)TM^u@ekq(7GUWdu3LJvNaQ?HrNmit}@%6CA(ks@PoJf?ML7HsoywuvmSJJf994~ zzyEhX_sS1`Il1}xl{HQVS@rWjt>&tyms#~@Yq_}hR0zt-92D&gMVV~{XUpJei=z^> zWi(RE3)rm6=K=i0O=_IG&GW@gY8I4cR$s`?0Q`b5#W+R1Dy1d`DhAEFs+@N`udhk< zDhDspuj*Da#j=C?_AG7H1bQeLq0mVf!wF}-!X;3s3x)W}`z@ze`xNx*oP=I*&8g_s zY3S8Cb9y}sMrsSpj(+lf8&pH~?gim8U2PA^&TN-4L%ZyNyAvQiFYXk5#<2^^lj_Dx zr!%3yy7-%uiGCkSP9N7!>@#MZPvM#z3pX}$bZ0m?Xpak$2}f47o^3QqlIm+8e#Ph8 zi?C5Xzk}N^CW{NaYFFQ2bgkl-B`8A2_0uU2?@Q;aegzj*Y4N`L)%|HdQ<3|4`fPA$ zHz(5KU!%BNH4w16T3%PX)KPyT?Y9Cbme|@qa7vj8e?6h{0t6x{k;y53a!6e!yC36! z&)gYUkLZ<cHTP1z_AcAi)O_(lfk~qcfDET>Zx0WogU-%unNjKP{&djYnO#r2ot@Tv zcc)a7=@V6*f)>XvMO|bC?Jd{cZO=}mJF~*t$R6Z!vtK^MWf>(FbW}6*`~xueInF@F z%|CNO8lfnDUyrKC(rX4m|5oABGAvZ-H5Vm2;VQa5PyfsUr23f&Tl7y^{Jo$Uh|>_K zc6H%A^czgja6VuBv$14bn7Xh^oH&>CTU<_p2sGWi^^{%#ntA%{{v^0Vd-R3EN!XoI zEzy9Ub`4B&;r}YVlt%%Tff8y}n-5j{=c~<!`z;p=AQ`04KE2{xdJJk##g3swr<0yq zhJMfSp9jTDD76QK{oV!PgnlP3fC~J!tDWem`&0yx;a`}p_#bgdKy*ntiSoH1ynwRM zqk1qcPB>bH3`AI9!yO+}W2iZGrcSIgI#J8C`muywtmAg|psv+<^+?m>*VUs*3(Z|P zXjKyu85e=a;pIG1o(5;=V$ed-{k2(uLAoOe7ZI7^(KD0e`xCXuSe5_@Y8~YV$Pc0X z{K55Qdn?*k2H^fQwhFnagm^|IeOZE9-Tj6W@RP&mRzOYA|MTlnZVy-ObR_U}f5Z>0 zf2H`oU8NT#cL+0J>~{SE)ouEP@5JNF@mfrNYYmE68|pYX#r;kN(;Qj#uWs_BDBtM3 z-YM>_OVo9E`DTBa7w^~0t;aQ5QPG-dnui5`(<{STW)c~`D7^J%N6!PgzNJQ$;I2SS zr2aUyKhvP@8^DoO?|#`z)S(7q|1;s6G&~cQL-E@~;ERXiHC^~M5<{s=21zY8{Y+v} z<v1Iku);FW2nH%;evp_%Oz?Jdd@B_ZTkX}YH(CdaAGJ;v&9o2{Su0e(>zMAFok61S z*$o<&Z_AInCWDZ*qWl3V%Fsh>tahWJSKha)J3};9z26uy`?DifKdk8xN<$Ry5E<ZM zNe_sT-=XK|F82U7qq0`IYHcBol33rhOh2z>`rC5V+H1OM(e&dQLHsqWf3ebA+dS^A zsRLPitUo_;QqW@CLxCdqE9?U<m!9DV;F-})%lrU(%!_ww%s@EeWR1B2+XEH_I><9i zAOpNH`)&6Jew{$TF{i@#zhz?Tx&r9s>lZ7LBf0F{brUSK=dE_9J0XKBQ(}z1{>iwB zT%E`au5Ptg_wjlOOYfBgLU3e4?Qh<euIb3YT&Ls8lw+PuDPKd*+iFkd61SDR<j2UC zAtjeJLSl93FB$iKF6o<spiXpRE_)mwM$U`o75t<&|1n>Dsx>CsmtizcDcQzKUL66i z{gIil%_W54F-QnF;{MpM&}{N|o+IaH#;-4t>r(;_u8*39QIo06%*XcZvYlDiie=b| z=;%BjNf>c%zEJn&-pxwi2Amp(_6wbdWKX&S^RPD!Op9$Ew4yg7+38OJcWK@Xc{iip z45qtu!dVH;$)JWDC;PVwmzJ3vCxr1IOc-sTm(-?fCOqeYa4V3YA^IVhzTQPuygKGX zg6&qwA>shxa%%3MjG!q@n$ml@9oWiPB0EYe=B7Gp2;V3KN-m+Wf7MNdJgC?SFYv;= zgUcM%@PW<IG#P7Gk77+eB+#X!Yu|JDl-_9KyvD}NoQ92=9J4V{DvDu<zciRTe4|Fj z3D&Uwx1nRWtLYhC=;jT><hBD`+?Lh`=mFxLwH_$O43KnMWmaSDsDw<$iikkon3**r z0=!m403R$b^EJ(kBv%oE{9B3$q{E0ndRh^I$PMH61y0^34W69F?EN>#v6y<r+A4rW zS%lHpj(IYA2o}9}6;`nrSK+GL&(<>57FWUG3yU$XlDNL8Vq8T&Ne@IbyIV0&PgllO zcyO|~N;1S^a(Y;_QN|cZ`YoU@`<BpWOlaskITP9hw_(B+=zDUQ@LNNlf>5W;eK;a* z91)?vJ4mZ=`E*jj%Ln_R3UZ3YRJdH3IIM*l9iSbslKi7d7y5I)-{uuI`G@2tmf1mS zwW{uXU5Aob6ZwW=X`UWlI?oenc6iK{Yf2M6Yh13KyztDta1<}hi2KpzIs6EB46^wu z&@pQxZ%EU8MEL|CF#<u?S*?9G4Nw|00p-*xTV;O5x%@IDld0|?KPZ@?r#i2uidULp zF!B?#(9rwHipMkP--M=Q!l#g(qB6xVnl%dzzI(4FZLJ21P-zVmAmlQ(^xV&DVJ#e% zO@sDx1qITEgKM2*>Q!OFyl%!lb!fhy>RHlHGRRO)a5+Diw9<p#bEOAVp$Alew;7o6 zxi|6CD<=sDFxe`&=~9burGd^r#eDHs20w$LbNM}7S#%QO;0)5Ia1P-3(?Y&NQ^X6H z+KX@jXP{=Ya<Z($l=pj>^5;A>d0JGOaw>JdO;mdKN-DiRuu8R+r^`tNYn+5ij$PxI zDW_4Z%9a#@EHm8JgMILY(2Ub(!YMJC?W$6kr#0vag9`A#lVKMGfp%R9S<>$*m>@nX zC!wDH>FRA>cIJ!E8E}-bxa?rcogvW)a#-9aDX01nBUJ!J^y)HU_A45M&Nz({g-%UM zXejcPScVZ2)|%q<#HnyXO?{gPb<avdVH!_EoP_cz7p9^SrbM$v(MvwE(`&UnNhqn@ z?L#kP@qQvIK_`q~R}KjZ35pDQ+)7G2I}+66HLM9EPaqi?tHT`iGJsT7s5T*U$f2mx zs{YW!7>c}k0LTf?yCLD}Auqd;@MeHfTa8T0@h`igCrsy}Y0<?}S{R-hqiHRcz4wH) zTk|;qf)PG1TpLYkWkza+X{OLX+#fLdov_Q)Oy^SOT2l3B0@MAh*1&M`4^08tWio(g zr-U4FHqfDO22|RlO5#^dmq68dWw7W+u`_IQ#GpsOZ_3TK`Qn$I_B0_@Pa%N_Gf4Gg zrbax@tfSiEYTETP&$g&Li9{)kbG1Ee*7}TH(OK8841?ANl<6V`ttsiU(9HFF|2b_Y zwJ3!coX2cICSb%9{)2knAQ=P+wj5#a^7u|uXayeh7V8C`ir;aYB2=(kp-G%ASLpT7 zY`kLJmL=Oj1`#)^qM^QIOLT*@_<2K-3|yHOZ_!PIC0Nz3rxX+>`fSd0nQiz<72}VY zyK9pAjVY1hxS-RFrqKf*&$BQ(zdylxQtt3b@fYt^4NPG5avHtfwOZJ<`aiXw5Svxs z7RNvb633WKJdiQj=?WrZFa5Tc!C{?fp3sRJpXxMEG!Q+(Q;4-NF!rR?JUJepV7%#F z!tF)%Q=kuEcH-{*FrA*|r@b@GXiyixk@x`2V&mLd{t6BOoN_V0M3F+I0(a$n@pk$V zzLA2$6*I-fcA1*v%0g546)OCDj3p7+?y3__XrPjibOmbBpQvt$z@wex0B?;?q--2f zuka`0Ta8a(#u33==X1wQd}LtwR~i`O`}PeC;Cx`{tT=Pc6~b|r>3txiX{~}DB2=^n zD|OvC8p9~OBY%wXYLOKiS{th=AM0)D7>T-&h}d{dEyCk@^=X10BlKdrTD;8(=3FYW zJ+C_E9YHK&@4^yS&)*rgNHyo0<9bVRjgWA#9;mI4h@NU=v)WK;Ro%8Tb2zq#Eju&9 z^NP{M8pZ)N`Gqf@Z=Uy-N3r%$GqL_;I>Z`^dx*5q$RFK8SMeJhu$sJ#Xf@xywMN64 zaIOPPgxEtcjCIp+0j`G>STNOMHJLNO=$K|}MtBQJPBkNpc*bUZeVU;Nv}t?yV43O5 zgXKrQE2FJu<j9V?ufMACz;>pSo;Ic~sS=7`u=nPv|5|g237LtZ{m^)@y~Q;r6J`|A zB5ng@DRF?nIDP(}H|Pc~Eq>o_q3lT5NZvz!u$nn+X1w@t1ffy2?<CP}IQ5do1{+K{ zG}jvbXDDF>lG;+Ff<}t8<%(3&R|kW&&^mI5<mk=}g0z${V&PqjVHFPu9C7=wimqqo zaa)PaFjcc@)+a_-QMm=R6F~q6_2o1oYh+3*8ay*Cs0j#fG03Gq+h#Uj&(1SPK;;P4 zGl$SKvx_psD=1jJOCSmp(halG1(t5OaA$TIb-9|=!awau<wR9$LsGU0TC%uLP4K!| z;_v+%&>R)jFyhU?@y!f7T;h(rT2kJdrFY>UQMXuRQ(_A%Ma6Ge+n}#rE9&)jBDY=z z*JYHV!g90Vq|U~%`~z!pLSSrlI51ROv}`-1jJ3GMIk|WJ82hHKd<8lFfN|9zJ6KH} z*5nhsWekR<GascUFq-5MRhu8=Hp5Ge>3pIq2{i^r2#PnD@*Boyo@mF8>2G_-v#Un@ z78`(8cusS~w?Cl~&#dbBHL+vl>-13l#wXPA>Jh*8TORQZL(Pq29GT0xe8^ss4>;>! z)xNGTzRil;V#i1(Lso9XAe@@JetPAQUwS%hy&BgaF~P)OoF=YEOu`6jPfAgIP)+rA ztfey?1GQ>s^hd9EohgBp8a%CN$~Cwz>8+u`>!mgm58Yr7Ty+nXv>q46O=G2dy_-iY ztTc6;!2l*YHfLdnpQ%CDW#1BCI@OG=Os9kjX66UPjPQw>m$2adhfX}*#3U(0W~c0G z!U+iSKD0m*V|YXTdiw|%Lv}%1F>A2E))Z<?1A{3Tm4z&9Vt9hZSIr;0EHO1E+DfqW zqk{4eU@+({wLGH6#^{YVS})^|5c7?J6gi>7BAhjCqG~j4ce!*QF;A_y22Fe|l1S9j z(r7fH1BKb;m{F}JF;(s`0L_bIV2pLS-mp3h!$6qZj8}gy?VhAM`QG>oTHeH^Bx@5e z((BX(anq6|(U)38)j;h7oXyLHJnGnw6%e9iS~4MlC@Q92w1VN(+`@~ibgBAxq?8r^ z)%d#iNbu-yTJ?!ZSv4(Pfhby#hfF=0OO+P8(Mon5vpOc-V1Ul6e2Ey7+2|A-tVW2E zz(u$|#UH4eC3x~snR$?%IiYd@l0o-!lA;LC=z8+<#dFIE>gb}+45?L$fva$xR%`>r zmMz5zQ0r|4ki8?&4|IQ|ZUBlvTc9imQ8Xeg!$Ct7fox!e|N23m4>q)nlZfieHnlEp z^H?QqtD2hpv^OaxV;qJoy{V2ZS|pY$1+(Q@_Hi?WZKG75jve9D`a`wR`|0T9Wz{#x zhfA+9r+|JkA(AYVr9q2u9k*K!4cdh88ng)%x>WFC>^bA&Kc2&gqvf?Q$+Ogv4BrT` z`Q@M+xzTl7NLKEIE_$z2XieoA39K_<Bn-SX&K8-|>joVgWLJzefkyWLvPYeh0g>gh zclluIT<1X}BlAynG)<4wSNIF(N<&mAC#sjih`hY~T$)4>;_Y*}6*tl1bDdZ)#*|%k zQBAON2fR`p=<={j&>0@lBNU{?%Lmiv(z8IZdv8UT)83xuDU=gO!*aC4)%sp>pA-Hs z^K`u{hY9B^__XxUQ!$W`XDl3`?4&<Ik|7gJwt1?cLWf_|Y+K-H-sa$FqMYQZ7+Ipw zzFZ3r-0`d}2>3;*;aGw!el`u(>8CqbU#=_H^Rg(X%YxqxYF>Xd0CvF;e_YqyPLvzU z3Cc~DQ|eF-H)AfXI>n|1yZ*RN)wKS&uE(^Fvg^2CKh_O*Ji}g$)YVA%tLyTs3>)r+ zb3>|z#pAY`)E`jsr0nwB6Lk9HJv4Smw@}TPC_Xm4lwE6rL1|!I*G)_Sd#*p4(*&#@ zf#&|?H8LVHEUha#5F$3yy!8c_a=VFJO6ZSPwW47yTW@NQKduoqYmM2adh)DRE_JI` zpqWbO&q{*<VZr(bCe;wrHHgDzt?!`&y6ID#zor}hnsxpnRHx!LDmdUg=P%ZT#5FuM zomqeeEHAecz~c0#@C=CMCx?~5FmH{QO6R4`Yw!|<w+%Krr=3-9EYG42h+%F&I9oqc zgLBHW%X4`8lyXz~6n-~DhRysw6<j`5!<%JzVnqJH0HWOR+Jvg;kLS~rKm)+Wxv}x4 z5#C!TVD(429?zi`1E5>|@oc>S(;wFxq4-ALpG6G;d$!P^Ki-Y=CwIKhXhXQ{0*BCb zj=<I*<tH^1{c*iP*wr7`EuOhtP8!Ca0wfGw^VCG$>yKg*9Rru^9?#$^5Uqyzvt|Sk ze_YFk=(j^1B-cfRR@E|(HvkdYyir_$RM7Ig=5;}8!aM^6AX0z4p3va*$92uf)9Oio z3^o;1`s3Q3Qm#jBK`P-fVsM=cnfl{eQ8f{w5TyE}ZikZ6H>NHGz1%#(%pZ>@RYiXk z6(`h^XJW!L^!kWw8E}T*TDDldwEHeCLL-GJC8>6+-lV}y;2KFC${Vs0d$m@8M%E4L zDJ~?%XXB+vTVv24*YZX*o-VktF&U67wW&9WD=p}cBI?(KmuvNERViM}Xw#dtOH<9Y zOC$8uq;dTXYq}<Bil9lP7rv0DojNAAOmH;YhRd*=n8$-%maa&v@(THaZjsSa)kaME zxmu|y^Mk~+fg>79mX@<!R+of!63DC6Y;5<3sBeCK@iDbtP4p)$$jSKH#mDW@bf3_> z%0Rs06M9s#jglwHrL@tZH|-FakP&w6)-FD3rQ|&fEI||(rfmCufUH`sBbgfIFQx8! zYX)epS&%PbDeD@aoHKEl+%$Zlep`cZw-EH>KgisgN7s^jha*DSd8jG3my-Av=BP&R zP7Vjl-z=70?=``qc}6^z^T#BId#z^S@5mE?SJXg{JjbN>RZ)aS<^PmQH^5yJJUnuj z8o7ol@Wa7=ZoxC4Gsj6ToP0v9j7i|(4t{VtbNL=QjyL{nx?qxN>;#K-a2I3i__~6m zqt;vQGUI8upGk{cQew$GUjWyDjO^AO^<v3^miPw~nq{C&91NBO)FkkXz@UC`R4vb< z_x2D6tyO8fe0hr^iGH+ljhkgT9-3PqO_68=Ifm*>gR~-$rfu)d<F-T*_06bM(frX# zJxFOn5#7n>%7qrvCh1CVCT-2yY7T5xV=ElbHHvC!P3{@#AR=BzXobviI;$qFDRtwW zbi5rA+`8*nJ4SNqiRAPKx3DxL1bTH%O;iD^Ue{|qcmDq)6rpM_KMfQisFyz(D8i|< zcFq(<XvNFtdoM{X^hwlMOayU|am~YwjU?bTip5?LxB3xRA#dmgu8U7ftq`*uRu#9@ zRfodm+#!*Da{B$$8Tb2-T?V|BYN%8tL3dqw7azCCiK(aakwS(#%Mb9LCpEJAzcP#` zcXzdh@O-KeV0j)t++B$_(>9_7ehIV`AN4LOTMlGJQwyhOP)#sjuWBUvok2A^^=d52 zo<TKRxK(e+*%?%`)m>FX*_}bP<28`1g;=$t{D8?rez%_=^x{kTLB6l|yCp{}|9)u% zx!5qCd~Nuo>~0UrK7IkU(6h=(-lt33r*olSk$=6*udZT=LaEx;pU6pnA}76zoENL{ z5n77O&*3$oUdEeS>S18By1vv7F8uw)vO6}mOZ<U)r255_v?XIos+JGnyoO!MPVoUT zj`lQJ-%Xhd>yf^XZj(TJBubO2XNw+b!9xNWD>+w%u8f7YDj5KW<D!JApoS$4jkB_@ z?W_H1FP2Cs0VDVwmATIay{$2j0?wta3@vl1MK>Eo@h57%<Piy{#0Zs;-*lL>>eV#I z2CkDg$1?t^=9C`-m6`^!Q#HU6@Tvw#HR%#nityxAhdNDjty49}-iKAql{6Qt_k{u$ zoL#MyY40b-@>9a7uZTu1F?`*DC1D0(4qvmviz&WpxI^di(iW_(pqAC~SlqX#ChQ{} zTQ5!G1Q4-jLRfFH_~${F!<=sH@RyMtqU)&o9vN<Ajj9jV^n&m((JAS^PwSWahZHtt z>k|Jy6e3HCqsn0ZLn@xO9d;*F0l@ALRob_Q)V@y-TQ&Q>S#3Rnc-Af6XYIb3cHghj z(kkJoH;0uRtfya~(UqL+<sV50gpTD(wyQ4@N=zb6jj2aCSuS0CC2lU+vhiq&BTnOA zPL*x8Be^5U^Pb^=fIhRl8IA_%GrE~@JoMQBt1WBtJ+*<YKds+yr2Tbj=ho0)Pwxz; z!Wo|lUrGl>)mkb(Y5JZA_l5ojhxYz3u$ZWJJcJWrC-?$I1mlG&wNQLYmCUtao1tFT zcudPeOX-7+Y`kQ<>a(WX<pv9n#_0ncn6C!AXi-NyJ&?)%S!$x%Bp398aJMyxOJ>q~ zr^1L^tkfp1wrRU^|7@-{t2EAGyF6>BvjUVgR(RORCsuIb79O*Q(XK*2i4gn?x707i z(7b2wuCXa_rT7iSylb3xNk+oE)C=PW7IuxUYG9K!z!pj0HIiy=D3#r`W<*F`ZkaF7 zwhg{=ui*jQ7E%QILIUd}X}Qt7#*Nfmsl#A)(_x0`#IRzN5a33RT8C+qjdVucfQFDa z8V!~;9d@$jw1sYEbEd;al%VOblQn=2Th)N+uv!ULro(ED20W(2PSzYcY*lm8VU0LH zXpACpjs<^P5?U+BGG;h#KoPB$N*HlnMR6^f<y<Q5q0P~t97e<W6=nh4gdkKU`OAiT z%&dvRbQFczH6WZNwFu{@X@Vi1s)-ZEO_He*BC_+AMmn((QIRI1=uTA^9vTzcb&b$k zQoy*-M!6gpTEMS|<I0bCc$=9L2z!88a5-ga)6(JA^|7PAY9k!l!qDJs7hcVCYvFB; z32$pecv;Vh!rMZ4C9;}N&e*f{T+K!?ZdK>ovvYWMu?hJ`uKo|ZA`%tFU2%k3+|RDX z-8P~P#hu{F_Kb8Z$(c#BE&G|OHEA}pfl)GDfj<FiM=EP7Nc}6LFt;i#F}LK-S~D)q zAbAEo6-^Pv@8mEOYmYtUE827Ia2f3x2C)OK`L;9~W_{FO=ss(M9#^zc8ce$c6J|^L zP~MIEO_<i$;!M-xIk80`v=(u~#s_(xHF#muAXR;2Y4v^cENti?0%@l6=gW=p29xg# zQ5R~N%J4$Kp)jpgfT6~5LdlG>>&!U2ZhTVMbru@~{YThUzFLX)lnKpX)gik^Tl3_w z>&dN-PR<xZve@W)FwE0O?cLDmSVldskLtD2F9q2rw>iumXl1m%bJczk5I0_81PPc0 zSt~={Sex6-p22Nq&mLLTc4^FURl^)Lx7Bvb4V-lf2b^sja5mjHY91>w$^qNPIp7H? zEOQFbzXG{zp|HdOsP^{^71r5c0_va5EUAUNC5%{rfH)8WLKKO-PxCaerX-h{3gp?; z&I~Qcvl54ipKX1P8`&Oa`o4Mruk&y-T5G6zYO%U|g`=?!3%?Dxh*oaT?v4(}ohvlF zy}Xl`mXAO}i}Pw!4$&lKI0rIGc}UU>0wa7ECEACJjyAy2;bw|>Y&txAfAT@020#0{ zi;@q?BHp@I$G6h)Ce0l)IRo0yt_9DFl1*ZxOiJJkd(ljA)J)s(G?m>XRmDoQwHS@p zG@B;IjMqe?4W78iIjucR?r}nX@x!zh!YBGDTzJ9!&1ebaWt!27`mweGcdQ_!d5m`$ z0WH-+LaGb^s!xskYNn30ok`v>e6h+W`AsOQpe~`(P5eMN9TtOAY2<^P=E^Y_TaCrr z0ANc`UgJC-FN;AYX&aC<dRxD{ytRK;oGjp14Fyg+Q^t^dc7x>8^AOIutG@vl*9}?C zSWH$}m<DMN1TYPH{k)j7HSGb@(^z*GHmz?=*O(sjp8edA5ltphxz8{yr6_6Ba9M8= zrdjQ`G3Whhjakq7(~SKtG9qvrBf6t3n3H4ZuAMK%MVK_`aGNdsu$hUYzDn!{JRUi6 z!%AeGxpu-kH0{Ln!?aUYZZJIY{5`^x(0|sIo)w8tY59=Gl%||PRRl%nqYci2tb<K= z4K}WXt0*B0c$VkE1kDfX0ePr{$V&{?cxhyUCWVx-2Et5G*fPyAoT_acBWH`YHvwi@ zj0#RPPAdhc)`0h}B{=tv3XY}Duw>|hQ<Kx?<c15#8W|kDep2~iDOgd2>5_@17Sg7S zE!Zm~v4f!ZO7}qk@meMBx?f_~@BxNm8<d|gRX4*Gyj@BQ4lC1lQ~9^XDnmyx5mgE) zNsA<ym{X;hdYo9R3cwcPTLW!XmET#bih5Ci+jc571CaJVk7n3gVrqk=StIq#xYjVw zcZU5ZbQ(1S3jMrzjcgIxs84ty=<_`Z9VN`CQI~$DG~IQ1nj}nuWcCO#6R085^D&$7 z_rBg`)7Q{m4X>;jc!%P-aj7bM6oG9PWspy1$^2M^TpoYjCih+4^E);A9C)<V*n#8H zVgvk||5aC(ePIgC>ag;7>h$73Sfk1vWC66v{eVL2O#Yvso$@UANv7#AUftppXJR2* z`1iER!j>Y$+@o#ceGqXZd@?nRNfuWoUji*t!_+?Z%ncTP1bx-!KI8B`x-^pRNQ!k? znrg|AVw(n*^QW{O)<b!(ErO>)+W4@wD48|Gu1dP%*d1CX>&if5pW+DGS%^c;vKA7z z-3kTU4%Jv=>RBOMl@?|}m2*Y6)9H44r-JASlabK^?vU7J<OrKD)(91`El+fW*aCZI z2zGul@T`OGumfW?+AGF!auPN>J@Bm5%&?F1;vj5-J30vFPh%XbL0mW-gsGSzL+U&w zH?xriPep;%4G(!jqEPx;t5S8uUhr^Ab5ibD319DRsSPFmAhYx=x9s$LY{6C|J>pu* zTZ(DDEcB1OM8E7v&LbDKIv-A(6|XX3H=Lmi#^}=fN;hv46%ND5@SVeqh8P*n6whWL zEcub(>!%d5d+7Gm1y0K6HsI0RJ5##w6jp?1cVG@#8POUCYYIw@)slOvOo8fV6!i6; zfLd#gmA_>R4Hfsx;FxHRi4-6<Xb)Fx19UaJx2y-YkYy$JY~jjm3B`bpOKzMbx~!}U zfVws9PkL~QW3i^5+-6ba8q}0Bf`L}x6ZL}WP6GR74*DeyIjrb<Iu6kUNJD&{7rWAn zJ%?hO)i%Wx8qyV5t{!HtnJJ=DkGaw*LYYqGs}~-N56RR}I$sys9jV_Z5vRxF;B}S@ zmZNy9`tq3%n|eyw>PWUYK2sE@PeL8@#oQ+=L4(neU35J=xDJ%-$qYez2wjpU1H)%_ z%Fj3ue9=xoY#HGrQ-{i+ALAqL*XE-dh@(RwuGg9!3BrB)RR2f)C^G<Jt(N1>wuk+F z1Y#!aGx)*_oDZMxnGifDw_mR<8%7qq_zO8Nv?e3hwU9qqTiO~;>V<@hj2`XeBfC_7 z8}h(8mOJT+D@>d+vGTHaQL;_^f|gJf#Rri+&UqtSC8n2K@jHfxiYg{Gv{ODn{4+xg zqsVQ0B>qOdoU<%RlVZpuZ^Z`$HChcAmeT=-MCu@qi;$FIuI@`;z5g&=fFIdtU~wDs za&TZw(fpz~h!$n-wZ@|c%@Jspw}7}_`s%&26-Jh~9vyC^7+v1F5`~O{dXDt6R&3k0 z>(q{`j6#CbQ4jEvS|gR&x{%73u+uDuoNdSEUFBXV4(r9VimtnmAMVcg()@dV6hPG{ z=vVjofCYyL5f#4@QOES-5@s<Tay;4cErrgn3+a^7ezH}o#~{tZiX%(6{#XGkkVv%) zn`+y`#r=*~1F=+8>=y@kpX22!V6+7g0XMSFV+$aJ&YOmFEr-}0P1BK@k#~AQ*sm*F zPEljF{4s3Id}&zLR)ibiZAG|lY-}}UyfIq}S<zU%6jE0oTT2<Qt_WPUqPi`8uy*3G z)T_aDV`D2R<Bi!QVMSw15?G_K%6&}>2v9oXGcDK!>zmsrM_&KwCV{Upk3o#2gql;$ z5<b-)3Xcr+8rdsKH-N{_=La5F5(|?zr5rG^#P!u3DF<Rv5*q6K1@4r;z)(o8lAWK5 zcAjj3eGaS=2d;7}&(?397K?hab5E5fL08YC`3^o!*=F1w+kS)SCOAF3c1ts$-hOU@ zVIM!-T}7{u#JYA76p%CF1w{Jzc^;EdUa~j5ct05_$RH1&k2+Dbgg2;tPX;BO11j=_ zchY$;pi7H+j2)QU8S#?haJxh^YXe!`jyhA4NAtiyW8tBGV4z(PUWMnS43g*V49DZ; z?aa)TtpZ4I1{vgPr~ois?+<}<nWDqN{<=$jtz?WFs9Z)V-Ks31DAlS30>R|Sf_5w* z`PO=t6|cn*EJQKnDvBZSjPX5L8Hr>N5pKG&70tROlfgAxl6F|tm}v*rGwbRmgKJl} zln!{US(pPB(+7mP9hQ1Evu-SAWUMhNoT1gQm_Cp=i5&auYzS`~=Ijow(G!}huS?vh z6!(foz0bDDl#O{6GCaA<dz5fs&WYvo!=?et6D`9P78SslV$<OF`f!i=^OB_c?PJfG zmd|J}89}Rg%7EC6Y&jxII;BnabytJpRyUN!K}yN-EzJ1bnr%!`;1w#s{J~O_dB&DF z+pZ>N42V0<dlYwGZj*xuA4=41zA_{ES8p=F7E4C0mBEvSdEx>H&oiTy3vLT(w7_}q zngszf+?%ZK#u~4?kw)2q<3c?o#he+%hE!MS+;NNf7yMw}P~1hH8SaJoxGGcpcb6vB z-)E;$&~?V_iu*$b1FJe*@0YX1F-XPl8cKMw#lqS=by8UHkH_qVw<BRV2gnENMU`%? zU0en%-ZzMAS^{XAo392k#5R&GrivKW3OJbr+2HqCHg8TlGQ1>`4;$NJXP)wOghLE0 zc93;Sd0iu5d1A4h@Q^?QNCkj6;`z|{kQ$T%Idi9o%8&VZU%x0sO05d`nOagPH+uWd zbhwqc963us2yl-~srGOlywAwl$^o^9HiV2M;xmC6FR>UuDg@RCS50?8<kxu-dMbw% zNX1sc*uD5A5to7SmRKcrqvQwy^bNnzej9I>linj~CI_%NTyE?Dj{1NDgu;$_ku%Lz zchJ<agnuoB?BaaN9&ZdglLl(*fSUOWEdf)j3*8YzISQT)J3y6upYteJXwfld^in z7Yt?+p(Sn;3~1tt!Nj?&a~Nvz;dQ9H%p%;Wgn6d(?PnW5FpG9sLbB9Za~8+H@D0UU z&oH{o2d?h&eh$AY>02U)1qU{gnJOl`fFG)5OMeg54_s3%A6w8%?e}imyT{g}jR4?8 zx<Cz5pXyfRkA!OKy1pFOSaZs&JeH|WuX@DlWL}5O1|1GJvm}yGZM?2}RButXo0kA9 zXZoX)@_KbsTJ{Gww4Rldiot>y;XcCy(XssnN4P6<R@dF71tQE`E$Hl&Oj$i_OPj}8 z()4#iV+QvmhLaZVYOw6!9912_<YfxfUAt;CBh7Yki)5*;w+h}tRa;SOxhjgRh!jEu zgR+1+5j*nOsn#ui)pQT~d<xRZB9V%&7LBvHka((%4*PDv66x87EG{Mw&X>Sld{M3p zuRMUSNiv1l?_qmTp}G_)9vO@<B>v0!%G7G&Yp^z8p;A#Mu^XxjgJX<`#Ml{M{1S1D z-)q@IF!CnpKShzl*5l$-MyhxMhk$8Z2!tvStr2fApg33t3Fyl@?p-H$qw`sKLmHw^ zxy{rwPd3%xP0^MP?Y==NP%NGn2C}0R+fa2Un^BL0;I`3<c(1R=84zk=5^=23*x84@ zGuvG*flP~Eu`%WBL`G`TMa8|0r?eCcI;U0*pZ(hP@Z(Tni+ZT7W~gFKV1)7NZ^vwN z7({=-(t%T@jT+|py*Hi!KR#Ulc8q34+WU*>3o(9TT7v_Nj@cbrcu4Q{JrwAc`b#mR z*P9Z}v0xD>#DO-yBW~>7F%b%d@kZIfkjT2~j$|E6^!(`jGlqA*lXtczuStvx1>U<j znT-Kb31v>gp3-xO9GDZb>#}LvzhQ4ENm;!bbnsh{HXIim01Jkg66owmQl#B<z;|9_ zs;FE2xP)Pnq^NQNMI;?Fp&QCdJ)tmYv|An376kUpR#QtWN@Xg=i;YXw^75kzH7w%9 z;eG*r$@uG73#Rf=XNul07mTMOxT5Q2wuBOVDNXI7bm2ju&^_5Ki?)nKyoTTDRJ!1z zWS!zkHos*dI0jRle?>cu0Hj{bMs{|S+x3Nx;PZ+5@Mg9@U32!f!DO7>W9C>P=*f*k zKIsU{Hdv<?NZiuh=0S0grDC1c+utbSSQ{HzrfbiCPm<hvmjFxvTHeS2ona?nO|nyt zKQ^$*J>-v1hiE5aVD1oh8I93l=(ciw+^{t%y)`d#GeY0gX9_4b40g0bkDspAZ)owk zIEAd)$oj$9I;c`nCDaELg!=r0i4-=;QFXvbQ=PPs+=^PPMX#(C%5?`-%I3m!7DfHx zk4mPBpL1WO;;Sp_0Bx|1UTP!;I~WkuZD9*!{ZQ+ghFi?FHdFF52@?P?+v54JrN1lA zJqVVBbn;V9it*j)O<KZbGa|?YhPF{2goA&U7KMM&NNAR!m{-6%A_^osqQ-~Jr+o}& zc3o?37wSE9^$`xVDobEJhysjR^sv+QL<sp3k4uPkkfFa$HilefUBRy>!z@J{aqxpl z1o6Ku3{~VnuxJC@g0ePeazt9B;n4|^THhS=EMLbb5Nf+M5|wjQ)&F97h<jv7mSTc) zc&Iv0p9Zq4@8@U@u}{wDUHG<Ax`=WQvfb5R@nxmzvBxqbRpYbL--P|RZeuvb(IZnE z1<u^5)}d6kTA`p=T*F9BTY_LofkJr5HX%IO4K4YGT?*02tw&JY^Tt%HDk%rBDl5tX z9~$Ae<A;Hsb~#5lEZDpNX_I<jymnHT1?I3L2AxJ(*7oghe6EwbS)-1|6i>3sS=&ka zN#r(CtcdRF28Lk%nbv+!F0gH2o!ak#REE1r_jx+<c{D}CU$F!Nn#J7vCP{t4sG5{M ziarlO>VqTvY>pfG+?!{-j7B2^B$~^ly+?a0nT%OxRGFadU@dkqM!sTL2=c6~qm8h( z<HB~kBL8GodRaKjsvDWZQP5FQ7aXMic_avPE-$$g{dX&JaDw#D%ea0I*Y^_N#I)_k zM_AU8HKK6_r2?Cgoz$U9bq7^v>DgkIv-8z??#ZjJR+3|2I|fK$GM+M-C_XQ@NH-jc zY&xC_dGx!Dr%G<46OB_M_ZwVUq1pkL53?YBS|)PFFG@<xmrthUg+t}K0bw~8=gXG1 z<@|(L@uH+p{UFC&^)s(;MY5*Iz=}99ULlvee$=YueY7_VDjqh5jIs^<=sRR;2lBR} z0~?eT>5?XEqnAmXnYUSRuvb1Y7h<-d5LxpF^p^3bR$j$<T02QH*aned<1z^X#z}M| z8EM7U3Ix)6cITVs5N%XMuR0H2g@@@MVXh~X)r51f#84J{Gzy#dzL=*IwMrV!65&T! z)+eI*ZpdkD(gofiUd#+otzAk&tQ-^jrrahHBfD~5^dF;{Fn5P}pE)uZowS@V!5hf> zGBacMEd5Pd0m|D{K$A7bJuGLh;c4bNjHIG2<{s@Q0~o8js<wh@M&H)N<otlcuXJWl z8*5CJ>xe2RX_}ZO1AJIna47Up{H<6Q&4W}k_0noJa}=4LDP`7Ch^Ly)$Q7W$XQP-W zCF>JXy1}joQYVNtvqus5aXVn(>VVS#h8?JR#w7bB*sJMF88v&RX^&W#Ym^!}lb7lZ zkO4ID0_N@7fVPR%K}z$i(1{!P$38|5!27#qj10<-I<^5@@R%!>4HZp*jWnYHw~9tE z8H~$qj8+c-!?6ucED|wQa%GVS*#&0p+@hqniFuOKT<7W*XSU9DdaGxf&#=|^bcaV8 zoEU~M!7KKD8$W2plEI;m#r5xCfXcHaK&N3_G{t6}W7}m-a1a+H+GBADku%Z^WXi;# z-4oTZ9~2|<Dj1J3%UWyN@LBt|-q^fBb+TB;<`7gIokKion~}#R5vqczf|X}r1Ew8R zS#v_6<9o7$I82%!%rXXZpQ&AF50jnIn&~VgHHYh_^OGzW14jkz$rGl%E)w_yLQpEZ zg;Bw3{Tfr~#Ds6<8=JtQ6+QA0Pl6rwVPLyW7D*HTxesG9z^bp#M}`#V3s(LV53vUq zbHP*xf8BHg6Y_(DiP}M6R5~7l^4bk4^?^T})w+hBd5&_`I3Zl75veTNO`j=YKm^GK zRGEkl0$^#9$xoMQnOdvEeI2SxX=rkWluDfO6PliAiltFJ0Dv0?K`$22dWsU`VnTYD zwprnl8R8hNi6#h2QRxFfK~yic=BhAk3G6UOo3GySfPErRH&Ua}$-_RN#T*lj2|zG) zD*zFuc)AjhYz;u<!b&ZiuS@`FU?343_CQM$Rk$$55I`55>ie_jmnf0yz5d-n!4W?` ztGDj>N#h!NH`kWVs$%e0afvBF;}Q5(4x5cY7<6+60#}CcW7ttTS$H^6FI?j8TWd|H zugr%BJU)W9XuHNUw_+3!OtPIyZ=6@#Z1a*}4<WPxM{}D94sMwchZmzM4*@q>3MPs` zW$h66s1I*c1Vo<|@qh@|O=K<6w>`5T;Z-|gPSYMzKjIwa>+W1n6b~w9RKvH$XXm!h zi{qe`EdbQpq%WSYtLIt3f>5R=y|!I?4DVLLc934_izhsIK5>!H{p~0JrF}snSB+T( zEon^8;|EM%DX_paOn{#)CBZLeB^6(i4JrOc{|XE(kY(~&x#Htuc&NJKFy0w_E<5eA zRQqf;@`E1@zWUtTh%p$`mcApkbet`uWh~&a;U|Ot`Sq=%iz86}-Vvsd_h)>57r(ds z_tP9jxD_y$6WrY)FEE`Z+$(JXq3!!T+&o)SYQwkC2%%d;eFqgJRRfKv)>Rx629et# z?*$Di_rHVLjvq+XXCDYZDCDV#Ehi@_8o_WVMuY{|%DfTtySG^n6Tl`%cA|En3Bbz- zm&e7sLiz|7^P=Rt_?dL~dmHX|E{U7)Aoyfm*DKZTw`zU2YI$V~ij#eV?`@T>)&I@k zt5_ZX_R<rz<Gl^8vRD#aq1sHrL@n)3!q*(1#vbaUT8ICMe)}C&TphJB8NwCY!41#r z`6|qJRDkGM1^a;OehM(pnS|-II-ETFVDXR6aCW4EpZ1IOmeGgbVxZGfu9Y&4fX>)+ zDKFzl1Ppg!2pZVs0}6@_ib3pq0(8UXn~5aV|5_n~=K9=3)sY<QYYQBPgZKBj2VeJh zVG*FnSLF7dFZ||*I*SXW3kxeu(Mh&xeDCuWql~#VjF(38;)@!GA|>!EI8>%v@u;2w z5?I;?NVXcv^;Mn|T%9cj5hmKzV?>)Cv=(ILWX0+OmSTg&U3zOR`Goi?IM5Jty(5Oj z3rRT`7N@dI1Y(4B=^!J6%#6{u_=-0rI1Bq&qfe{s{WjJ(;#lx^2}hWrfcWZ~ye`}T zovCE!54E?xQZ12F5iN@#GUZ~Q_SM&08OfWLPb_m`Xo>cPrVar<!`U9#Hm567x`a$o zx<EsB^t5TfECvG6h`tpfA(hzh3+t|mt&;>RWj1LO3M&Jf!3#o{v9)NeW^u@iFB=L_ z;IeM;qa(&6<-{jFw+fh0fVw+hx#PwLFOc*i**c^$&aQC*B-sLbDB0bMk{<&bJ(uhk zAlY5TluWYIMkZsWMzX2(pH#~$r#`EG0g`Q%q3lJ;kNbNSQ><IdYhJpM>=z)}=0yQA z;#q6f(U3ls5&9QM2Wngrdc<U<(5I3swJ|E}=48o?s-if(TT46WlC-lkM|=m?;KfQw zXHuC)i|#=@vegp57Aeh#D2#R{POE6w9`kMnll}!}ehx)ugs2XogJqyNOxh<rS>0Ci zC8MwT(nP|wP|X|S9xB9Dl(l%nM>+JDEVi~b+D(8j)+En(|NM~;B+yFjbaA=dBrm=( zvsLCMq{(u4?)KEELtLxGownrx(@&(eG;3Soo-(`V#zBjHNOwtan0%L7S{@@cGD2_- zLUX}lxf?(llq<<2TOGI5PpK9B;1MU6Z>3OAPF4I-g(-ahGOe8}9#Bd;t)uxG8XPxC zhsKhE5;F0Z`DCic`ejhAL3GQkcl^nv@k*IRux-NMEayk3A+K(Cr-9Q@t1pi^4fKQg zaA%qe*0ROIqrKMY4T^XdiaQH(Lu;IIn~T~k{=X|7dZZ+khUns;uIzI%Xn*%C6cT2Q zKW4l5s;CA{ImfjYOo_|XG*m!9b9+pukg6E4e$eLwGqI#v)b=%zj3K{c$FVSfromd~ zAJehT(N`kyGy)}G_Y#28js~+ub1jgBKd33ECY{V&!G@tiF(}reAdk|EsHl}BOEKak zG_CmJ*GWbR;kv43+GzOo4%o1xezYDR#I5I*q2^YWRK!*^50fxc!-H7yk*CBSf<~co z%Mg`(JyoN{cd?IF=u}jO9}_hU5v6DL@Qeg4F*H}o`=UW`2N1&ZjI4ky?Z|oID<NhZ z=+wSKA>%|XzD!|}(V9*My~4T-JTxH~yjuh5yqW;(ZK?pJio0*ZRQ7Jx@#2vnuuaR^ zrc%g4$@r2d$ZeDsb!=xx3C*Q4kiSEYMt$<nKAor!ZJR?~&sAjJ{G3HuJGsI%7mSDO zGi7#?cKtOc=EE?X&8tfcvh}UrkqinGKa^|Yg;mIInJC<>8BFvY<un4VB8Fg+8w4L% zdet!<mq#vwbF*=gTnQg<@4HAUEfW`8Jhc-z2m>cvM7JUKo$2Fb0d4XuAP|YNc=EG= z?jAD2$+LhS7f3r98nS$wfYee0OeStUIgtO71|7*184nXOq6u7IDZl~8u$CYp+dLF9 zloLEb>-eOy4eG}dc|36qaW~D7maTr5SrYlUuz&<QE52c%aT`Svenb2QY$j0VmGW79 zH)bQ0+GoJ7Ud^kBATjr%X>Aik&b5R(Af*O&H|1#Fqcay86R?=GIddU{#7%*+mcfst z!%*X?RJ9MiI~+RsC_eO2Ro&)M96BEj#MT3&58ZYg0kG0vfvEYa;`JI$21-tA7`2Sl z!)SDQH`)4ALTGOiau@$?c=_4k<-^0vKeJ2aZb&B0&21H*v4`v8!{X2FcJo*n)NrTx z%agtM<F#7-Q+o(+NY8ym+F^Z9iccLEU-kP9O((_I{7EeTKm0DE&a1<iP0gL2*dYuC zd0-$ui?eElP%W_x)hORvMB$yys<B@eB|2K(GR)XakMEsX980rm%uHR^D@EViYOPZ( zuiSCpd#cL3vc1=WKJQhm)A-)%DywJJQ~2H<snmiNyKS|rS@_pi;t&Z7#md}^BnO>; zVGd<Awm4-N)*dR!xBUMU9U8f(4nj3<m<2O@`5T}JJt91P@h9OTPA`_UM~}!oks*41 z1RJu-lA2khPGtFJQ9{-b-R66QY!X;&2Wr$J*SlrhsjKrH?bwLifK3lpTMo0zMNANe zR-^6sZD1Mj=A)DL%Al7&NRZVMS|o%bIcw&!=LVy~1h0|^z(Wljf0<_5BiW1(g<Zpf zVq@|v{l@AcATiXamF1rM=QeQZSd|x`MB$Mh+uRJtY02F7UNiUBeti^`(oj&aYHbn- z<h2&Z<*CSBro3V|I%ff;#&`BK5YyiFfGuLg?lp6dB{&EJ#ZJ6|rQ_yn4ARDOW@)|F z(7n_7iWSZfUWK$i2?f&nV0S8h#cBt*uS1QD!!L!5VWgHp`Qu*iS!Yt(TiNEElE@de z>0qz-V@)e3>j)6XVOiaZSB+mN)3G*n>!*Ve!|Mu+)Zg`zN(VuCL{Pk~@fB}p$KS@U z+nK7S?ujJ6KCO=_t^R)0n<5vV?iaJ@G6Dpd8`eLq&R2iQ={2$Q85vpRe0YL@B-N!& z;;IRq_UX`h{c*oD?R@pfU;Ctt{KlcPi<gqR@pFcGQoo)xLh4b(|0lAh){rXLO5PY| zEyw~#-e(gZHs3!|h4Lo8nx(0xLaBc$juQt6W24|22ET;nX(SysqT^+nM#5+#Nz(|k z4isF|2sf+c-XJXAEE@E(BsudhG~Q|CL!)XQo)&MnTXQmVI#fiGAC%a^YWA@4Pw_Ke zW5~51$-?B&zviP_=8<S(O;AJgUi+LDt_PflVq?hzWvDAf9)Ex=PCRG)F54-v*{@i0 zDg25x7cEUZeZEg<1?{>cN&3^as&{>#6K*0#S*?&7AFQt$q^>ull7^9qG$ek`B%KSA zAJEnIgjO%~qMod`)@z2z{&LIUW0r|BRJBB*zfQ%G2uX55qL44`W)~#i?^g)hg(Q2f z(<fUdqASc*ls&}=7zWFryc#H0g<aq@&1t2o$<^1<EHXY!4Op1hl9RSK`qixL3&&>d z%u32WotX0W>_WklRo`VdyZlB6;>{$_)&iLFo6!gzeg^As{?^3<-R%e%rmK{2C(LH8 zPtGUFYORJsmg2D=EH+*b4Af78IXxI{Xr2{gT9ZA;YkZOnw|F#rXk_YE2XO|u`2xmo z9%u1=BSHLz_$uB}a~?&-Y!Fj?1ZB96hqV&O=El!zg$NeRk&_@Q>Bjc1O9EJz)??U` zE2FICs;G}`iU#8f5lLZvN>`|4l6d$bV5Bt{=>>c_#R`gdnjozyYxUPq@311F39<B^ z93s`)e+?q7{%+iuby=H8ulp8=#8!Dx2ttv*<yyh1B<;}9MmG&@M5xU*ZCst0ol}aj zXgfkRJG*3*YIuT?YDz~2vyo(W$eyz6)*#f+pPW#&QvBI(524OTn#L^g+Jw4hY>v4? zm?S2!CxR3|;9K0~qpFR=-6zLG`#LucKNs=PxHLbjFQ~<t3aL(Oi>jUa2tA;k1^3K0 z=-`dM5@4pxK1^|l8heGGnI;?qtrOH5fz(aI4wH7aTI2xTG%Q4B{*X6x)9^-@o7o}P zOta5F@>5Gk!I|I0l}SB1vlJ)*d$F;OfGXF0e`<OgKazYty{?6w6fVtq7!h;fafQoA z@0yQ251k%VCRT(9kFXx}qZpM*D&9a7d2Kwo6VY?^BDKa1wVrj=tNd5fxVIU7GQnxB zuBCE6JPudeoiPZ}k+m&b<cQ*$5b7mtX%kq4PID)^67Oiyf<U)_RjBIXaWSo}UdFeZ z_(?C-Cx+N1!lnt9F-UIxuppvYR4FC3EX@`B+DuA0-i5BDW#ygt5gfDzPKmLyuu?Cb z`Wr1GIk3HV=lXD;vobJIo3OpM$l8V#P+_^6OuGdWu{G?jyAm28wqcMqsdCBy9a!ch zFKma+B(5VnY(xSj%B95TNN$^4VDFkTh6F7sn|)UPnCYi1NAgr1kv?bbSY92CdHUQW zU8d~b)9S8vOK1;{ZGux379qx}DE6amdc*xVB1PRJ5pSAW_DnqMy9fTX{0jB7cFD_T za}L+GIfq4*2~YgQ(*RfOoBJU^VVfZRkQ`jRQWUaYSS&_BjSG4dsh40GX{tb!wkxU! zjbuXWxQe1rRhC_Chtn|czK$dOrs^P+SBOC<tHH{0Ipr0nJqTqL?H$u^y;G6tCIF=_ z_}r7{QyXJ)kL?wMvhd34zpM*v&OfrhVq@_QE{_~s<vH~}CWZ%gMr59e4KUFzD{|qr z%L<+**&csN+0z!`N+K&L`UNW}7?JsAtCn~nUohz+=gmMO0ewklPdLGID{OnNL}Jlf z0-miX?4)juklNG$rPv)(A$d+e#87HN-$b>X;NGt*-<mF9em}U5Ym>~}7YyKSqVZL@ zj;UgH4QVJ$7b&2x8G;cpfaZ#WRt)VRIU`(v<PiQ~iL8;9RCq!hl~+nqxe18sfECuX zgcCd-jlZ=(=mx-<P)Q)*EQN+WBi%Z7X(0`XBtZ%oB5Bc)CJg7kQB4k&kfzo^n#oFC z*pb#AL7GZw+b=|ym`ETM`_yg$_>?=T7(|41VO(S$Hv+=735f4Eb<_;8f+`uxhO{)X zyS^_ubwEY4{XlIZ8%T{cF4;f<ip7;WF_aAgJS0f*C<b@ELX-`wp*>V2E~w(<h-}z9 zF+1;6q)wdjlFeQNV?@GaCL1zV+E!o7hFGWf`f<IVxhfU+#5HcQ6zGISo;U*`mRh)# z`Oy488#?hiB%#c1e<^C3p&rlS#@>s@f}JB?O9BIh-R6r6Dw-kroZ#tlBK9o9j>PvN z8;kH7!y?m>8aD_IRcUO-W8(qPmXF_*FvVaM=<Ua`+43S>L?2wzprVjmvBDeCWjMpm zrS*coR5=W=sufGhu@<mvY5~deB~V`n1w%2TjH)_j3dc?{M>tIDM~OzkG0zG_V*7<u zTnDK&L`pLa;A%$5stRPMlP3J@DM9t^`_gn|Q)_-tnU3U7kUAlOU{1_j?h0`@f{jk3 zLnKG43Ae>Ihs3M2=cz4D3RPgncatdW8xD1Qi{HvL1xnbU1b@9W+YE?NDwZqV3;AkS zD(Ss@oM-b$Gp*jkS)<s-{v-W#BuBt0#?_Rso+ux}k|GHF#<LzM2~Wk`SA~gVv=Wj6 zUC9sF)nUANBO*r{Q$dGo7`BL|K5G1KhmRSA^goR#|IHLP_-vy7&$2b;p0WLhAV=*w z&o;~D&X3v^Y7y_^*MveNGL5odg<(34e_byO`8;DCHtv(E=z6KzVV?$Wsif`b;L3M& z$Tl)Q(W13ID=kMyrDotS`C|5Xvu3}>h}c<icJaCh#cb_>*%zIMORiCwKohcXuto~d zL!Mzq1D?lb!weVk)Cw2`=xXGNoR&XhPhEE)0%#JSwuIuR3<kh&7J~`g+qo4PlLhCD z-d~L;g|r$WXZp@MIxD*I#AzW?i3u7!hpf-&%Fb9X13V;#5&rw6*~(Y7Z$Fc`Uv^1{ z9hs>Lwn@euCnOWdBKPs+ZoDMTarky5p4Jw;P&eVO$`Dm~2Pm|g8-<3?h(dE35D2Pi z09{1q;eT>r`5f-LR@M$_!nQ5t#I<^rQt2}D2Qjb$T;jy~EkqRh$*W+j+U*2nu1{D2 zkvwuIeIhqmvP!X17-)gl;*W)Pu|Y%o=MDLGWXXAzqGqLKS%Z;U);>UW#5J9$ua(;Q zr_wNTZ05)+KHOCNhG3{tkFeVMkWVFOu0S^h?dw*f+`}`L^|9)e9q6(NtQeOg15&G; z!zZvJFbx-dB^{;~jes&VLdP4Lxe-#|mQ-%ak^^)}N5dCUW)OgE>8JW36KypqsgLlE zk}`8rYKtd+i&l35+B73ao|2HGhOE6!?&?aJf*Y3D=#?;Ur6V)|h#eg^F6c-GlFNJ) zeNE@|APfb^RMK|n6{n}oOjM5uU8XVmWolun_bhlI?2-!QlUn98z}!&kx13veF4vha zEYXMO(4&`d37rj6pIo2jrmmT0rPI0t0@-7F8_~VM*BJV5x6zn#0JO?DW;As8Hbz6+ zlQ0@G*KwSvk{Y;GM&k!<X+k;^lntV%UfJN-afr|Egu{bEUugU&q1L%ty~Me;FZ5E? zK2Y5I4k3l|>KQ_tybXn|_wymkbgD&dbCBJr9xHribcoOY@e2ZeK{w`AcJHc}GTbkK z7s&}xAa6nnX(XL%(o}|eX2+w_xomYn3%ZuhrSI)3&q|y)s>&cD6=?~r)73ZbaTPGq z<Y2+<mXk-K#m!<X-vS^<LT^L#KR{jWcPL-WI71>qca9D@7VREVVKV`|6Fn!5Q&DlQ zRya{TS#h9AeL`S!r4Z*zV0vU$(Ca3}9~rwpkhgMM($<IJhDI@ZvK6|5m4<9MXulFQ zH`h+rqFE>ZxKqkGw=GmI@3$!~vK<A|R+2Ukwn*LSqJ&pefT<TkwMAHvqhd*aoBYR) zfP(XiUl5d1P)fx^e`uP-BLStn?KPX_vn7w$?7P-zR^GXqg?3N0*|)9HtZa#z)x>f~ za-mpDUp^s36LF;D2<+5}StePIu~OM8sxXSiO4gQ^U`3vH`}Si=2fD`tS&c%@f0ZH= zHl#7Z=Q|8SnCPP9LY`qgwb^V^+cq$wJ8>q^xk|c(yZAB&KSmo9YJ3fI>*Z@8C}F69 zzt<SALa+g35NapRYB8@dePWpkwzMPe?frhbrYWWh4db@m!eA3|o90U9)IfcVu`4j+ z1coGA03=G>(4q%-xpOG>Qa%p6E+nahhBdM3YhU=|ce803Iq+$|J^bqrzwb?*MeKy1 zhVVsBl3?nc*wh^ph9DX=mTVxGBtEKjVwv_nJ2H@wgb;>-)d1toKmaMjVoKG>r9?Lo z_K~D(+T?*t`8XIG$+ylb3is?TZ(*u)TpMh&Y5*NIE^C?d<>C%fYtTGRj1=H<)I@#K zAN|BH1PE8$O%?yu7{DN_2NfZ3a6PSp>&Bz_)TUvc9!scA%1c&h6G0f8%FxASwXb}M zPi4$Gw(0atc#e)(kQtDx!w*BTROU?#OH+9epJ$*+%F1@<78>sk!Z_iC$8q$EI%r~S zRgCE<R6tAzrOt)#54JtM-36b{sgCatF7RfF_cWm!MI}QFa4P1Z*o_@Iqbj~Xcv!^) zIBe%=4jc+AoZFi9R{qk*XA(IXWVu~By^-+|N<u0%y3Y9MI*d+Z<FUo7z>vkOM#g3# zD-MW=fOZ(0VM8OMng^xQk&!9NH8QdeBO9%-IxwYi(eLXyilv){5LzMcqSQn6R-Uw4 zFnrtndLAvOms?`9NtK@Jnc}Px6-i8ndEk-7tL!}tE*C5JST4Ynn1i8l(p$7VZhJ{^ z42(@HH$8@)umK8LIo&**O9{o3G*O8mE4B&Padjj{*Y&(Y3|UzXRspnaM-?aPkQEi} z9rV#V6{&+>UeZpp)A1O|)Ow@6M)qVQz@z3QvpvD#v&8?zhEK)Q9{#i_?Q|4lLuAE} zM#wlaib^Za9EN`eH3%3coHKO>HI%8q_dw5}1{6-%Fn9(v2=UuiLQ`qaDKndBkSuWt z<CiX!RwdW#9#^qK&vP_;ED?C-`<Lv@Hdp`au?)Y6H+I~p6a`25hHfH5BTdk${sZS9 zb<$3Jpm~zA_H_8+zqwS2gQ3<gXiv8Acd7|X)bwC;$Ib)Nrxa16DJR1`_E>VoGzwB} zx9u3BAHSxWBoU*!Qvdv0oe)+buF|1Iy;*^l=F>V4ow7DT-ngAjg=S7^^3?5cfTUz~ z&2|*W)nKdT>q!y5*UpgDv3|_=vI<95S|22V%?o*eJzo>Q7tR;&sj+Jqol!O68I#EY zLE38q2yq9)oj-G8IpItkPw&+yW~5%_fhpW($Pb()G-$629Z`VCQ6C!>+ER1&K#sRY z7O6lu-Ke0LB^#Y<3PdLz8KSEiS$_7DBfTZ0w+!j63S?=O-;hPOhIFb5>220AIX{9X zAiR?h9V!x@v^7X5HM?eOKod7-agwt!Fk{h`&Qx*s--NmWdBq@Hist?ag&sA4h{tyr z9t{Fzrx=^imGnOI#gaOHepz%}2=!^F5_c4mr3qSyVProTpSp=_S65vgAGif;S4Z_g z4wn$_twEJ)62mrMEkC$CN6v~SM-WPEGgD7uH4KRsGfiS)Vy{Ik&i!~o%eUQsjHxDv zF5)=y`)yX&hEK>xcV}X;80`~N*~XXHztgc;;$@?|P|FOQ#Wgw4%I@-5fF@MvaB`Dv z?jieQQ&E*w@YmcnK9ypOVpe?9$WgU8mp*YviajS;`!l!U#%tdJHeO;{hfKeo@sX=I z-DEPKdolT_`r}+i<WtvLld@eM@L4?m+E(*G7cU}ve(B>9^CF&LjHYVyr*luxVfDfn z5;k(&$t0~$n7i3ysF{R7E3!M1q>AUE^)wCCkuk8jooPgqT8*9*``K|PtU-?-cS7L6 zre#udECsl~#{o<eLaA0?2x9sg+ps6(otM6-7*8H4TiUqq|1pNVVp7OavA@PVk`M{= zD-L`_?u^s8&Do6IdhUSHsF)7{=~J3t>k9jp0GH~71w4Wilaf}Ef3iWR);t*<Z_xFk zGFVS3F+@16rO<^IBgVfmhVjz7wklOpyju#|gEtnS7)FqK=Oii%mYh?V?AnI1a}uy) z$Lgu25ck0;Nbza4)q7_k(iFL6_TbP08V+pAnIY~>ju$Jefn`^=lR6ZU_$!Z?P<VVk z3TGxBP&icq3k{sSfJHT!9V?W<n3x?ajKLMF5v8t(dH8-t8!>TPdj=M460;3sD~-{l z>462-(+srZ8rI2sPCO_;f!?j@SO!&G)_|~zW)kx<izgaG1I^M=-uP`kfyS*qL^+<5 zPfT(4L`lz@4^G=q2jS&FJFB8OrO70Eo&)cW8;Ph?&xw1M){ZL}^Va`Qk4gn~%!;T^ zjjWg7gok(nv8{0dkVgx!=86GoKF<#ir9#9S%Mt@3{r3Owvz8-Ie&A0}pal@&>BOJ( zlj6-%J`mX7*_Tmzy$dXCDN#)sn8vK*k0F5m!DphIZ}t?!0y<$Tj&LF={z*ekdOtK4 zxoAh-1r7x}{*%M5q~ZwL*ZxqTVT6<^NUJDP(u-c{L-}sCM#IYq+1r2pR+X&&Q9wWX zNnIDO^<wv=W4*S#R|WtI6;hR|WGbH28WuB%OKz05VKB#n#k6Z%7r1>v2~g7am_-dW z)iZ%s{JDln%S1JKEj_0Arv7Qx3+;(GrVd5ggnqc5+xxZD)<mk&@NCPsy<2z|T*I1L z8lWQrwOIuQSirSeVe=;J(toZR#BJ#z*^wAh^%aRS13m)4C^6?M8^TQ*f_&!=Z+Q<N zNpUj+=ZfY%sA>;z+g-k-ta?w=z{!Up=|uxqT&VbMQ9w~Qi>?|<U0H!27>1oW>aF)J z)g6pBUZt0*?-kAWcvO5&T_wGXq!3>vrh!5G>DE{4Qpq;l#)=~8xf>|Hobb1t6o2bg zt(njJ_1)uZ)M>+1Hb{bOB<7g8fzIkkyu$-BDvh0lw>0wjiBFt>TfJ{;cLyL55#EtZ z6kqhtK$QYrUf!C*N=(rr(UHfk{MxXVc9Z@>DcmXoiBq5*iYkG(l~JW@6LNpd5=HR^ zG&x?hQ8q^1tX8Y|2YZ8(63fCXk(mPIHD;2&#KV@3(9-M5BeP(hS{mdZ=wmEmTU@6h zhZqM|HyXD^tlee<w-2zy#(~)!`PF)rlfY93`>z5t>Akj{HUWD@<yuZVK(iTVOCeDm z(}+^q=BOF|%b0#rt!O^8E(<AhpRjA|Ys4kOo_XpBJ;|p0^NFNZV-LRRgp|`)lj2cP z)Cv@Tp-Vqs2R+0D=CbMBn^L;6#;Wv5ZAF)MD8ULr&Jnf*L=u0V#Sa@ZFL8iTJzC<| zTrf5zR&;>4;!`f8Eyz=AdBdB>8#V=I+AHC3SRE_?c0i)8SvB8!Tq7o=Y{%uyTd-Am z@x)tkyj0XS_SP@9*^3JSV|E-bkKKMktS@{um|+W<V@83jrX!Qsy1gxD+|fVzk@9PI zgoCcdsx!hc0e_7Lxc@<9?A6#dX_pi+eM9uC<T=x6E$3!tjll!bhB&O^lXtvD_^AG3 z4L*TXa6s{ot`WRRCbchnv(<oKtK$#pvWSO@H6=d&HY@RQb$8-ZRwNq6TCaVggT-mp zd+8s^o6Yn!-#3SLh{qAL!at9rybqo*R?HEfEdn$o;}IJsf;q^qY{veMpEDq^Cxj}p z?hl{a*9I)D7_F|gJNXemzfnQZ_`0sY^|k7Q#>>2JLs6crVi^e5)PhEh?q9Gj?J+S- z{nu3H{22O(`n38+4X{Pf^WDdOm`@t`So{@6KHr_bl!-nkn<M0?vHXV-i=BYc#+1vX z^}O+tkD(~ut6%LL<@D#ITF&u`%AlOKJEftPF{*ElXBqa>GWkAyN1j`8z7~i!{5~uO zUXiK5p@<Dy#|NFA*@G&pVAD{>jt|;9vlBeudYkZL@~B0tdyWs-b#<RAC}Psp10cJX z2WYU1lDqYWCBnPW@H?}I;_>6aBw+4Pu=`;ZeF%RjtsXF6jEj%DFoo(~O#EwPK~!T$ z@(>gip5bn@@|IdlKdjX_K9P%e{H!s;JyfVZU=7x88bnl(2<JiX=(5Te+<}nxH!vSE zFz?RMxz<a5m#UzKJSOH{NdoW8dHAy2BRfDT<El8FQW|tUMHF!ZfG;QfEkm0f$-N}b z>TF=ZsvQ^2<wJG%2qefLl`H%5uP7%gNo_v-7pdY@U^y@%J>psjtl4c8du6%;iiZpe zDUMIqPzZ)c^7(BBDHDK)T?15T12`!%>K$eci`ztur03taNCY3;8NYAwHl}sM9ev{o zE)dQZAdL;Fg=&w<O=0UT!jL#h{Ertd9k2CL-HD-$(OplEnuDM>q^9!450|AkET53B z4^$d5O;xXzu^jmFY;6Ez{pE*Me~{r}0w%r`j>1+g>>6-!lBP@mRDf#&vAn}8QG^66 z2-Oz(VYfUIl4yBswbCq4@nxY{a~%J#_*dPF)i_@tEQ%r+WNYoX0I19XK-7!BS4n#v zjhL~}nuXKVg~jJh%Z{~QcE$OYw1E<U4NAOfvn4Jar!nLY_^GIC4p|-7r06kUdrad< zdiRG+`B_cI;&7EV;s{*@qbM#fcWL-R8}y$aaIy7uLXi7L`eqx1n-Ze_S{(><c0^X! zqiD9+8~)ic?=Pc}qf8`bP`yUJS}0SA`L+h*dN#!PXo(ulBPANE@@cb}^0%dK6ZbuR zc5i(|&TD93eU3`(u+heK&4ze<&G;N4aS@J<zRcN~1y!3I7(17+&PB<3h8%46=tcVh zSx>6<^IKmpT=MSSZuEag*SEXX|5GPeU4!y<P=PYuSMRE^d2sIFNJ1}4NNC^shFX^J z!aF9|7D$NjzUt-^h##+%**%Jbks#F_+0e5lxi$435F7=bG^3feNkVO1?>ECM4?-tX zyOb$>4Q(&7D1r#@6CH7osk35P?l(nbirGCIp?lbx5-#ms)q|r-l>!aoEnVpwLz`Wq z%LJ3{RS6w<<w$|*K5J?Kd#N#SZ?g`?1(h(&KhJD*h@<}RA}cqzw{kjSqxcuS8rTwF zsFpn{Va3bLnJtWU9_r3D6;n7#QxOda*_xqegnXM%q_9&I0e1H@!JuYsr0Zcd=ZkNu zTfsXU%|`F=kn|&(pIL927Js7BnuLw$dd8jCy4@%~)SS1kp*b0<YtA@R{$M)nZzM=9 z9jfUaMoEz#S03`BYBTm3cQF`wV<K`9^>N&ae8jEDPvKUOK|bPA(5>+hm>S{XYsMfC z&ccyvFnCg7X_@FtW6m4O=8-<CG+oWFIG0Gn{}hYj;KDt43wWS?->Xo3#b3CVkK&g~ z-A1(8IY?hqq2624Vc>R%!3YO7RB(VM!BN>xIPobTwiYbVPJh6XIV{sSut1_<T^g8r zFarKq+_BK1xFDVtm=xA~8GNyb=fyu8%*@4xI4WDc3&Y5lqJb}sqcLlsScgFa<0{u2 zyv!GWB`!;PuN)uOsqo9Vuqr;TwQ<b2gkjA_G%g7>ouMc^3TZ(yG+|o&4;w@@+!55Q z=cFvY;gCIZEy53-2?it&5>9`_%m3q;KLrlrSma2J_)}(Fq({KP|58o-ZM?=7@B+L1 zR13JYc0ZQhwA+r`)>vcY%;`<hT0s8JCx{z7s+?^&E}hNaGO=5YRA1bx%YM_#J`5E{ z%7H4PA$U+Aid98l!LU1Ks!W9M;zb>o1M0?#<h@73kR|Wr3!s@JvVoB)Mf@r&d`73* z6##+!Fje0pfaRC`XVvYQ*8H_?fCZM~tsaHapl;7hl~p%N{VC!@&QsL!BN3k|zy>Rn zzaxv0sT9+4%qkI`((X*bg?1Z_PAQVPDmq1k1vV4JV4(q5Dp!eQzR;&TJ6pTim+%E6 zB>;4>Saif{IP1S9W3yEZRGaE(pCd2vSvhvg;%Xo?(iU>XI9f%wb#RJ7-<i!jW_t@m zgpbOYG$KOl28+6DxD5C*(6MnDk`b37S>ZBJt|4;L`@fI^AU2Opr~k;x*kn!)1vewy zp<;DbHsy_#F|pz$vqof)lhScqh+-dFHcYM`j>U<=G-kLm#Z419H1!0Zx(`6GS-W#J zd(K5%A(p%1w<O$9U#(=jq7}}!-Y%(-9c^uWV|}Sjf<(=#Z`gDRf<@Ny`{IM*L!AQr z06AyUVvrF1jp&L`^<VL4Mt4L!X27!o;sC%v3Us3}o#O~=DjL!LA!46xOP$2gQZtSb zh2ES2N`eTQUP)8JhU2Ad?U+iOT6wd29_X`Gx{I+)U;+d2hTbihiV6T#7B50O3Tu$f zmzL!e)YR_1Yy9ZW#JR4@`YEvzdRtDa2W!7d$rYZ(VSAhu-H%1y;?C&0;{OqVMi%3S z%oJM{EZa_x>sMsb>r*-bbz<}B?jVPwt63)g>RUC4js?Sz@SM-^_OJi;eV^A?_jn5e ztc2;Vlv_yytdfdr*%E@OD$yd5^Tr0kt_5TfkF11H=b9>VRYktTF)Hc1h61X=Q5j^O ze^VTm^!`a2Kh$wDxHxJ+GpJ^W@{uSi@hw;=&Pxe;CA&ZK<+~pH$X_h~%Lk9$Y*Db? zU%BabKmQBA_3$IxZa%K>;t8tJ7OW5x6IJead?4mYcXt2kSHJ$ygMW7azdn}S%qi}l zc>kZh|Iyq3;GVxvZa%&{`C*z0)lZxD2Rl;`Ku$Fj>uZ~cJJ^zC;3LkP_}&;;T}lhb z3_zOk_x^mGNAjW07s}C@=m?#^QWH4rs_;#bf)D<`)V&LwU1wSEzb^Z}XC^ys+NQa6 z%_OvGnn@=2q@^XRg|xASA~)fHG?ST~W-ghT?3tvEAWciTs8y?Q4i^Da4+4rHc#5K@ z>QE6;I9$#_L=T=*5dQ~{sECTlt>^oD-gm9N_gvCK;m^mkd-mFEy_e^G-sk>4@4JLE z1;m*&4|=)Hla+3sWV`EQ@T4san3Hu)z?(Eg=(2WDCv4Pe{FWG($zKW4o(=2|LS4R! ztw?Ys!&4Tg>Sf@FFX{OGW|y<DB~El<56kaiK1w8riHViB!><V}3ed-l<VxuiE~4rK zFA0>)dHa&|#Z1f-=tVGi)3?)%s-KW}|4)K=Ahhrc9Hz7%gtu6bNeo!cg4(l?=6uGD z8A`Dp+`Ysto21UA^3D0i;25UAzAiAS4vmRD5ezFWcw)$^-_T|Jgl?7zrM^zkSvXPJ zL?N9_Wd82G^YRC2Eeqh?+|@>A4MlPvvH?8oV$t=+FKWUVAm-GYb=e>CBNI#j``)PS zNuJ}K*zML7JxI@1u<*P@QB7{1tu+2lBxg9|)_w9*d0>B>Om6(V>veN<UquF^P>jXF z%iaykb_M`9?nj1rD*;i{USx+oO;|uh{{4l<E|RXOrkr`A^jyU&@D)CM&AfifEl)oQ za31Meorb(H2=6Ci`1*<?OeXbPF6wg?rC%@frSn#Yi%+=9@6||jW&jqBm;PnuH(h(E zE;}~eVg!VO;q;~SPe^3UrtP0>1`=EDcXaV2%ICq!_uE3%5+@gTm3qf=rABDWPYVW$ zgg1YHOINYqPh(0DygE&)N6t=1_sORcin)mh@30;v8<qOTM!Wh4!h+k;6ut~Ti4i!y z154>ayR*J9P`X~(e=CrU_Whi-Zzta~VGolXQKIzZ2=~Vhkfs`ZpA_H43(t6o07ccY ze+K{pBdNUWr9?6z7Alb{;3mmS4J5Pr!gPN$MD9{Z_P&z<9V_6F2zx@r%BcOgKGwx4 zxfT(pNC!lXyG6+~hO%N80>TSZ<veX`NrmQVy3IqXbz}Xvic4jSxhe7!<$OTX4%Cn5 zklA;Rr}1f|N55@^jSj|GO<)OWX`8xuyN({}T&6oM<&tCp?KfqktX?C*mrP;4CP*0N z^a8!Vf_*xc`-Kv!#Rt_jdV+mnDGB>&<M7(X`%Pc)!os$`sI>J(sjV-HAc9Hx4)ulY z&GY)AxTL;7_ex(BwIbWr7pmv=g-D>tz$%%>u%cCAPWlVX#cj;VD|e}&U<=$0L`u-C zs0ZOqscQ-GY0gdp>0nV>BB@IWu@HQi{8k--Wq6}kZprk)2(ULkdNEN<KjKWUkE3nU zx4K}IU)t00bcF#jPYx?}SDK52eA4cUc0zB}y=_g0McCHgw$`d;TC3ctwerP17979g z`urpH`CxaS+Mbd*YaeuvGQG%xcH%9VbipGUa!R*LH$K(q9mM%<mrf=~S~T7!*{7r? zF|M3#J!3_OaG35$c(~|MeJ#H{yPh>Y-4@gJ9-hf6W>GGvzYtAqO`+%2Y=7(&08~n< zk5>WG5|BuiNR@<g59BktESTjwdPUg9?Ov0K0|Y8SOJJD8gvd0NjmzPuI7olbX-oxl z&}Bh-pIbBg&!#jjfbPqtzvD|f{BWQQRdS6Q*qNw8PuQ4O-TDui*w<KvX>(|z6m8$& zo!H#4>qPIzR+ZoJDA)Bv9oN5WO|je*+E^U!ZGP&n(XhdvS%YvnqABO{xRcA{HkU_j zE-wfMDxKd`gD0*D=vxjCWZ<F;h+h|Hurxpyadax0BN)=Kt|*%eMAm<*6G(5e{=k4E zzEU*piTA@$Tm~@`nUje4rOaHA0$SvzFfVp2u}+i!rt;i5xk?385NXY!lq75+SKRlE z|2}Ttg>n8704lRmfmo0@(rYo0Fob_h2}|;zZzb8vS!-OY3i3-kfw3zPh1j*y7?z?7 zBN%x})w=_A&=v?%nNX(cp+`$9Hd?bI8?m)aNZP0rAmcY$+S+M1QEoTkt7_Rur}Eoh zXip-5-R{U37i&_-ye!c+%tb&<a-KhhAqzk|f^p>VIfr)V&KSCdMML>xq1y^&2wD!^ zB||sBW$0FV;efr`Qv0YG1&4?&LN_jlsu;REPXEo(&H8_iZnqx{{Uybcq1)jKXV|cg zhbCKhS#-CXScGm%|67P|A|f5=hA=E8-UUcOw|{YG#%3KHH(^r2A$G|j#EXVttAvIq z7Z1{OD<YEzt-bl}csdatv@4=gAN@Y`wWyQRT`@W@-2qP4pM2Czl*j3toq*PxK;Pyr z<#ER8{id3@cyCs~WIcgE=Ucv2Le@gzxj2vOZ_$w_lD9bYhD`!N{_HEC8p;j7nb3}r zKx*JVoVCq_u>~ca$Zl_Q@4!5O3CryGaUw^JOXlQ<!)fuK-Gqv#`F_AUZz*s@*ggP@ z@WY}v6limmy>{DbD9OdPAxgSDqIYiF;c)g07hca)r2TdRb<uq|9KWe$$jrTj7q{o0 zxe%)eSKl)y<OXZrcnte|IkjnT$dML8ZT?<|+I-N|=C6lKAATa70<h^wUG-$kASB6O zFL#~nKC<MAo_mwy9WDn{i-yY1f~Im4XbqGI->)0MMzNCtI1ojtJ_J(5HTu$y69Z9^ zpUhl(Pi`(nh^<ZPHK@Oa4XsOMkSq%@X(AcG%D`wmet=gCU?uzOO)3Hy6m|m)V(hSB zMTZ#%h8+6I9i<6=P>(8Z`}#>x$*nowX3=HM^>kWuY%O;vu~S`^#0FppO&v)2ZkIaW z^n+jO{OzbKGdP!B7I4pnN*CYbo{I003OV0{XuV(rCQXg+^>z|}7$Cea6Tt81a+N&q zTdh9Ta+QmY_w{xXt8C9#xaTTX^sTtkIsfbJB(}JU-R|B_;x|egw=c!_D9Btzst_Kz z2GbdKF!7s*co?#@iP5iP1cDG9o`s*t5VzfO;pz`?kcU4QWTP=o+Oaqo{X0jl3{0&0 zOq?QUNDEW^VW8|oF8<p>E+}dXxxkr{+F8iOgcN3^LN55F7IHD9`>2cU$;+ZH)LSa* zLN|{i>QZ)57u;%zA2)qTsdM_Wtj2u$-gO?XSVkKz#}zpE8CP&N=i}uHOZ>b#P-V{* zBn5VGg<>jjjf%CoLZx%AAkU$LE3m;8>5DKXDI_}^b(BSz$b2OJiB)s{2on;~MVLC! zs9HC6qS9w?v^k+FPYd}R)b%cgrI-O&g2pQq`hh}FR9aQzt0!|}VNHIrv?%@*nzLn{ z&iU+ON3RUqKpYC9gZeMQf0vOT`PeQUc~zjuqvHAo14|dL&_dQgglV4T!LbbV!)W~W z2trRvN`a)F1P$+U`7auXdeT6A<bp;uo_vWeC&T(%!Y-r~e3N#}A$8bnp{nxyL%A>< zayrK?E^G^u?vvu7jYo|KUBC3K7^qOC_<lb;Cjn=tUDy=}v{h-oP>woo*^SzTMYLWG zxn^uq|1ZuyNm;{!p5S`5?=~WpbxsuhAPu~=S=L4*d7@>`8P0F3j_@InAX;`IVTES9 zkhE8XhjMmlo(ae%CezW1hLxzvb0Cx(Kp0hU<DThgC%0mg??S@gv%PH_nJJ3Es~V?U z!C~gz8<_lAj7w(7jyR((vLys+D;6Ca{N6V;|HCF!?b!;K;!yI^K&&m1`mh-&$ez2H zM#_ndP(=OxVO!_6vniPgfg3dDHIoBMWYJHPuA(L7B)S`PERrjAEZ(HSGe99Nh=%h7 zg%7ADiy=^v=<Q8z+oGvN0Skmk@zTmy%Cs{zWraPoR|}Ukmf3zup?=lU3?L4_kqiXT zuwuONG4(2QGCJsu)6f2o`LlsMd$VU752--C{;eqN>0qb8(Y~wFAJQfZL;p=hql5ZS zs33g_S_souTjHc&GZ1S)T_N`iF3V?tNumJ}KC^ET5jnq_B!IxF7Hbj1fEbCWiOrN$ z;9?I|o%6^WUMx>*fY<)geL|X$Fenm63acq9`8+9GR8>^G9S?C&SiGiERh1Oct-8Pj z+n}|n`C7acp1RjpG6Gy_ulU$-JkAXl$I?nYV~{++v^4>c#SIKU4;w1G&@8qNz+T3~ z76Aj5Ry6%k(D)rUKQVJOuK7gLon^~LHb>r<R5DT2_#2fSAnNaFVn`HB;1$qA9$T6N zz)Cq=f<^j4iF%btRQ-#pt}tZ4Y}+Ap+1ilr^*)<OqM!Miio0WZEseiab$4=+Xck29 z#gaN=z>`UAxyJ(q$s*#mg|}<X3*hjmeX6+lhgpLb@<oO0nJs+BlqUQPZSfrS5+Gtz z*7Md9=kA$wsc)jEEMe8ROBH$c)G%y|)Wj@Q@0lc$uV%0qm4u*GSwGsmu1M1g(2+*O z))6-g8Poz;lW2`m!Zl@ylQ_iw1<OBV8`L{y200{E8LR|vo}rNmhJN-cI@CWN#3MOF zk9=02b7VWzGP}0F5{_}uG8Wb~>K+b~b#4B0=FbXc!%?g|Czq@=LxsYT?qU6&Vhp71 z(+Y?391ELS@TEX<qTI2~<Bd;>L`*X3sYz-qQO?M7$rSL+yHJ=|pwHn%#eH$Y6cw&h zqPd(nGJ%DEwMSO3wfEoEo(N$u*sO?roX#UKbhZO<V@2Iaa*nS;@BhO}Gt0ho<VqXk zt9(9ARF=eZ$-M=h238FyKC81z5DjXu@hP?CiD{LnU7D})hq~(&uYi@2Z9^+G{--@5 z_$z&=G_@F(m%CfeDscyWuCY==N1)jt5e1)6Rmy$Pz9BW<QZ0!oE>T|yIJO-^!a9gV zkztENBHt)whoShn)~INf7C)qxY=Eo*mKq<|Pm9^*4?*A(Gv|(KQO8>we_(B_XWXuN zLG9X<PP0kXTfW)dYjS`vv0!FRi3M0TXK9hQ{Bo9Q)v?V32y+u-OOfa8LV9j!+*@Cz z`b)6Ngl-9s%q_$k><^5<M5~}jf}Ijx4=_I4p08?7sn~O;a-2e-2tTb9R|s~gGO&tK zifQi4*wRGfRbpUw`D(kgQ(9LC9;gLfyMjN_E~sE@!#0f(-$wY_?~eDZ-e*O10Kn4b zY~!a@AOXJYCAYacvGWNAgP>viU3}BFBaas#cD9#=5`-edBnAhRa=*2VoboNVor(G# zQFlUd?B@d-YuFYNvAF)zv<4hb7$Gp^cPigYL97^(buVVXBHk1AkWqI%m3QFY6rQ;@ zDR2^CfNYbX&c_3GUepD5yH_cBXMV@bc;n{<x$9hlK+}ds1{58Ir7QRdIo;KGSnqzJ zXO0w*L6fiXP2z!Blaf4PB7aOxa+NjN#!@z$mfEO8K1}cG!m@~TVF^3rg>=qF=y53< zsPX!2cCC3jqC+Fb%w~W*!$aq5;SEo?g*QIF7L0O#L1i_vZ%9Pcf1@KY2>Us3Lla>{ z2Z`*g@<t%@)1e!k>mZ?@2ROfACXXZ}1Zu~PG!S8-@n+-qO&Ur4bu}7wH;QD6v?YTE z<W-%oZB)H*zf8M}fh7sjwfh(_i=u(qH>^{l^qz7#N^Q$Mw0%F(LGfXalW3un{<7q_ z>38#X-}JICZ2Xa--IXL#An6k}{@8BPvD>V09CC9dBp5y7<wB%)nYl<O<jG28HvUvK zb>x^<rWzmCFRi%)u(s!Ln6kwSxIqz)c+s9&+2>e4xPA3|!n`N)Vq}=R|1=Cb<IvJ5 zz-#|A*DU_f0$3WKc27W%IzV4VjX&vtK}z(oVBm}vfK2ZXNkIdo3j>A!&uVGqY+`V% zt((EMNV;agdQ4^8+2n>aJ|iHs)gQUj)A)#fr6J;sbRpx~KX=dZb5PTbkLJ&zLHQt! zzi`h14ODkL&rP#yG#e)V+6lmjlV6ZK+W4>59;77D(rX+4pMDcr2&6nE-U95fapZ1) zw9({;{1F}zR3IZ+Bw<&UyXD*!Z;qL0Z2W_oqYz?}KnBgE*K+O|=^j*<;m!bDgG`w5 zl(z;ClLH9MF;J;#vrPi358%7$i&aJtf-T)20ybZC;hk`63CoX+O=NQYOOhHH{;8r} z!v-z60vrhyY20d&SVSWKMUf`TsD0<o1kT+VcjQc812Huz9Xp*9tF~5i&99^?){`MK zdfX+!&11L8I6;<@cG9!=+-n{LP){UrD?NMnJ@@K>Kz+%biBhgAll@Wb%M$RKU9v2+ zYi-f=fEa0sLJ$H|BNpqAV|1`#Kx2kz!BK`T%Lu4{Ju(wY_)d7p(Ngwe3eA~M2B*Bc zM@_Y&GxaEJBEo!DCR5X{$sLGtOec{GYci6omM8=I)Zv7#0tkOx72>RdX!7y&AiM(* zUkF)4m~4cBXd;OgAs-Ym0Vcw2zI=Hhc$rEb!FI}=Fd_2u#Ap=G7&|cU#&0>lkqDos zE*rzYrM~fh#Byt3@F8!_=I;6cFSK{;ZK7O%sGViBM$Tq*$x)5}tAh2WHQ{~7bLLv> z5N2sD=eW9i1MnhRu$}Vyd*|kf{4kVH{F*LOSc{4JXmImd+-$w`k2o~WP~&f{mlG!0 z8G6w8lEIg4qyRo*cmx+fKa(9AcCeg{fAL$gEjytyR)i9Yw%K$+DJKGh`o}{A0w|?5 zb?K;4SJ0Kvj>PkHNN~JOzDDfO+7dS*qs1jP-=Zx!x16ahO_#m|0*Xc2q9jKcvIlL$ z=`x&W#ZAnK?rk~PCLM7FSHfZw^;d;%qG2Hyqw#~0NRtdT6G)^262--cJX*CQ7aR)~ zB<xIrMJaM0Y+5nkw2p-sI`(2#9jjH@yAY~fSThs#XNZx(;ZyK(=KMUc=4ZrgFJ9B* zv~l_%vcsi;x!{pSmoOLfkb2pk3$p>gVifYG!LYvAXVC;M{hz1KhjNMqH&GUreC=mN zlP2skIvyyb?k>?Eobgpj@%p=TT6^*OvuE!`yU(-Vm<6rWHK-yF#fduwA)Psye&$#I z^jC`rSh&8v(QIUP9yv>wEX**w1=^zQ_C)&U0krKO72NIqQWxMvFgR|6vr_j)A5?dQ zgXaCWUQx^K2lbQh_voAMHz^*;_UH8M%XFeiS8F8BFuI2#ow@TK=_bi2>ZR}ChkR}> zALOaO{Gb(lfA)-x53a~Z?f!hb0bl;Z+_xeH>`(S8feFI^n(dbHbU&3HG@#mHQSlj% zbqrCN<3)!JO6W+lbj8Vm61*Yvn!$00T-Nj^e*mX!hp;7EQWOB?d`TP0^Eu9DUS|XI z-k0>k@$}2_NiOQz`bvuPK;bmlF!ojh-I!v%<s|R?OT$?RRmr_{O4LaZZ;po8m87_Q zJFe(DBTwaJNpQzWMk<c8c_G2h0*1<OWu-t7DLNO}x}sGz=m*lnNz!o_9O|aRHebf4 z81xcwCWW~aOt3hVGJO-Rlg9_D0@$V+fazTT^M^&LB>}G9aZ=~P8_1l4pw(_$Y2n0D zknUjxZk;#I#!old0-aKB0)S58>@LU|SS7vl<Tyw}axlhKKAVUXGoHk!=LV{Xg|0e^ zm4g}CReE)Zi%L~>o+xzRBLWa`K%rj+y@UZ9-X*1XwL{;=!sHK81gv&Fl{><VOTw<3 zN$q$V%^?!r6AkF#EkI|m7^bCuupUDn3XC-dis%O}IQJLJgU<7!AezFwpJkx5mTE=e za4qt-Na)Y3C$y&Iu5-kAY}U1rkqan0YU$Ad;jR>i#7aL0r205?n73xfsUan~S4W0d z`-!?S_bMZ3s6-td8RmXB^qbsrbF6xL<RD6p|NdRD3f~ustY;A6UC8hl3XoRC^s;`X zzQbJv*;FY`FBd7FzI~uD!8QT71cg44UPo#>y%^Fog2kj-X(QOQlBQTa&|;8Y3QdHk zC-{5Wp|x}U>bja?-A~2<%Qh@GBxRcwatEvx%@Eo0WbUJnz@bo}y&h0<y8d)pILVwg z2I_}+Rc%t{d|Ymaw4ab|uf?6Z++gzU-P<dwuu>&wrKEOJ)7*`lq?i+m9kvf4X&yQV z0Wc4XXmroeZ}Xy{uGXCbM;%pN7~ERqM338QQMP$IlU?N0PiV;G^RMm~q+IkMgQ_AP zy@OutEoSh<qtUoho_MMv4C8D{1B`Z<>WyVM5df`~Fi$+tOc(LkZ%;hz1Ce;pvGNPZ zgk)b^V_k4NV|5<tm~Xv)Q(W62G5D#RUGX{(oQx62b2}G~MM+!3t4Vn%qQoa(Fv(Sj zcpL;cjyD+#oi75lX8_vNTX4+}6?+1km%ClyJCv>#FAK92_Yg(xw)56=L!vGIvRlL^ z8Ifz6j=nqNf~{@2aRIUjGjLd#aShWf%>7ZoOXdjAryz1-pnj5^8f8~@hQF3T#`|M) z-1A{%^!A>Z(Q6%4JC}LAO;XSi6Wxt<A!{&rhzRM)-lAi8^VGWvr1Q91*dc(6mXgJl z?W#1rH_i?f3k49T6&tW{{$;cOIYj?ZFR+N}eS;ZdIR;FAJ{id7SRx283&=q>6rAJ* z;~wdV2~^0_X{DZiTT28tbD5bQ2>MtGsSDW65r{FNOI1-T!)J{qDRDkOGSq`cIx5yO zuL^*lgNX#3=j|8sLa;i6bz?jP(;>7Ni2zDJ9B9=PNc2uK?p2cd#DXEh*BVY~0{h3o zuIbQet@hr9l7qx;j$wF=**~OGeZoCmqt{1KMIGJPtA#AH+VzeDNnO1_03@^1y<0mv zgG956CVq&?sb<IvIkwv-rypwbS4z!g82AqEi{5kyHn$;0#=3vp2*`Pgo@348!CVEy zu<m>0xHB)Cv1A!!*L+AoJg6l#_g~jDIiZ@WuA3RPNz#he+yo3%$B8)Uj!_wk!kX-= zDd}jjEH{E~oaj8xhQGyxWaE%n{3u3ALaqSTIfFLq98~D|Arw<?+fs0o403j7boI*K zVu)D=dBF^F$yw;Fkjfy(ywRl$a(^j<ypYNuw;SjXS40Lm`Ip@H4fygh$n8!^2Dupm z>{DQFWLRS$iZgC9I@cNRCS@IXP;@p(ie!vu7P^^Tvc8xbRvZ&tdn|j)K7k2E*l1o8 z_^7J1BF~SFg0kD`lZ^s?HGQCx^?+5=BU-45W-t!DY0Ue<Z5chlB<KPF32xzbVOz&W zlQUregyOGkmsuvzlmp+5ahpouhH1K=INA}V#RnbrfL|l+jBXU4Ea)(g3W6s;yV7-) z)iz0I<P7x*4PZm9fo*=eu%|SGRDRc7L~brHbtk}$!vf~88=P86_aSXS;&_^x*8o}@ z<G3=_bR+op^H$5S%6r|r(S|whtY*U=-CP-cIOYf~8P``v^IXcZ&L|kFG0Lu@^Y&7Z zM~=v=95N+&4!u!U^vTGuY8a~QbY9bhVItF{kiHB*2x`$?CRT+Zkj!DwToF8ws_GIB zn0M*dLgzLho+)NANsKlgEbTPVp*amCQ<w?CbYUfcb|};nYK3zU7>aux;ANI=Wp4;E zgSMPrPTLC%+E#kqtQHr|3RuTZ;*R6SJZpeBS+ru@e&o0TtAUG^<dD@Z>S0Ds*BC)a z-H4n?g^~p`0u>7wYXl3J_pHkT2B%0c_YzvgWz7gKn;gTg#dMU}tx^bRjNE~`Bv@6m zP=_w8S5XZeC|QnIbjjB12PtNjsBy^9At^f<!40;8cHpJCu8I)AdlQ`yff{8KEwI33 zkz$D~l-=?;k<iZS<9TjuVXB#7g$X7;ri8bzbnrWRiKArOF(DcCMwL*Sv5}Vwa0W}m z|0Gj^Dv?;fjxum_umIcI06~a&4tXc{PSNp_CQX(zX6_<K(E0EKH7C`qNg+3>swO@L zx<GSgSF89d4}d|zAId7p>}*=rBs3TxHCDqMW?uuOd$1~dU>y|nS{rc=m}=q9xc~tk zUAGOaei^ulY%e3Up~#g6MQ(n8LR**@@fKX1^H$NiW#O85zsS{i3q!7(9p!QxZxtQY zFsHWU4&jKmYzh@EFdu~e<!$5CU1kSd0fToL3+d(bAPy@!mr>cQ#$lK!R-1*`#T<q& zknxv{gc@)b*`O}A!iQz@x+fZ=)!2%sBCJ3~1e`z>;#fk2$><s;!>tvQ;h>1gN+$D5 z+CF1!R*={)ip`iju^Hu{8L=6;JGoZ`n+XcKXm{A-`&EZDCkILWJAi>kHiKZZ5QYsM zJ5o{>GBypSkhRUtg3-aS>yRwp5g8NN=^$cK3W$S=h{-1OL~K_bL@ebH9h7u1XVKGY zn}~VfJxinRC5c$9+Cjv$v6zTKfXol?e+eI%5a=M{#W5wFJ&?zgAVyxeEL^2|L<yf@ zAqnFj7zr<mDH+ic{dDf5O$eSfzq*Y-W)MCm9&F^T2(>0lA^kjOqFDdC3h#R4(VbpL zlLr|25ry0gO>s+1vP=OJIDL_y)s|T+tC^fGnpWbs=*6~iL0V?u8Q(eXcO<hgYOrci zGf=ZFZEMK|BD9dIeCEgt5w2LOftG!P(qdY=a68i(!AUp)E2c*gEa*e)qlg!-yjhJ$ zv<PzLBO*&_=d=rnV*RikRp@3DL&lVfY!F~_P04yNGd;8d6lYOMB1Hu#!n0K_3xSty zvlp2sx@S2I5@G~4$_i|h(T^budn%(;8Y+qG?YVwa!R!?m*sw4Zz=_^mH(isU9@b%* ziuJ&s(;%T~5TT59%do3FlSUWVKS{ZZ|2PPuk+9H$MXS-760s}z53Xk@1fLxLWn-do zVL-2!rUpu+jR1R~*H60#sCujrI8Po0y(<kwNzTp)LkM|U#Z_j4akYe!2)UgZV1BZt zBv=UIfef%<5r^@_I4p7o7=!l)m~ohqun}8ZWiW3j37QuSUP%Nf33v-75jiCRaXKZz z)IgxNJg`_v5bTR`H4)Mx61pHiB8dosOIH~2cT=bQL(>&3;E2D7GdU?Bh(%P$X><h; zpes1(S-Jv*OIMUlSCmBwrYp=vh@>k72)crD*mKeqHj8BunkZ9UNS?Mk6eFu&btuMk z+|H(94~^3mRzpR>0J=g+mvvhg`xjdmdwCycya<u9ED%7@C|@tN#jOa&9e^%;1k>zZ zU=&io8BR<{Um6u6iE8cG2oXDgFp?5O=)a_!$l)n5E0s+qpeBu$M6VhNWS*r>du!R4 zLh=CP#>)c<QL#VjSM_{*RMt>?xMlW)U*4f4dsj6`^1Ph3RBiLxWA4HFMX`QS5f8`u zpv)@SaP{1=z5-3MxFzl|FQxP?2O#C-E^=u>jbobFw0jq8>yCEKdLClE(g6vp31kdW zjAzJIfr%xMCV<yr-AR%_98Qv$=dbGLlO)A1NurgA#10K%8<>|QcGV$C<eqERJc=Yq zKqmr7HIO82eA<Im?FUILFW%6CepZYvqXF8(<$-*-=#GjrjZ0_%BlQKkD@R@u+Vl{b z>OwJ+#vxf>t858*VOLlnOLwNXhe`TK!*$OTop}k!oH@RgxMYxye7(JFgy_t+C3>J~ z5wL7!r_dnaF6pLZacY}F<B7eOz*_63=-AYmN_vWpbBgAkQM97Y-yKCmeX{c%3Fo8M z_`S&xdm7EPT{n6K*7t%&P21U7c#o;=vOzDR(Kdz_(P*a6@^LZ3MO0zznq?s@_J{Cd z7N|Oyl0+_a$8xHUDod+6NL><(FUbc91HMFqynX09(sWjtBg>c2bWl;Q>CEmGU&<s< zM4Wo8QcJX(!dRg1WRc?v5DDO1R<|c`Nfn1pjYBOlYF30+K~ZpUl@uKE(<wNu(n!JC z#1$n@MXee<3f%RO6wF2@#1Ojl6Tp-+WO(8xm_bysF@fUwsgzDdqKG{DTspx5@gT{S zXaRr>dV9Z1Qm;=&${#m$kbEK;Y)brHyaWDa1F`l8F&X4(asCC5?-Fln-w=D(bmFJD zzF}D=w)c+7f9d#O014Rof*dk#L7aB3bWs1rDw!zMQL0er>KToN=|NiW2wo@^htz{m zsRuJB`6lxKP}IDg)WED^So@ijSaFK^tQ{5xvI(6_?N4|FWKmF}gL6-}4L~%dBiL}T z><}Kx0)SNkIp}bP$xtQ4TWMr;i<ZFn2NV}FV4+lIjEgpMyYV&I6qfa<2QthTg;W;_ zdSs|`@eJSvJuaL)1AzHm&5USlO72jQ4$3lv%q4Fv4C5~HXe8v#trV_o-mNhkHDZ0a zQM*2bFh`0)79{V*rD#U7lQr#P8)1|UMROiaA}&7J%3c<4rF-WjngmeVD9hcNPdTjW zuj(d;uZey_-BLUb@JjAWVK*9+EK)tzzf_O%CURdYGZT%c1Ut2Dm6%DRDVbBQJ;!ve zIOT*c&8yUpl1i|JFsRH5<sK;xvJ1(X;hqetN<Y&JJ*kYd8sK<maM^FmiKr(%&Y;4u z7joLsxbCS`uj`0!%K`%N&H}PzBmqgY3w<e%dWPO*0a-v!PA6eFD*AvNS6D#W5z5~z zAeW7a#wA~rHbprspf&>RfjoSB0B&k79OsFl+9n3pMmveYrwcE^V<Bxg9<v2ivr95Y z<5`>0G+AEwL=s&RCFx6v86aG-%Ml7>E<i&@u>z5hzvba~<ZT8G^92$dFkF}Vn|du6 z!O0cgT_<YeUPuVOVsH3!&r+#LLv#cIAo!1Ls_0@_h(v@$=^jh5^@b9B!Yu^)B0C=o znwA%uNfdT=KJ|QivhywI0+!JR*iJ^4l=N1SCBE2etGVaxd`o=ku=Abe<V`6Xo!R-O zm=@UiSZ_iCK@GF>WnnoZ$+UL&s6_}09pd#$D#6WNaUy5u<2&Sb{(|ZNyo?RSh?#?h zRBlL9#9#?K-y(KI$#XlOE2vx>oduS+^D`7l_<1{@a!`32pY}j@zCFk$ooP4B<c58> z-?o%t&m2mC8_<|Ju!LlIjxDahKO%d9VGnT|R@;XC!mgGw?5#7lFse=Djdj*BHR{$` z9+_be)rT5V7rin{l)}=|b!m$2o^b6~$qaiM5(!&t-6ZS{d!Bd_wg{AwF!#J+Z&9f4 zf`l>bC1)JX=A&+-9bXpF3sL5VeK&dq@b`i!&9Jvs6ta^%QQB1pQTi}eHt0n}*~U;e zQMPBZJua>m8}{N)Z*(mY!&2ZYi(#>H5Sfwe40}Z*=LQr4=@N5rg*Y8R{T>5IMQSV* zskfu|PGEt2>mI{i>k<S*eF!TggvEv(LRhg2VZ~Vpi)o>4+i<poVbAhHZrB%SQ6>qZ zKTOEuG$R7H1K_$L#^P`mllXnXatFMC8%+cY0El44E`kNF&Ahh=R@=b0y}q$C@CC5a zHNAmv6KDp$sMdgB9PR$Z^<rxAo|wy{%fN+6#p!~K3ML;YS+Cz|sp>W>m}^qu-uO1m zr|almp=WFm_Q8>)D{G`Mmg2sJpe9N|xXzMJhis5CuN%DUCTe`$76WD8U`4pcHc>!f z8S3?S#n}m)Xc2&;nnJa>5@vT>@G?!@K8pdoWg!CKFsYR<LO%xy?k~3Rd8GEbto#8^ zabRDU^C>I`A7FLTZ>EsxG;!v>i<!pq2{b!QyE0FSR!{_|BbYcT8$Z^iL>LPRH?P(D zAEt9WVWBhG2vX5f1z=u$PEnTaZ8@CV(gCt+of)Gd1c`Z%rQJ0eLW~)0<b`O(t<ZE7 z;*K+0gbg)m+EfN4BV$LEW`cAdK?3FGO{qV`wrui{D5JNE=s=DLux3^f)9#xGpTsy> zXHN3{eu1-j)GOU&#{kuTF{DI$t6jWFoACm`qc2%%i(c+cS`%a}JYzmtV-P^t^>S}+ zR74q#M@3Myh2jB55vr*m{<IJD(Vc#%Ue>=2TvO2AV$8AoG~hzB49~xWy>ZXWX*sCh zAM#WkxHDWrHiU4*g~&Cx(s}BQJq6&ivvYLjnd(92RKgm`&(84&)|=>ZPeCTq%AcR3 zE853MH=kRRAgSab59*9DWDCx}l9B;@e;u|@t7#u%CXfY|kjR{g?QK!Rj+SC}gjMjx zj5`eaIo6VDsB3fs+Em5x!Fao-Htkr8kf5q5PD2z|5UU#-1@Z_iWiL-bF56Eq`U*iR z8G&{Lc46Pd4I_gX9XY^y>Q;a9i36GW(fLmj4OtQjT>pRHHGdY~+X&h|_$ggx?>G3b z-LJ68Nll<<|Iw+%OZCk$d5ple#h45O?HKQt!di|GvYL#YLg1Sr7Uu1s6J6m7vWL%T zjaA3(0Yg*Rnzgra?QzpFN%|opwsI$KRB?`&22qWZc!p&bOV*!OB3O7D8($ZZU1$;e zX)J=e<uX;L7d6a%8djX9G^nl+bU;bE1=7E97@5K^UBe$x4ezH$%42k<ZWJ1xqdRU8 zb7y*9ZnJ?=EX!2FUuS}wRAO#LW?885Q-<0kL~PrsdDQSTP)NVwX5iD5=1d{l{gNwO zNWbYO#J~MOYdHDAS`;?Ql4TfQ8Ii1`>KO&pk9X)C5v)9|&}n)RVs_xS<}EuQQfkLS zKd@c4$;{Z?j88{sV*2j4i(d*6dNuvYcN--Y-C<_+H+~<EY;AnYmVlfjgFXsIPi!mu zcXnLsfAG8iU;z`$$Ro{C+<s?-8>!uP;Y7b+D(z!(e7W~UyhlpkYlw~$w;*%jN*zk( zuy=-&ZE|Xs`I8ZXbMdq}Roo!{z|dAbb<KHFo;Iu{PQww|RiwCi(0HHZyeL!2SEed_ z<q&>zf=!Leau4A<KtW2<5|5qSvquq?qZFSb)C2k@v%qe#zF@a>MiH7rr7UxWQj&<N zmcsb!OA*#V-?|}in@l|!Ka5~7C=C?WQnv6k28V=U_&H<w3XB-I;Bl$JoDICeVGEmt zk+AY4@?aMky3RG_i^t}_5kYF)Z5R=c>iwMvYkLctq(yetSm%*~3&lkJ&ms%-`uUt^ zV|vBOc(<fZj-zyd7TC??G`FOZWN%i9(uY1Go}^dFS>olPmPrL3+9Cbmr|qG?*I>LB z9b*HRhC{qj#%yJy9Z6v?#i?+tj$VqhnY;JO@PLj!rf+*wJrO^>@IeLkQ=4D998yE+ z$MaH@(Tln{pU8`id+s$TDj<Y&da@TfJVpm<)pr0e=kMSnFJGdAi!`g47;^+XAY9+= z|A~s6hQkYhiLG}qQRHx*u9AVQcc2jK7j<0!#qx8YYTf1zO3s~RjkSt(>xcInf1utK zv(_%1>+k<qaF+M6^YyYHe>)0IdqvYP1XE->X_s)o0slajzKn!M4UtEa6RuGEv216$ z<Vet8`+VVf-dZnSUwuBL>d(@C8P-TQ1WEDj18O<%Fj%tf;9aOkNBcTfs>nO-u_mzX zp;(q67B?!e;a+SSxnTM)17bXd3nm)p!~uXCF~Od8W4*FFlpF&h)KTM?Ri^&Wh2`Jg zLicVNA3?&NG<vZ#NG4Hnt?;z$tDcVcK3aB}-OMeJNEV1?-e`QxU(wkiP><|`Dq2L6 zqnggc^vtNUu3%X)SmF9}kT7qxk%rgXPb{3vee+#Ajwh5H;s{B9`jye9TF0j8fDX(m zoX@Hads|dmIM-Nsmjgwk?E%R=3u|y+HB5FYYZNdT5N|ZF#h=vR>#u2Cxlu+JG6RFK zza~qk>V|D`dVUf)aqC8er)fE<Kz?i{@r5jvBywSLq_MT60@bX5rMfTCLRn+Mpt8lJ zIH^0517q96f;G>>#6PTdJ!I!2`P0{geSFog3AIc@QmV3b&CqpILF11#1G05&dz`}% z1nj8B%ss5X_Nkvqv3_IQqiX9*x}ZxPHOA}{4oSmnLP>Z8y~P3j@hytRHvUCMU{L>? zE>TTW1xM{dConU)vhzcIh3&V@W9)L1)hz~T_M?qeBga&w7fH(1{+ECKycaCLe0Cne zVq?Ki%H(F=3XsxDyCeA?=WXv3_blBWc+d=d$L;-u6T6lP)Cpf#dyhHFC0qGevHiNV z_In8(qR;m?3HvseLjSK2hhZ`M(fM7`kFtrg&nnqTa_z`?`oBr}@8L2z+}q37-9#(; z-R0T&m-0fko&Lw!r;f9Mh=W*FDmi3dXU`t&xcLgKRT^#lCrQ2DUWfw=*6k9LkR#_Q zOn(ipu+qbx38e{#7>Vg;-wzE2J@82T)@SV&723DHXt&tC)xPxwyQSk}^R^xd)RxjY z(zD8oB4m<3`<Ojb02t<cw2XgtmOW09N{5Zt8O(P1db@;P8*k7Rf=@!J-OdNqPCbqA z@&l7wlzgo#2~MGU<Z60%$m6r2lS8;rdTk+J*MuwpctZco!y?Q9I!afaM%Pe=9B@dI z=)r0253yUl{#K=ZOJPiv>S^DSv8G#%_N^NIm58Wg2;8pt8)2Ga`VBeh$SEfIU+mJ# zkSIkAXFUC^-I7rkmiAFDPQS&S6~65ZqCUnLZx8#f2`Xt#0Sbn!^W2dHoZU{P+*{*} zC7xl1yhiA$KU|E9cA|Q8(5!CLsAm*WLIB;Y?X(1y#?zOw!p;k|KyDnHj1lS6Oe};9 zl;#I8-gSH>p@Rt*Uu5h|Wb6vDuglK%Q&>!?0(PE`^1-m-=t_wL=$iWVXZd%3(y2Hz z7W4D-^$%)(2(i&E2(p8opubpt2CLt!%3mL#MxZ~tW$VK37G$*W78FEY%lKBB`h5=g zibYcBWZiJ;iy@ZMb9B=e@P3B;Ux*oBO^=*R&#^ntYE{!;v<4!nqcSgaHygM|9t)=y zoJ^;r<g4kyo1j~w)pq3$^6&1>I=r6_i(wE13euHv)15(mv+XXnCV;b=ewj#$EbSo{ z>0P5g3k(JU0dJ2EI0SUq&Gt}l=?xsF=iNh<0Xdd5PEaq*v7*jg$wyQ&toxF#btSTO zHtOCGalaZB{m~=}#oQ2aAAuqZ%h+o^H51O&gvw~glZj>mGJ-k*hZ$9dljuUU2rea| zq#N2n0);_>`XpL44c$^J!s}}$-U0;U3T0Zt_h<LpvS|9qqc@JHk04v&f%NRR`SE3Q z=J}CJQhSt%P;pWS<QxYmZsEX+`d{juMrqI=dW!t!hfHF~-QyjRwENA!F6j&mM$jwT z)=*fGhwjt*9FOZH5)lDa7>^Bm=sGUi&$CC&#d428{&J5%*^-ai9boCjhXm7Mv^k~q zM7aV%deQ0rD1BdDi!PCz(cL$ToiUj81QxZpQxR`Kg^|^~7Aq9BGs5()*>Q^%NJdCP zaR`ngp@@oHuzvbI#<-CfS7_nAnXEie7nrr)#oTgzvVJxsnk4jQj87LzTpozh@$tU3 zL7e?7WEcKhOc|tS2M=kw(zA~Qx*)K?581>)`ik%n*XP1REkCOp--NXB&rP00q~xDo zG{K+xaK{8|Y2%%5=3T}%!IjqeC%DjBdOkGe%RgFf7OAz|qsKkLj~4R@zU+HD!9VrT z<D1~OE#RNe{BZ|*F%8nQ-;6zaVS@AW&vMxKULa~?j;Gr97EeF<@OOu&SN}-|o{He< zcOS^e@CS*ft8zS5zqfdL*W<*~vCg2?LZ?brnRz;KhjXGeB<J49z0xn|`ZT;m|7Yjy z$YyFsYd%pRFVO-B2tx%Eu#EVsm`$XkGZej4)+K%~Vvh(5i>xL}@pVx+rs3GzWNvXL z+YrROfVr01PFb#XPMEx4ESjpbx(3*`s6YW<h*p|c(wKH{BSzEOAzo%ml9t3mT{4s( zH7#iN+>BWg$qQdl93Qu{xcb5_Qk{NM%t&yG54vO1*qv>=UW0+&^e12Yj!y)$6G<<I zn+`LqJ6mp`o<FI#{NafH%MN#xX%{3ttXbL$Z_V$Hqsy3XA`K&O!?3Vte_hd0N@+Ao z%RzcmmV5N5D3=SUFhm&)Z5&WiSWhY>{#97-#k>NO6p=<zq8@66$7LQz4A7>e2==6~ zvb|mRSmZK=6uJXD^2e~?Ci;7YN<ij^ED7dAE68pR2s3b_Jl-$sw=Pq@rf@H1aTB`R zlMb~N9&DldSG$Af#Z73VvkcRgodcrG(p`fAab|T4#SK&jgIao4p(&Me)T)v540`!n z$1v6Oi|&GW1ZM)QO^NexHjhfN1l}38f~>46G`Hka-+?^@>*?QFyXn$};UqkFb?^!e zgU?yEequ8E0STHcEkto7!CX%N;o<qSppv>vKxb9)i*RICaCRAy572YppQ?tU%4}Ek zYf*N^J9KQT5%#hyMBDsh>4@;~FUWWUomnH<NU{eYm`J+#eZdGI62Z?fJTeHal52z| z`xOT{I~2%A>c*?RJVj6!p#k2prv;BZ92wLfkcFd}Wf!2^rNtz>jv@fTHw01HloL$y zQ|<v^vV^)Y{dxufzZXobIk*eU|Enu+^%s>Fnd2zpSHLYN>%SVaHZ4wR|HlI-rJjb& zVO4{7SHKgoJT31aV(M5-lP`|;(nFJZ<{`@Im!O5UWz#G4#`Y;JPcNuQV^`03Q)-(< zc?=}G#lno;Vqu1bS-Zu;jNM{khVv2Z77H_W3nR6_!i?HtVa9HO*+N>fTP(`ht&;QE z1b|8JLN4{NNC8l+R+?bHvlDl*7kBXlY5t4loDQC2=tYxra1yWtCf?MT@PwEFPbQX} z0Vf2=M#U>aO6be-QVl&W`wbh~yd1eV?IN0^#m>(jh(Q#AO|OfEQ;V`vphKj{yW}L5 z)L;D#rKp12pI$}2zSI~FI_zZv17EO=&7X$k|0{0n0=f2FVOl0u`TwE}Ahy$|=(_QM zSj-l|b>E8F22$M0ll?U+NgGL$flm2fDBY8$bL>8p!fKl2e6RP$$CF;DrQ=7``6JGM zAWM2oBY6#|v*#rEIf10tTFo1zq?+vSMc=WyP_tRkAs|GVStuH0xcxmgEELoBuxCK; zMso}Rzk?l%K@%UCFg-7QALfra3@tv{SBQczkj8SW-xbB?<WuwmQpr0_G|nvbLjcTL ze@!r2TpBjgatSe$Gz3QErQ+>1DLN!hhg3yWptQxY0cZUW^u1lMJ$mM3`X0WOwZ;|( z8ZeE4I;FD#639g#jbAc37;7dH4a9ovkJ>PxTD!YXhnDKTU|I}BCfzMZ+)*$Yh`SU` zWIeKc=z9$E9+fF{7-;#r74AI$sEGvF(3b8b1S5T{nb^jV90O&Cfm#;^P(21`tsBmM zBBj0czb)CG&*&g13cM8tcw~f$ZV7qmunl5&wtlH>5x3~$$Uy@^0PsV$AoKxHB$HLs zm)_jI%jju3L2g7jfrIv(rV(Eq)UI`!tXMxP8*wyHaUz5!Z1%z=Z-^3ViWL@2P&P*R zg0b<jrBp*vWP4b&wG99SL3Bp84ScvY2o(?${gt#d?r%o^CP66@!1^l;ql#b!4`@La zvg{A88!J}eM6AJlYM*fW<?>1G2?ry#bqGO)wWME3+#d=XSm2VnRU!##`81%HPPgog zZEftaw@WSa%)eY}sd~#b7}-msT|(Ue^WelW=NA&+ls^EbKp~;sNALb-`6QS2Jx&y< zFi9*|IA_%wE>u{moPN_5yTPRTW7m`oKyUK&127`+xRt72dLZM>o5wYU$f5BKMeT-! zG~@{Q5NPX!nI#}I(VQ5viGQG)PxeP(y2_`*gb>CyDkh+7x=&PEWd;;d)Aoyu&?wkJ zkK~HxB&as?A7Fx09Ym2jzXiE#(pOE{yu$HWgOpU0MxIDt0yBuPO8rm(Pz+W?CLM+b zTOjLF;%}5Om7>s&rbxv)1x)?7&TC6+W+d?7O-%r{X3s#iYlYN^6_c#Pyw0~~{qw`~ zvQd$jomHf6(d>ewZSXRBIV{<(oPTjOVa3jxprIc#(wnt9lt3cWj&REC(@rB-LViip zc4>>XH_Qz`vsA<LS*oZ?P-{W5eC)ou)Bw>KP{;KATZRJRuN(C=;cNKPrSxmdmzHVV zVGNh*_eJd$QC8RLo6hnNQc%Cygmrg_(7<HS+6LsNwGC)ORuNg3<PDfuFE!pIMTeMD z;zrH=!vCue2E8a8zEJM1en)zibdww1XoO&(O$Zq>GDPG?_hLlgdgiDwLKEV0qkF0H z?l7rr#In2JQe<zDqeV;6l4hS@xJf+LT}(A3knEH`QVV5@M^K(7)0t%8&YNUfy?2vL zAzR8vp|o{Y(dw8`AoJp+8SYx<Q5q$NzTQ4x$#@vu(;*bJkl*p7LnyHNi(o`3Xb%~d z4<@*Gk)<>6*0O2{Ts0|qDwC{)WrJy<iAVQnOi9Qy-e4&S#iFvjc|%W;76vMI=D9eD z7M$Fxq(>AS;1B?Jiz_rVolKM0tz`-1)r6%s<UQ(GOpzsrd!PW6{A$LZA_!L6M5i!P zP|sjX$^2l@jJ*hMuew}Ro#g0@JqF9s?G_<-)`w>#5uC>F%smoC&D@hX04fcFuAy0- zKhPmla8?kNR>-jg&pTzGOHYwf?=a_FAGjsaVIfc<6Y1z>`-rkrl$Yf?;jX^nyqsBo ze@|SsY$mW|(F^%6Pi9bfGyD0%d)dtw4#<%6N?5f_?~aswVGmJ4EDXn%_1O_u13wWB znt1{qs(hS7LE?qxE_U;xqdEsjLollyNl>-;jAh&Kt4F^Ev?51a)uac7Lp!QZ(1{7+ z{HyW)8g5$Z18O>2uXt#nXDtQLo=MMI4nw2_Gma!dAJ{>zZcVff^<iLU;iR`(jH2;I z2_fiT#mQF_k7*t|;g|Y_(>!JA+(bWu<=}VwG;;V=U-087ECqjMY530zsT&x^khR?n zJ%S|rPy8ZcP0BY9)YlI58e+4Dpcu|hxL-?H1fvld9q<@NFe8h>WYX(j8q2Dt8POQ^ z$bgjNG1#5o-^akJ9@jZuKjrXxzsGAqModpuI6iniwz-U$ItI#)Ab2Ie$p~m)hWVwQ zCVltvufLCZ>o8!P`>19VI#mn4ECrc7odw(BJr<H)oy00t)T>u-Kpz|d&JU#5UG7+M z4T@hVQ%x1$inBt`2m*`{P56rJRM+|hkg$~INI=JI!*k;kN~XEB*X(KV>M7aQv=rSg z8eQ`i1Sk}G?Zr$H9O^~A>5yLwqOQ=q_9PW{wdfH9n-O{s6-$Q(iXwEXCB?NGJ?GQI z6lNPQ0DeU$@Em#Kj->4dUTN<2WJLx%N=E-J2fV0aIp9}_2pSIf72gH$(CG?Dk<ZO* zClr7H3|xOtH8n9Vdx=l_C`T=o`0x)c-?k<q<A6$|$R1B*J>M~rl|U^DQ7=P9oS2oY znOv60AO!EcX|_;-O2tv$?Q-H&XRB?d2nQu@;tNrmNXFWxI3>1erx-ToD!zJ^Y$jis z*M9{*{KUNX=6v>rZa!;^iwC;}8-(c$r1XQ2^TBdW{jaDi8#CG@6Nd#3+)FH{JO?)Y zVi`iW+gT1Q*)%MFl@{f|vbACWZIzPzIz^5RUz0leHP1+eOY#)hl2TyJSWM580?Rt` zuYFz;Ov@RO^tfB`>bIR>k@9-j^i2G4VJ@uK__RI6GR6`;xb_HM0<a5TxyB%Yg^e*^ zOQpaX7E-c{I#Xb?l%FK#O=?Vdhy#Ry-1t=|KOe0{1+?`>hiYC2TXa}Qbfd53lR!j> z1L<o7R`-8$)ozx-MvGjOkvlYgv$Ec?_Vn~tzM#X*MC>hqGJcD$EBQ^`5X$)a@o^sK zM&w!<YqTlGrw5u=4n584y$=MI1r1$WaH8GIE%;xUa#Zki`On>SY0c3HM1+@y$xKwX z*s(H*#>b1WgmK2rFUyGZZGGnMdHg_1@VeJRo&$N_>acug3;*$+$J2tVlPm_{zqxhE zg#fsthi$f$R!<JD<f-PzY#sb=Y#lu?`a+{P>-!e%>Y@}6L3}n(U{FRfc#RCj*uXAG zG(c7dc|)>n^-?0qP1YvD^eLo)aPzX0{pur5Z&T+%TAE|auPa6@WKLxx%946vrDkMZ zhm`A?TTm)iDIQ#)%~xLzsG&I$MY7_v@y!wowmm>E<?s{Lo<Kig6uj5u$0zmI_6sm= zZjpDhpUqeP{8Dovpcp;#`zzka(Dn}zlnm$&`V@Nfdm%ZS1fYT==M1Yq<jFY}Ig5yu zSx3?#SG+ZYrs9K!Z7RCy$oN16Z7T=M5VSQ&Ge*#?atKuf6i}wZSc9>^lgy8ZL6~2j z7^q6Ag}p-}G-32(<NfzV3>o`99(wr9lJrn$yvx&pqYR19j53}u=YKd{Y6ob_S{S(2 znhufED|EqLPJF_Fu+u9Phf6i$KQ(?%FggNKgd<MxwyZY9Fa?s%YG@a=FPln28m{c& z2t#H*n9xFIr(cP*u%a-!fzi>q=!`hS_$JuvEKE>;Wp|FWSKorjpkfH@H&fW6!2%M@ z!BwI?=m6Jvhk$n*8MJ@P29+-#Mi$f=CsQsH!VeqUa-y?O#dSQ0oxoZk``Mj~MSw8P zIOdgZZD09$3J{I+7h@+=ls;l7OfKK)tE~xo_#SoIj+tdMEu3U<YQmSA3fu8&@2I4f zqYyR+=UBBX!zz62u<ACPX+3lKvAKlj^N#ItBhv*!&fnkTz~y()Ul$z~>T3c|Cf_tN zNv|St^QRmG-_R9j3_a<F8{YygI*k&NIUtRawQ=7ojMSjgOxbtrKYhT>)@r|-==oi( z#~EYDbYj_C0ZK^DE2WO@X-bfd{RX<!_ZN#y^G#xRhwYY0ZYuhg%_x(JW-Xhrm&~{2 zVXypHi)e3`L;YnLPgL#PA!jB|mihE*H)NLyk&SvC`rPD*z7_<$*@AD9%A}Mlrb2Gt zas6jQG95O8;Io|Jz+q$_cw@FW4f=!lpyNuy>kB!Y?a4WZvyYMWB6tK2XB|Mx8cTCG z?HG3lnHh|fY|E*jZn=lKSkl58-kqXkxMeh-<papLQ6!m#JIx+xb_)I_m;wP%v#HZ; zAUXAlXR>!{d@#Vf(wk_Aib%)nOcer|N_lWu4zy^s{EK6WS^3vfD&v5&vPcw}9jHts z6`ypV@TtFBo?v_VW8@tOI?FupumOZ%<2JU8K_`e5NtK9|umngQEMU=3Ol|tNR0CNL zY>ta`#ucyd;p2UGFUn=98`gvz%d=KEhxd=L!bi+V=)y(AN+*<>(jj3aYA}5q8Esg@ zM{(g?uqM-r_gA=eM#>OO5+W6K08B$)o<ICr+dtaKVn3b8#HnW*3flIlL;ZLmiqelQ zRZVk)*LSDkvOGIbCB)(}Tyd$)33m<e-5AE?0RT)ib7?dH=@gm14y`341??c#lrJ0s ziG0E=JNG*<s}&yhvxDh;Q1^BXsvc~#x6d>0^~=TfcZ+jy86ULN&-4w5qVz#m#c#nP zqspq79H>7Oc5PMi{AOu0ENRe^kum<WoM_B|Bs;^%?Hh#%<epBURGgE_%4X&y_c<Tc zWzBgXwHI|N`*)#_3UMvvqpG-4y)xCS{@ctM*KfD9D5M8re<va$Ol+AB0$$Ebwn-;X z*TcS9WU|;AVMuPIGVxG_GJ-ne>GhvqMk>)sf1FFDKHO0SdA&mMYHJy(gseJ`U~)~d zl?VJTBbdG`n7r!#e!=uzN$>OH={!h6={=V8M*WH|2JEY6<vrpKK4*jaHGxQl34};8 z&G34sp^HvKYc1SQ<(u7uY$I?VIs(cMy(a&aXep!I5hT6PPdG>zf3*x*+32cjYO#~X zvXBtkxyR}RsXH|yz`IS)-i1>OtCcv#L~y5P^&%ypAVEu+FuZIkY_YOQ3S1yd9WPnO z!xpl-t`d<fx!P8%Pqkd-V)}@y9J|W)e1&_iVnyGID~+FhI-Rl33LfZ$wbh<9Bvn?t zgV&7x^UJcKbf<%1gL)xI+kdun9xdS3uT|e$0`<Ro9<GYs^IQM$%{^QH`?(kY&Rc)y zMK|2Ax96(X<otXvA6~8hgYwjL(we>_nGY_Wog5o!CSy~h%{!CXx#8B_Y%)BT%=aco zPt7IGsZsYcxajDaxn|NDp1QqxzGA+iY3fcNOO8&TqJgWEx#P`bvN@HE%_j2~Si`#G z`qaH(zAA8LlRtP}GQTPrnVvj#Vrq74GTNLRJ25ubY*D0qgWct7rGc3kK(3AXE7WvX zr^&JD7J#<KrfwG`^nUx?aVoE1NZ)1E3pz&cfuA49dUemNQ$uKHpAGj=J$rZ`SGCH> z^obM0RGJSO!?Ux^*4)_iRB~jpd5rnFBx#M^ethmoa%^~PvN>8y7~-+HtCRU^%{}3A ziE54RYOefJRc{&@o|>ATONK{AnzPVA@|@$X=J058%k;?bWb*8}>DDlmaC&(1R5KYp z<<Q(phL35oMw=l1)QG?p6!Ve(ZreWF8reSAymM|_Yc|;4IyE~tvVHgd;hj4Vj5LRv z$3}M_*uQ6F&(MJbLq~V)-mzov(Y>Sl_v{@$HpKnylVe9)!>u#hN2Xg%YskoU`|NaU zZu{)<;Z}2W`|OF~$w}R?_MZ#BX1C8BpFS}>yM3E_J2kU?ZhCrh<oNK|)a-U>=;&l~ zaCo>ia(wUZ!Qq)1evJ;d?iia=X9l0X1+;3P7lKn$uDR_ePmPUC<S=}fV`Lc9Bi8Y; z6Hf?PvvXht_-D^B-ZN7p+fSSV|J$dgZ=18poP8oQv8eIZ9e_RYJ!*ZyJfCQu7&+eh z|7&9BTH9xa=b+@NCxW2%5EiueeIw0mO5E&jXF7%_pU4QZ_C!Bp)7zV^)^uz89mBI@ z--||PhNs{a#PbtrEXOh6jZNQXLc!YmzTvpdobArOOFqwmM_O(_F+KW35Rpw4czA*> zwFlT5zT>vjO{B*61~gBVV^3(DInFvJe0!@o)4FYJ8d!xwr)ZGfhR>y}h0A3vm~u<d z1}$f%pk1y>#^&@0{V+D#Oy(=eW*He6D0SHo&YHM#u?g`6+Y7Il53dNqf%$MCxClu% z-8z%lEDW2%r|0<_&19L)l>O|)*vyP9o1?=c6LYO$47eFo@SJYVO((;rr^iN<nd!ME zx6szD=E$kmEZVph>^U|(r#q*3a-x|SOJD7qRn@VnF{)tc8GCMZ1sMO<WcVnml@4ml z!PwUgW6<L`yg6$2WOQ|Hepk)WfNOk$CwCmjcmeil85x4!>TJt9+g1za=fhf1&1zmT zA77Ddp6|P|-G;1HwN9~1GaxZZni`HZm-kPJk>Q!)k+Hcm3DnX$HaUIA^c4M1p1OT( zDw#Vo!!`B`cGnE|r{?IDW@IQN&CxMzo@A=oltrA3PLG^AL5YC@9&ekQJw1+%kjysc za+7s<baW7#_Sjf!cJ5izcO0I*3G=DhN=HW%e$wUai%x|<;D5mr{+FNCu;j&pt>bY) z|2p~{mX&@2RcnOX8W#JVzz`E=9r3rCbEjHUqM<2Ii+3Eyk<h&0S~OdeXH*r=Y!07D zMxlDx`j}{HY8dh!nWmjFU;Wfn^Uj&($Q+G5<GHsOv7X@JlTS^l?C5pWY+fzSNbVTJ zRXFmj=RW&6w_Ts!aPxDXmEQ2QYj+&M8#+DKnw}DLSiY?>BMhi)bZmBJau}v<^J5Lw zi&;)9&Dm3vb5~z|YU+;G@XY2bk0jXe4CctwTCF3_etywc|K_&OMqmByfBeAA<X472 zu<3ng*8k*3zw*tyZhgV?N^jr%!V^ofth2TfnOt-uzvGS;tUGmk2dDZn8L3mwOnB9q z?H-*s_PwAu-JE1FBN(%-W@0wu@aXB`sS%-iY&tQ;H=7)7o|(ouVJgO2-oI)WJ3Ret zbAx=LF}zCWf(6%4-}&UxGgC=Ab>=!R4n~Gg%>vxi^wc0|!bMU?izI<_bi~yfX%pEo z%{(7Hb?g|H{v3j^js2@@ZZe-e4H1ksp@B)5mcdNpO+o-$m!IZirzR(Hs=()Pf`~Xe zJxd>%l-*VnrO6Dstz|;<RIAwr>-OeUvnAPiYHIAH<d5^14nOtk<R}P0@E}I+JkDSN z;kk3i_Co^GEnKLv(c4bV+%`AuS_V(gK>7&w)SNFX=I8SGu+v;en{YwL<ZEKV+CW&T zZt2~!Wy`uPTS`&9Vat{^JzLfmwrsg#<EkxNR$Q<-*|Md-wxzgv%a(vrmv7m!dQ-WN zt4(~igmwL0dFhr5FJ6Dyvrk#)!C~n5c2tJB+p|puHZ{@=o{C?E$TWxpN8_eZrHY%g zVqb)2@C-SS$=uwT;F-<Vi7`Yx#6Q}c8f%UQw<2jBk26;iyKZWzlgyqPIi467GYz8< z;RvGlO#Z~Hi{Oy>ym{x??A&bdJaa_x?~=*k7LKVVa1>Q%LfvW_`ptJX7)}%Q2yS&@ z3a4{5jOkM?Ch6#@*)zf6DI9>-sTs)&4B)xui9|fzF2$W0yx`Ut%yMdK_%uXjgFZZE zQ;ITZP6Ri!re|jdN1N&{nH-y%p#Jda;Pe!gY=o}q;HlXt4U4o4&Tg2Vnqug}nQ%JC z6o98@s{?gX;-d;dbQeA*ehO}yMkQg+oSK3<FmyB{s2|#riIY$!xV|+#0U^zd%`}6< z+i&&rnN5KQTfE?^<x}9E@Kf{Ko}t608ndAU&%xPuqcn^@?at<i7x=!T3{^~-Y11I} z7oZB*Z91o?ZV#S)=ETuy<cVPrvY(!@$u}9F7kUnpPq+ltKEsfNTpOlGFk>=&6Pp-m zjm<z%z?#q5^+YxnJl0r8Fi$X~oOu$cP=<&@qP?8lg1m1{@vN$vknQLpC$3TEUk{Tz zrccrM^a!?D@NCo_Itz3{?2^|uS<h@iH`AwxY~6um0nc4*gUlXjqGN(Xr=}d0sD7?L zc>owEm}~`VFd`W_GlRg!%n1&|g>z%a#%Q*M#d(_2w*Yz~`C_tVwh0v6eP(NVZkm9c zpE2RUx9MkpRvKt7cq%cUu_?#U9!a!pDr7Wxx`(2Tu)NtjP^cKKGQGsJzP0?ZhSZ*> zuG_PxX5c^A(*SAz%1Hd!@CmV|TF4(73;7Y{ywKgpGn#XEOt&V)IHTY_$bNTn(Ndf_ z<?3}ZqFJME9-Nv=oDygc=-Gxm^@XV-W~a2jC#c#t+tPA)QnLPxIC!QteY?21Lm+hC zg{UA>BkSXY5#jzHJ~fR<Y~DG76-!S~$1)i^K0SkhEMcb3Z@@l2IyQ+VVcau|b{!iT zgI3IHI~6@6_;mRB!fzK=hd)#NyXfM=SAy5YVZ6Wi>)}1&t?~B4hTv1hUkWaWH^dKz z|0}Kpzg7&w(dZw;W^s3PU-a>4FutYmSH)k9zE)UQtQ6lH-CVpVx-)oI^yK&-<1Yk% z5dV1LqrtDnXNsGmsqjQRP<UtIslhkGcLy(zj|6`mmJ6>fytr^x@ci&V`18TT#a{?- zhz^BcEiSU^zdwehDdG-8x5JWMd>KV{&;4Eec)2@|uY9;%^&mLUqvb1oCr_eMp^V?M zDh$HQqn%M4?yIaVl<GnF<fz$qO|&Cg5w45EuwEP}{#{rBT^EOxj|)+i>p~?ga4=DH zDfLPP_bdwej~YdO$1#%}hUJyDIIM<osJbd&<2sJQ6@{p$T;@7!SI^XLT&4rAFQ{=s zIgJ!3%=%+YgB%=J)Eyp#eHRsbseMV<h}3$a9Pt;ZqFVW4yI)g#?nzN~=(k5TcJV0{ z8qpfQFNjwITNsroP@<PgsTRdOR~OblNqrW>)%?9AD)P4+lKY$76mBY95pD`sguPGt zkP1WrEenT*=t@5Q`L7kwbs@TewWKwr=S8I`h+j~?xVpN!%3z3kUL4g*wbGi>3(8j& z57jOUdkQ^;Fy0as>st$JO7-}<C=J&~b;|Tb+oGOTPY>T#d2+lWzOHyxRIglB3~2Lm zJ{9`bzssUMK-CwO!V8&?YS?63<=U_}9_Z^|abF?0xPDO(1;s&wd4D<TTU{+Prq2If zHn1jqLC>exJTrV=bbIlt@WtWtszYHA@2du@-e394RZos<rTvvdoD?`$-w;0|tQY#j zebv6G7TvyjU9AKxC2RA^g_kk5py!gpjzSQPUtaDh7T16((9C~9vZb^uyf7>jis7q3 z^6$k~lgR~^T9M7pYj_HIM12D<DaM7t`1y<?+`jFe_3@4s>k6By8;VyIH-t|r^lf<y z{cI|1Dr~OoW_QL4qp!OAj(7J2)r)JD)unJtSbbWFp0+|mVRT7&Q>8jY51e_iwz&54 z{e@N0PJUIOhV@ZgsFkbbgUJ<zp6cFseYCc?rd;SLKlAXuUA?=?+bXrv<?;GT^oh%8 zHIAZ2sZ#CVR@rj#n#<R%x}^FHpnGa9j4mzLN_)Z!DpypGgsbWogxjw(3IVcu>6&7# z#5u~Im!Na#3Yr&17t7FLv0f9R!plSVUErHs^7StYiyLmL)ZVr?x_sbi^;)U-!mFW; zRqNJ9D+-tV_LE1S-BSU*6{>E43wo-xLlx+zzV@cdvwIJhYtO4Zf1s~cs)mDMUu`{U z1Q&;WhsBRuV|7<$WF@|z#HR%GTMM<XURqumUKy`{Zn$D+Z*XinEY?35jPwSzpr|80 zgYsozt?<KPt+XL3)ehBaVE>}xg~8PydSUU>=+dn>7Y>wH9xT?&)!@oxcs3~5>;x;q z^^CQzPzi#GVsBh6*P=@BtvD!MvSQ_mZP6vwxhH?Mc185?MTdTDRnMxPAE{pYs^^9` zN~TES@SYKIVhU&Re*17;SP)%7InXwlBE@h;a9OGJr{!=ZJPB@#*Y$@pgE2J9F%y@q zyt)_$TRweNA)LP~ytooQQl>tX7XD$>xbl{Q$~^U!YacFzYq_icYdq&)$$in0zptD6 zr&1bM!3xyEmB^W5xN>(CY%PrTtU!9M2%l7Y_x|<0TMJcquJpzrUK#hse^G+hSM(J2 zgZ0go4V=pw^;NG3Dt%Ee=R6%im^8RoE59bZJz%xm#%5w2%m=|tP!8Z@dZ+)J>O&Vb z!e$xnV-<d5xMA%J>sME+;p&3P+~QU7HPtPv!(<;73Kb*_2wxvwt?J0{D#W)2%t}-_ ziM;_Hsp~hQtq`t?E=DR+nzliBT#GhvR&B^MRKv}ngH~h7)^b$0Rweo+pY?lPbinZA zdRZUd1d?bxtb{*|yzQwR4Wnwkd>A36fBX8t9KtW$R1J?6KsnQWX;g?C?9`1AYAX;# zL17&%xgk18suhG@hnQ*aR5TEtmP$fd{7(P?T5%h^fGiJ(Bs&X3wL*M3d*hNqRNBuY zsulOcgRhTyzavVj1T%l=U=aR6e-Qt4cq9o*V-Xa~@mJ48z%!umQk1~!qBR%vhL=|^ zts_k0ArJ(RD1#foJuJt_wxe7m7mAF03ncpYLLAC9Xfzs3WBB>-KNbV1s94w>7s6kr zj}$@v^wOdFj>1dAy(_L{thIOt?Uuu9<4wh|avfrPHyWyf&buv+P*PF&<}j{YWY~2x z(TCKyAp}Vxh*;+)_eIaGXpol(*X};l^0z>YAdJ@PDTT)kOHKWSkAi9#{R=Ez2=Ax= zLYUMxmyAXTK5SzWP<OQW$7PTeKI>xRujt20++sGMLv;rm)}!6UVtff>F0CLVEe?v; zl!Ndo2#UJM7!D7Laj8;?${Px=<&_JCot3aJyjYx@bM0t&jyl&Cg2K0>e^T?{J}93k z2S?c5@Gha#`2D%Xptz>EqapPELwGK{wHg!x<cf-vE)M6z9Ta<}3ac1$HVFSM4n#=} zD;RD#5)AFwxoh{Hz5Dhb7(O~O+B}xLB-xr=og^=*ZLMwIT>J4Ww<bTHh5v4yu~qN| z;lJdFB0N6)ckQ+P*AfFAIo_Nd8XDR$v~y_J(C(o<Lwkqz4ecK~uw!V)jvYI9>|#xP z&yKx2_U+ie<G{|LojZ2!+_`J#?wxyf?%la>=l-1sb`9;?v1{k9UAuPg+Ouo#u6?`q z?>ex1X!nlYJ9qEey?ghb-FtWM+r5AHfjvWecI?@?XV;$Hd-m+vyJz2?{d*4V9ooBN z@6Nru_U_)hXYby<`}XeNdtl$tz8(8^?%TC*_r5**_U_xaZ~wjn`-k@L*uQiCuKm0B z@7ce1|GxeE_p@Ao0DunA*#R0lK%oN&qaW-)lY39-kC=1$e~%T{4uU)5AQ&npL0|bu zc=&=Oe)GlWHeY<%xxp=)&h4AK>d3YKy!DZ5fBLP-bz9$d?x}D6v*c-0Uq5&AC%$#$ zmVM<1p7F+wpL^l`+lOEAyxr+5zj4C@ue|Zl=Uy2M_rEGQp1$h9_}#BMc;bOqefqw; zfAY(}{<-@$zW45*{^oBC|4i`E13w#l^aDTp;K#zBd)udnf9|7yHvESB{^Ef*1poVU zZ@B;ScfauyU--bAPk!m{w*+5Kf9cP^n!fY-Umt#V5Pbf<kG$=k2RFR`mmYlg$3Fky z=l*u!eJ}p|qwfp8_Dk>E@b!n^7X*LxzMC(MfAhz7Z~V<~KRUwawZDl#4PMTMv!5#- z34Zj#bHN3dB$xFhM=tw^tw*lfGW5t*({DSsHF`%fxZxYgw&2@G_P*uOb9=uP_U{X8 z!}~V%_V0gZ-vbA>ukOG4pEmU0d~o*z&)zWnx#xU&eE7MyPJiyXZ+>X_dBMkrf28@z z2Yw{@i~i>ak38^#cYnVBg<tx@=U()QFAo1`68!y(zZ~9m+pWN`u5={0ZRNS(!Al+q z>PZrg?l>3HUv%!F2cp?eK0t}TkAudxLeRLa5R`)8{>qV{Z}pL|o;(sh`t2n8-9~>r zwEKbh+(Z3^*-s85Z{J-w8$4PdDApRpuRhHJ;3)q0i5UWXvqa#IWkHtFX*^@+oVOI2 zB*c)%ToU4qc-WK4bc?l#ZQBz54c*z67wPpkJoRaZjtx%|&1;=%KC5}`hUVlXi+#?e zXN8RwBfRhcspmdrQE~d=kC4#y>iX-7jo>7|kyXK^eE(^|{r)FZ{3?Dk&fuF-BX~LA z`}wTp^RJag(8KqSQ)gqx?+JeQcl<uV?=AdRhnx9GChGHV)UWaVX+BTt_&vb2`plmH z1lJq*e52F|CizBk2Rr%BV4Nrf!6trJzmK1vVeGxMzlP5xe6ru`xfZ@I;G=(lVxJ57 z))<5z-Mfg-#e7uv!Ez%w%=b$e$4b6C%dO_O+EYKB_l56ueAJ)*W&H>*U+ZZYepOzR zd+!yE;KktT7OpP__uKfsj86tj!nNi{c-BA7h1$~`ZsMc4xSY=wd>VZ8O!IanAI;-c ze75o-S<~9S99sENt~Ey8|Kqq3yohh%Ky&g=V7Zm=Ex;lizKMDN4}1$I{m!0m=6S+z zzVd&%-p*%;58L={-goi6o6ib9d-!DI+RODmKBCV9d~gc%dYs_Nd_LaSF#HMs`X@ZS zW3xur9{;smSAq38d<$N+m;HV^*Mjv{V84ZL;YYvqFT>w;?q@zXPg(H$Aiq23A?0_5 zJJl7gGTQiI7*2C>{SWZN`6rJSs*T_VV0bE@r}5FhL+&g8d!xU)DZlfbU3?pUcqUau z--sLIjciVTh~GE!5ze!Jq6y8%S?ElC{w$N(=O1SEG>6aS^E^H`BHJ{dcY{;WX!iTp z7yN$Dg5ReBMs%N**KhIpao`n9e+?kwxqG31!GDgA<}mwxFW2JFAK~+SJ}=<2>8eKX zCS=KNTt6>%<9&O7Bls0Q@8lD_sEUdhJvG9@W7xx`vKN}8gGbNAy{sJ%=J^BVp=~>N z@7cDap}R*}*kp-tP}xGIp>2EiZQEg`v-=~uxog{w@Uu}cJS%w|gqIbMM0ed494X%Q zvb*#h+us6z`4Qd~#C@V~8~`~w`d18R!rtEU4SnGd_jd$gxHE{tUHA{X@lEyw#c*#> z3in|*?++^BfuI`g7z%39jvWZ>9Xo@b$|jT<QT5p{sQu@#w;m(``!OT|>;5|IxQ=wa z(#nwXtnDuG$}p(+`a5Z1C0i`=Uq$`$t&~)AMP4&4uZ;6&6+Nr+EUm6A_^VoeRnM<_ zbQNr0ZZ5*bbFpXVwxRG3!^e%@=sA6X5GhRZNBRt0;vMWpHi1q4NdACNd<01}FiQ9e zl<Mye`@56BZLDf`suzaJ=<a3S;zF@hu2gIFp6_@Zcw0bHdQk-eEu0=}-obZ8-<m$I zYq}m}6=#ZHacyksH0gkHMgX;F>$Y5!Sz%C0F4R+8qG*Mn>lGy;B^vV(2M7_SdLb(g zwoE!W+M1sE(RRDÝ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 literal 0 HcmV?d00001 diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources new file mode 100644 index 00000000..d1b11a17 --- /dev/null +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -0,0 +1 @@ +b96941af697ce7a6c1c8ef1539d34aa7c23d2d41dd1b2ccffdfda5648f90cf56 diff --git a/plugins/official/csv-editor/plugin.toml b/plugins/official/csv-editor/plugin.toml new file mode 100644 index 00000000..06d920a5 --- /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 00000000..9df4a56a --- /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 00000000..38cad0ee --- /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>, Error> { + Err(Error::NotFound(format!("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/excalidraw/.gitignore b/plugins/official/excalidraw/.gitignore new file mode 100644 index 00000000..2aa0170a --- /dev/null +++ b/plugins/official/excalidraw/.gitignore @@ -0,0 +1,4 @@ +web-src/node_modules/ +# Built by plugins/build.sh, not checked in. +web/ +web.sources diff --git a/plugins/official/excalidraw/NOTICE.md b/plugins/official/excalidraw/NOTICE.md new file mode 100644 index 00000000..1df5d881 --- /dev/null +++ b/plugins/official/excalidraw/NOTICE.md @@ -0,0 +1,28 @@ +# Third-party software + +The page in `web/` is built from `web-src/` and bundles: + +- **Excalidraw** (`@excalidraw/excalidraw` 0.18.1), MIT License, Copyright (c) 2020 Excalidraw — + https://github.com/excalidraw/excalidraw — with the libraries it loads on demand (among them + Mermaid, KaTeX and Cytoscape.js, MIT License). +- **React** and **React DOM**, MIT License, Copyright (c) Meta Platforms, Inc. and affiliates. +- The fonts Excalidraw ships in `web/fonts/` (Excalifont, Virgil, Xiaolai, Nunito, Lilita One, + Comic Shanns, Cascadia Code, Liberation Sans, Assistant), each under its own licence (SIL Open + Font License 1.1, or MIT for Comic Shanns); see the Excalidraw repository for their texts. + +The MIT License: + +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software +> and associated documentation files (the "Software"), to deal in the Software without +> restriction, including without limitation the rights to use, copy, modify, merge, publish, +> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or +> substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/plugins/official/excalidraw/icons/excalidraw.svg b/plugins/official/excalidraw/icons/excalidraw.svg new file mode 100644 index 00000000..71d1b0eb --- /dev/null +++ b/plugins/official/excalidraw/icons/excalidraw.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#F1EFFF" stroke="#6E5BD8"/> + <path d="M4 10.5c1.2-2.6 2.4-3.9 3.6-3.9 1.5 0 1.1 2.4 2.6 2.4.9 0 1.5-.8 1.8-1.6" stroke="#6E5BD8" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> + <circle cx="4" cy="10.5" r="1" fill="#6E5BD8"/> +</svg> diff --git a/plugins/official/excalidraw/icons/excalidraw_dark.svg b/plugins/official/excalidraw/icons/excalidraw_dark.svg new file mode 100644 index 00000000..2e71d97b --- /dev/null +++ b/plugins/official/excalidraw/icons/excalidraw_dark.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#2B2747" stroke="#A99CFF"/> + <path d="M4 10.5c1.2-2.6 2.4-3.9 3.6-3.9 1.5 0 1.1 2.4 2.6 2.4.9 0 1.5-.8 1.8-1.6" stroke="#A99CFF" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> + <circle cx="4" cy="10.5" r="1" fill="#A99CFF"/> +</svg> diff --git a/plugins/official/excalidraw/languages/excalidraw.toml b/plugins/official/excalidraw/languages/excalidraw.toml new file mode 100644 index 00000000..cc7fb623 --- /dev/null +++ b/plugins/official/excalidraw/languages/excalidraw.toml @@ -0,0 +1,6 @@ +id = "excalidraw" +name = "Excalidraw" +extensions = ["excalidraw"] +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "JSON" +tree-sitter = "json" diff --git a/plugins/official/excalidraw/plugin.toml b/plugins/official/excalidraw/plugin.toml new file mode 100644 index 00000000..70bf7ad0 --- /dev/null +++ b/plugins/official/excalidraw/plugin.toml @@ -0,0 +1,35 @@ +id = "openidea.excalidraw" +name = "Excalidraw" +version = "0.1.0" +description = "Draw and edit Excalidraw diagrams (.excalidraw). The drawing is the file's text, so the text editor follows, Undo and Save are shared, and the IDE theme applies." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: the Excalidraw canvas next to (or instead of) the file's text, kept in step both ways; export to PNG and SVG through the save dialog; a file icon and a New › Excalidraw Drawing template. Based on the Excalidraw editor proposed by Deng Liu (#738)." +engines.openidea = "^1.0" + +[capabilities] +# Copy and paste through Excalidraw's menus (the keyboard works without it). +optional = ["clipboard"] + +[[contributes.customEditors]] +id = "canvas" +title = "Drawing" +patterns = ["*.excalidraw", "*.excalidraw.json"] +priority = "default" +layout = "preview" +web = "web/index.html" + +[[contributes.languages]] +path = "languages/excalidraw.toml" + +[[contributes.fileIcons]] +id = "excalidraw" +patterns = ["*.excalidraw", "*.excalidraw.json"] +icon = "icons/excalidraw.svg" +iconDark = "icons/excalidraw_dark.svg" + +[[contributes.fileTemplates]] +id = "drawing" +name = "Excalidraw Drawing" +extension = "excalidraw" +path = "templates/drawing.excalidraw" diff --git a/plugins/official/excalidraw/templates/drawing.excalidraw b/plugins/official/excalidraw/templates/drawing.excalidraw new file mode 100644 index 00000000..1d9b5f0a --- /dev/null +++ b/plugins/official/excalidraw/templates/drawing.excalidraw @@ -0,0 +1,13 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://openidea.dev", + "elements": [], + "appState": { + "gridSize": 20, + "gridStep": 5, + "gridModeEnabled": false, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} diff --git a/plugins/official/excalidraw/web-src/index.html b/plugins/official/excalidraw/web-src/index.html new file mode 100644 index 00000000..9101cf84 --- /dev/null +++ b/plugins/official/excalidraw/web-src/index.html @@ -0,0 +1,32 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Excalidraw + + + +

+ + + diff --git a/plugins/official/excalidraw/web-src/package.json b/plugins/official/excalidraw/web-src/package.json new file mode 100644 index 00000000..fab85689 --- /dev/null +++ b/plugins/official/excalidraw/web-src/package.json @@ -0,0 +1,26 @@ +{ + "name": "openidea-plugin-excalidraw-web", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "The Excalidraw plugin's web page; `pnpm build` writes it to ../web.", + "scripts": { + "build": "tsc --noEmit && vite build", + "test": "vitest run" + }, + "dependencies": { + "@excalidraw/excalidraw": "0.18.1", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^26.6.6", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^6.0.2", + "jsdom": "^30.1.3", + "typescript": "~6.0.3", + "vite": "^8.0.16", + "vitest": "^5.0.5" + } +} diff --git a/plugins/official/excalidraw/web-src/pnpm-lock.yaml b/plugins/official/excalidraw/web-src/pnpm-lock.yaml new file mode 100644 index 00000000..0fe21cc4 --- /dev/null +++ b/plugins/official/excalidraw/web-src/pnpm-lock.yaml @@ -0,0 +1,3208 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@excalidraw/excalidraw': + specifier: 0.18.1 + version: 0.18.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: + specifier: ^19.1.0 + version: 19.3.1 + react-dom: + specifier: ^19.1.0 + version: 19.3.1(react@19.3.1) + devDependencies: + '@types/node': + specifier: ^26.6.6 + version: 26.6.6 + '@types/react': + specifier: ^19.1.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.1.2(vite@8.3.5(@types/node@26.6.6)) + jsdom: + specifier: ^30.1.3 + version: 30.1.3 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.16 + version: 8.3.5(@types/node@26.6.6) + vitest: + specifier: ^5.0.5 + version: 5.0.5(@types/node@26.6.6)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)) + +packages: + + '@antfu/install-pkg@2.1.0': + resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} + + '@asamuzakjp/css-color@7.1.9': + resolution: {integrity: sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@10.0.5': + resolution: {integrity: sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@6.0.2': + resolution: {integrity: sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + + '@chevrotain/cst-dts-gen@11.0.3': + resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} + + '@chevrotain/gast@11.0.3': + resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} + + '@chevrotain/regexp-to-ast@11.0.3': + resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} + + '@chevrotain/types@11.0.3': + resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@chevrotain/utils@11.0.3': + resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.7': + resolution: {integrity: sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.16': + resolution: {integrity: sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + + '@excalidraw/excalidraw@0.18.1': + resolution: {integrity: sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==} + peerDependencies: + react: ^17.0.2 || ^18.2.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.2.0 || ^19.0.0 + + '@excalidraw/laser-pointer@1.3.1': + resolution: {integrity: sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==} + + '@excalidraw/markdown-to-text@0.1.2': + resolution: {integrity: sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==} + + '@excalidraw/mermaid-to-excalidraw@2.2.2': + resolution: {integrity: sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==} + + '@excalidraw/random-username@1.1.0': + resolution: {integrity: sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==} + engines: {node: '>=10'} + + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mermaid-js/parser@0.6.3': + resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + + '@mermaid-js/parser@1.2.1': + resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + + '@radix-ui/primitive@1.0.0': + resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==} + + '@radix-ui/primitive@1.1.1': + resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + + '@radix-ui/react-arrow@1.1.2': + resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.0.1': + resolution: {integrity: sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-compose-refs@1.0.0': + resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-compose-refs@1.1.1': + resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.0.0': + resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.0.0': + resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-dismissable-layer@1.1.5': + resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.1': + resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.2': + resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.0.0': + resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-id@1.1.0': + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-popover@1.1.6': + resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.2': + resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.4': + resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.0.0': + resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-presence@1.1.2': + resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@1.0.1': + resolution: {integrity: sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-primitive@2.0.2': + resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.0.2': + resolution: {integrity: sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-slot@1.0.1': + resolution: {integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-slot@1.1.2': + resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.0.2': + resolution: {integrity: sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-callback-ref@1.0.0': + resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-callback-ref@1.1.0': + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.0.0': + resolution: {integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-controllable-state@1.1.0': + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.0': + resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.0.0': + resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-layout-effect@1.1.0': + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.0': + resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.0': + resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.0': + resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + + '@rolldown/binding-android-arm-eabi@1.2.16': + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.16': + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.16': + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.16': + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.16': + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.16': + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.16': + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.16': + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.16': + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.16': + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-fs-access@0.29.1: + resolution: {integrity: sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==} + + canvas-roundrect-polyfill@0.0.1: + resolution: {integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.0.3: + resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + clsx@1.1.1: + resolution: {integrity: sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==} + engines: {node: '>=6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + crc-32@0.3.0: + resolution: {integrity: sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==} + engines: {node: '>=0.8'} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.4: + resolution: {integrity: sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dompurify@3.4.17: + resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==} + + entities@9.0.0: + resolution: {integrity: sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-toolkit@1.53.1: + resolution: {integrity: sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==} + + es6-promise-pool@2.5.0: + resolution: {integrity: sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==} + engines: {node: '>=0.10.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fastdom@1.0.12: + resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fuzzy@0.1.3: + resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} + engines: {node: '>= 0.6.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glur@1.1.2: + resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + image-blob-reduce@3.0.1: + resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==} + + immutable@4.3.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jotai-scope@0.7.2: + resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==} + peerDependencies: + jotai: '>=2.9.2' + react: '>=17.0.0' + + jotai@2.11.0: + resolution: {integrity: sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + + jsdom@30.1.3: + resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + langium@3.3.1: + resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} + engines: {node: '>=16.0.0'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + magic-string@1.4.5: + resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mermaid@11.17.2: + resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} + + multimath@2.0.0: + resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@3.3.3: + resolution: {integrity: sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@4.0.2: + resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} + engines: {node: ^14 || ^16 || >=18} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + open-color@1.9.1: + resolution: {integrity: sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==} + + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + + pako@2.0.3: + resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==} + + parse5@8.1.0: + resolution: {integrity: sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + perfect-freehand@1.2.0: + resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==} + + pica@7.1.1: + resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + png-chunk-text@1.0.0: + resolution: {integrity: sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==} + + png-chunks-encode@1.0.0: + resolution: {integrity: sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==} + + png-chunks-extract@1.0.0: + resolution: {integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-curve@1.0.1: + resolution: {integrity: sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pwacompat@2.0.17: + resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==} + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rolldown@1.2.16: + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + roughjs@4.6.4: + resolution: {integrity: sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==} + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.51.0: + resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} + engines: {node: '>=12.0.0'} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + sliced@1.0.1: + resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} + deprecated: Unsupported + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + strictdom@1.0.1: + resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + tinybench@6.2.3: + resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tldts-core@7.4.20: + resolution: {integrity: sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==} + + tldts@7.4.19: + resolution: {integrity: sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + tslib@2.9.1: + resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} + + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@8.12.3: + resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==} + engines: {node: '>=22.19.0'} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + uuid@14.0.3: + resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==} + hasBin: true + + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.16: + resolution: {integrity: sha512-qeNEuOi9a3qkqRf7Gfez2WL2VwlBTLCNekHBGO9kxPVUp9XuY15yfTJ9jwvOwKI0I2MhUUfZq95NSp+LK1CLQg==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.0.8: + resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + webworkify@1.5.0: + resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@17.3.0: + resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} + engines: {node: ^22.14.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@antfu/install-pkg@2.1.0': + dependencies: + package-manager-detector: 1.9.0 + tinyexec: 1.3.1 + + '@asamuzakjp/css-color@7.1.9': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.7(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@10.0.5': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + + '@babel/runtime@7.29.10': {} + + '@braintree/sanitize-url@6.0.2': {} + + '@braintree/sanitize-url@7.1.2': {} + + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + + '@chevrotain/cst-dts-gen@11.0.3': + dependencies: + '@chevrotain/gast': 11.0.3 + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/gast@11.0.3': + dependencies: + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/regexp-to-ast@11.0.3': {} + + '@chevrotain/types@11.0.3': {} + + '@chevrotain/types@11.1.2': {} + + '@chevrotain/utils@11.0.3': {} + + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.7(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.16(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + + '@excalidraw/excalidraw@0.18.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@braintree/sanitize-url': 6.0.2 + '@excalidraw/laser-pointer': 1.3.1 + '@excalidraw/mermaid-to-excalidraw': 2.2.2 + '@excalidraw/random-username': 1.1.0 + '@radix-ui/react-popover': 1.1.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tabs': 1.0.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + browser-fs-access: 0.29.1 + canvas-roundrect-polyfill: 0.0.1 + clsx: 1.1.1 + cross-env: 7.0.3 + es6-promise-pool: 2.5.0 + fractional-indexing: 3.2.0 + fuzzy: 0.1.3 + image-blob-reduce: 3.0.1 + jotai: 2.11.0(@types/react@19.3.0)(react@19.3.1) + jotai-scope: 0.7.2(jotai@2.11.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1) + lodash.debounce: 4.0.8 + lodash.throttle: 4.1.1 + nanoid: 3.3.3 + open-color: 1.9.1 + pako: 2.0.3 + perfect-freehand: 1.2.0 + pica: 7.1.1 + png-chunk-text: 1.0.0 + png-chunks-encode: 1.0.0 + png-chunks-extract: 1.0.0 + points-on-curve: 1.0.1 + pwacompat: 2.0.17 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + roughjs: 4.6.4 + sass: 1.51.0 + tunnel-rat: 0.1.2(@types/react@19.3.0)(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - immer + + '@excalidraw/laser-pointer@1.3.1': {} + + '@excalidraw/markdown-to-text@0.1.2': {} + + '@excalidraw/mermaid-to-excalidraw@2.2.2': + dependencies: + '@excalidraw/markdown-to-text': 0.1.2 + '@mermaid-js/parser': 0.6.3 + mermaid: 11.17.2 + nanoid: 4.0.2 + + '@excalidraw/random-username@1.1.0': {} + + '@exodus/bytes@1.16.0': {} + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@floating-ui/utils@0.2.12': {} + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.7': + dependencies: + '@antfu/install-pkg': 2.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@mermaid-js/parser@0.6.3': + dependencies: + langium: 3.3.1 + + '@mermaid-js/parser@1.2.1': + dependencies: + '@chevrotain/types': 11.1.2 + + '@oxc-project/types@0.156.0': {} + + '@radix-ui/primitive@1.0.0': + dependencies: + '@babel/runtime': 7.29.10 + + '@radix-ui/primitive@1.1.1': {} + + '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collection@1.0.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@radix-ui/react-compose-refs': 1.0.0(react@19.3.1) + '@radix-ui/react-context': 1.0.0(react@19.3.1) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.0.1(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@radix-ui/react-compose-refs@1.0.0(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + react: 19.3.1 + + '@radix-ui/react-compose-refs@1.1.1(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-context@1.0.0(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + react: 19.3.1 + + '@radix-ui/react-context@1.1.1(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-direction@1.0.0(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + react: 19.3.1 + + '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-focus-guards@1.1.1(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-focus-scope@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-id@1.0.0(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.3.1) + react: 19.3.1 + + '@radix-ui/react-id@1.1.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-popover@1.1.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.1.2(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popper@1.2.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-rect': 1.1.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/rect': 1.1.0 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-portal@1.1.4(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-presence@1.0.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@radix-ui/react-compose-refs': 1.0.0(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@radix-ui/react-presence@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-primitive@1.0.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@radix-ui/react-slot': 1.0.1(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@radix-ui/react-primitive@2.0.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.2(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-roving-focus@1.0.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-collection': 1.0.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.0.0(react@19.3.1) + '@radix-ui/react-context': 1.0.0(react@19.3.1) + '@radix-ui/react-direction': 1.0.0(react@19.3.1) + '@radix-ui/react-id': 1.0.0(react@19.3.1) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.0.0(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@radix-ui/react-slot@1.0.1(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@radix-ui/react-compose-refs': 1.0.0(react@19.3.1) + react: 19.3.1 + + '@radix-ui/react-slot@1.1.2(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-tabs@1.0.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-context': 1.0.0(react@19.3.1) + '@radix-ui/react-direction': 1.0.0(react@19.3.1) + '@radix-ui/react-id': 1.0.0(react@19.3.1) + '@radix-ui/react-presence': 1.0.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.0.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.0.0(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@radix-ui/react-use-callback-ref@1.0.0(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + react: 19.3.1 + + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-controllable-state@1.0.0(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.3.1) + react: 19.3.1 + + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-layout-effect@1.0.0(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + react: 19.3.1 + + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-rect@1.1.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/rect': 1.1.0 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-size@1.1.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/rect@1.1.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.16': + optional: true + + '@rolldown/binding-android-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-x64@1.2.16': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.16': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.16': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.16': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.16': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.16': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.12': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-zoom@3.0.9': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.12 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.9 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.10': {} + + '@types/geojson@7946.0.16': {} + + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@vitejs/plugin-react@6.1.2(vite@8.3.5(@types/node@26.6.6))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.5(@types/node@26.6.6) + + '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@26.6.6))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.5 + optionalDependencies: + vite: 8.3.5(@types/node@26.6.6) + + '@vitest/spy@5.0.5': {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + aria-hidden@1.2.6: + dependencies: + tslib: 2.9.1 + + assertion-error@2.0.1: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-fs-access@0.29.1: {} + + canvas-roundrect-polyfill@0.0.1: {} + + chai@6.3.0: {} + + chevrotain-allstar@0.3.1(chevrotain@11.0.3): + dependencies: + chevrotain: 11.0.3 + lodash-es: 4.18.1 + + chevrotain@11.0.3: + dependencies: + '@chevrotain/cst-dts-gen': 11.0.3 + '@chevrotain/gast': 11.0.3 + '@chevrotain/regexp-to-ast': 11.0.3 + '@chevrotain/types': 11.0.3 + '@chevrotain/utils': 11.0.3 + lodash-es: 4.17.21 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + clsx@1.1.1: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + crc-32@0.3.0: {} + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.4): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.4 + + cytoscape-fcose@2.2.0(cytoscape@3.34.4): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.4 + + cytoscape@3.34.4: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + data-urls@8.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0 + transitivePeerDependencies: + - '@noble/hashes' + + dayjs@1.11.23: {} + + decimal.js@10.6.0: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + dompurify@3.4.17: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + entities@9.0.0: {} + + es-module-lexer@2.3.2: {} + + es-toolkit@1.53.1: {} + + es6-promise-pool@2.5.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + expect-type@1.4.0: {} + + fastdom@1.0.12: + dependencies: + strictdom: 1.0.1 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fractional-indexing@3.2.0: {} + + fsevents@2.3.3: + optional: true + + fuzzy@0.1.3: {} + + get-nonce@1.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glur@1.1.2: {} + + hachure-fill@0.5.2: {} + + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + image-blob-reduce@3.0.1: + dependencies: + pica: 7.1.1 + + immutable@4.3.9: {} + + import-meta-resolve@4.2.0: {} + + inherits@2.0.4: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1): + dependencies: + jotai: 2.11.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + + jotai@2.11.0(@types/react@19.3.0)(react@19.3.1): + optionalDependencies: + '@types/react': 19.3.0 + react: 19.3.1 + + jsdom@30.1.3: + dependencies: + '@asamuzakjp/css-color': 7.1.9 + '@asamuzakjp/dom-selector': 10.0.5 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.16(css-tree@3.2.1) + '@exodus/bytes': 1.16.0 + css-tree: 3.2.1 + data-urls: 8.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.1.0 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.12.3 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + langium@3.3.1: + dependencies: + chevrotain: 11.0.3 + chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.16 + vscode-uri: 3.0.8 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lodash-es@4.17.21: {} + + lodash-es@4.18.1: {} + + lodash.debounce@4.0.8: {} + + lodash.throttle@4.1.1: {} + + lru-cache@11.5.3: {} + + magic-string@1.4.5: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + marked@16.4.2: {} + + mdn-data@2.27.1: {} + + mermaid@11.17.2: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.7 + '@mermaid-js/parser': 1.2.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.4 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.4) + cytoscape-fcose: 2.2.0(cytoscape@3.34.4) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.17 + es-toolkit: 1.53.1 + fastdom: 1.0.12 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.3 + + multimath@2.0.0: + dependencies: + glur: 1.1.2 + object-assign: 4.1.1 + + nanoid@3.3.20: {} + + nanoid@3.3.3: {} + + nanoid@4.0.2: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + obug@3.0.0: {} + + open-color@1.9.1: {} + + package-manager-detector@1.9.0: {} + + pako@2.0.3: {} + + parse5@8.1.0: + dependencies: + entities: 9.0.0 + + path-data-parser@0.1.0: {} + + path-key@3.1.1: {} + + perfect-freehand@1.2.0: {} + + pica@7.1.1: + dependencies: + glur: 1.1.2 + inherits: 2.0.4 + multimath: 2.0.0 + object-assign: 4.1.1 + webworkify: 1.5.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + png-chunk-text@1.0.0: {} + + png-chunks-encode@1.0.0: + dependencies: + crc-32: 0.3.0 + sliced: 1.0.1 + + png-chunks-extract@1.0.0: + dependencies: + crc-32: 0.3.0 + + points-on-curve@0.2.0: {} + + points-on-curve@1.0.1: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + punycode@2.3.1: {} + + pwacompat@2.0.17: {} + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.3.1) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.3.1) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + get-nonce: 1.0.1 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react@19.3.1: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + require-from-string@2.0.2: {} + + robust-predicates@3.0.3: {} + + rolldown@1.2.16: + dependencies: + '@oxc-project/types': 0.156.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.16 + '@rolldown/binding-android-arm64': 1.2.16 + '@rolldown/binding-darwin-arm64': 1.2.16 + '@rolldown/binding-darwin-x64': 1.2.16 + '@rolldown/binding-freebsd-x64': 1.2.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 + '@rolldown/binding-linux-arm64-gnu': 1.2.16 + '@rolldown/binding-linux-arm64-musl': 1.2.16 + '@rolldown/binding-linux-ppc64-gnu': 1.2.16 + '@rolldown/binding-linux-s390x-gnu': 1.2.16 + '@rolldown/binding-linux-x64-gnu': 1.2.16 + '@rolldown/binding-linux-x64-musl': 1.2.16 + '@rolldown/binding-openharmony-arm64': 1.2.16 + '@rolldown/binding-win32-arm64-msvc': 1.2.16 + '@rolldown/binding-win32-x64-msvc': 1.2.16 + + roughjs@4.6.4: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + sass@1.51.0: + dependencies: + chokidar: 3.6.0 + immutable: 4.3.9 + source-map-js: 1.2.2 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.28.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + sliced@1.0.1: {} + + source-map-js@1.2.2: {} + + std-env@4.3.0: {} + + strictdom@1.0.1: {} + + stylis@4.4.0: {} + + tinybench@6.2.3: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tldts-core@7.4.20: {} + + tldts@7.4.19: + dependencies: + tldts-core: 7.4.20 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.19 + + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + + ts-dedent@2.3.0: {} + + tslib@2.9.1: {} + + tunnel-rat@0.1.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + - react + + typescript@6.0.3: {} + + undici-types@8.9.0: {} + + undici@8.12.3: {} + + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sync-external-store@1.7.0(react@19.3.1): + dependencies: + react: 19.3.1 + + uuid@14.0.3: {} + + vite@8.3.5(@types/node@26.6.6): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.32 + rolldown: 1.2.16 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.6 + fsevents: 2.3.3 + + vitest@5.0.5(@types/node@26.6.6)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@26.6.6)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.5 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.5(@types/node@26.6.6) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 26.6.6 + jsdom: 30.1.3 + transitivePeerDependencies: + - msw + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.16: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.0.8: {} + + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + webworkify@1.5.0: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.3.0: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + zustand@4.5.7(@types/react@19.3.0)(react@19.3.1): + dependencies: + use-sync-external-store: 1.7.0(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + react: 19.3.1 diff --git a/plugins/official/excalidraw/web-src/src/assets.ts b/plugins/official/excalidraw/web-src/src/assets.ts new file mode 100644 index 00000000..64824bb5 --- /dev/null +++ b/plugins/official/excalidraw/web-src/src/assets.ts @@ -0,0 +1,6 @@ +/** + * Excalidraw loads its fonts from `EXCALIDRAW_ASSET_PATH` (else from a CDN, + * which the frame can't reach): the page's own directory, where the build + * copies them. Imported before Excalidraw. + */ +window.EXCALIDRAW_ASSET_PATH = new URL("./", window.location.href).href; diff --git a/plugins/official/excalidraw/web-src/src/downloads.ts b/plugins/official/excalidraw/web-src/src/downloads.ts new file mode 100644 index 00000000..ec1ff3bc --- /dev/null +++ b/plugins/official/excalidraw/web-src/src/downloads.ts @@ -0,0 +1,25 @@ +/** + * The frame may not download files (no `allow-downloads`): Excalidraw's + * "Save to disk" and image export click a download link, which this turns + * into the IDE's save dialog (`openidea.saveFile`). + */ +const click = HTMLAnchorElement.prototype.click; + +export function routeDownloadsToTheIde(bridge: OpenideaBridge) { + HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) { + const href = this.href; + if (!this.hasAttribute("download") || !/^(blob|data):/.test(href)) return click.call(this); + const name = this.download || "untitled"; + void fetch(href) + .then((r) => r.blob()) + .then((data) => bridge.saveFile({ name, data, filters: filtersFor(name) })) + .catch((e: unknown) => console.error("could not save", name, e)); + }; +} + +function filtersFor(name: string): OpenideaFileFilter[] | undefined { + const ext = /\.([\w]+)$/.exec(name)?.[1]?.toLowerCase(); + if (!ext) return undefined; + const label: Record = { png: "PNG image", svg: "SVG image", excalidraw: "Excalidraw drawing" }; + return [{ name: label[ext] ?? ext.toUpperCase(), extensions: [ext] }]; +} diff --git a/plugins/official/excalidraw/web-src/src/main.tsx b/plugins/official/excalidraw/web-src/src/main.tsx new file mode 100644 index 00000000..a2cb221c --- /dev/null +++ b/plugins/official/excalidraw/web-src/src/main.tsx @@ -0,0 +1,123 @@ +/** + * The Excalidraw plugin's page: the canvas of the `.excalidraw` file its + * custom editor shows, kept in step with the file's text (see sync.ts), in + * the IDE's light or dark theme. + */ +import "./assets"; +import { Excalidraw, MainMenu, THEME } from "@excalidraw/excalidraw"; +import "@excalidraw/excalidraw/index.css"; +import type { ExcalidrawImperativeAPI, UIOptions } from "@excalidraw/excalidraw/types"; +import { StrictMode, useEffect, useMemo, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { routeDownloadsToTheIde } from "./downloads"; +import { applyScene, codec, exportName, loadSceneFonts, type Scene } from "./scene"; +import { DocumentSync } from "./sync"; + +// The canvas is the file's: the IDE opens, saves and themes it, so the page +// keeps only what works on the drawing itself (no Open / Save to, no links +// out of a page without network). +const UI_OPTIONS: UIOptions = { + canvasActions: { + changeViewBackgroundColor: true, + clearCanvas: true, + export: false, + loadScene: false, + saveAsImage: true, + saveToActiveFile: false, + toggleTheme: false, + }, +}; + +function Menu() { + return ( + + + + + + + + + ); +} + +function useIdeTheme(bridge: OpenideaBridge): "dark" | "light" { + const [dark, setDark] = useState(bridge.theme?.dark ?? window.matchMedia?.("(prefers-color-scheme: dark)").matches); + useEffect(() => bridge.onThemeChange((t) => setDark(t.dark)), [bridge]); + return dark ? THEME.DARK : THEME.LIGHT; +} + +type Start = { scene: Scene | null; error: string | null; readOnly: boolean; name: string }; + +function App({ bridge }: { bridge: OpenideaBridge }) { + const theme = useIdeTheme(bridge); + const [api, setApi] = useState(null); + const [start, setStart] = useState(null); + const [error, setError] = useState(null); + // The document's `source`: the canvas doesn't keep it, the file should. + const source = useRef(undefined); + // `apply` reads the API when the document changes, which is after the canvas exists. + const sync = useMemo(() => { + let current: ExcalidrawImperativeAPI | null = null; + const s = new DocumentSync(bridge.document, codec, { + apply: (scene) => { + source.current = scene.source; + if (current) applyScene(current, scene); + }, + error: setError, + }); + return Object.assign(s, { bind: (a: ExcalidrawImperativeAPI | null) => void (current = a) }); + }, [bridge]); + + useEffect(() => { + sync.start().then( + (s) => { + source.current = s.scene?.source; + setStart(s); + setError(s.error); + }, + (e: unknown) => + setStart({ scene: null, error: String(e instanceof Error ? e.message : e), readOnly: true, name: "" }), + ); + }, [sync]); + + useEffect(() => { + sync.bind(api); + if (api) void loadSceneFonts(api); + }, [api, sync]); + + if (!start) return null; + if (!start.scene && error) + return ( +
+

This file can't be shown as a drawing until its text is valid again:

+
{error}
+
+ ); + return ( + sync.changed({ elements, appState, files, source: source.current })} + > +
+ + ); +} + +const bridge = window.openidea; +const root = createRoot(document.getElementById("root")!); +if (bridge) { + routeDownloadsToTheIde(bridge); + root.render( + + + , + ); +} else { + root.render(

This page runs inside OpenIDEA.

); +} diff --git a/plugins/official/excalidraw/web-src/src/openidea.d.ts b/plugins/official/excalidraw/web-src/src/openidea.d.ts new file mode 100644 index 00000000..f7d50e91 --- /dev/null +++ b/plugins/official/excalidraw/web-src/src/openidea.d.ts @@ -0,0 +1,41 @@ +/** The IDE's frame bridge (`window.openidea`), injected before the page's scripts. */ +interface OpenideaDocumentState { + version: number; + text: string; +} + +interface OpenideaTextEdit { + from: number; + to: number; + insert: string; +} + +interface OpenideaFileFilter { + name: string; + extensions: string[]; +} + +interface OpenideaBridge { + postMessage(text: string): void; + onMessage(fn: (text: string) => void): void; + readonly theme: { dark: boolean; vars: Readonly> } | null; + onThemeChange(fn: (theme: { dark: boolean; vars: Readonly> }) => void): void; + document: { + /** `name`: the file's name, without its directory. */ + get(): Promise; + onChange(fn: (state: OpenideaDocumentState) => void): void; + edit(version: number, edits: OpenideaTextEdit[]): Promise<{ version: number }>; + replace(version: number, text: string): Promise<{ version: number }>; + }; + saveFile(options: { + name: string; + data: string | Uint8Array | ArrayBuffer | Blob; + filters?: OpenideaFileFilter[]; + }): Promise; + openFile(options?: { filters?: OpenideaFileFilter[] }): Promise<{ name: string; data: Uint8Array } | null>; +} + +interface Window { + openidea?: OpenideaBridge; + EXCALIDRAW_ASSET_PATH?: string | string[]; +} diff --git a/plugins/official/excalidraw/web-src/src/scene.test.ts b/plugins/official/excalidraw/web-src/src/scene.test.ts new file mode 100644 index 00000000..9712d8ab --- /dev/null +++ b/plugins/official/excalidraw/web-src/src/scene.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { SOURCE, exportName, parseScene, serializeScene } from "./scene"; + +const scene = (text: string) => { + const parsed = parseScene(text); + if ("error" in parsed) throw new Error(parsed.error); + return parsed.scene; +}; + +describe("scene files", () => { + it("keeps the file's source and writes ours, never the page's address, when it had none", () => { + const own = JSON.parse( + serializeScene(scene('{"type":"excalidraw","source":"https://excalidraw.com","elements":[]}')), + ); + expect(own.source).toBe("https://excalidraw.com"); + const none = JSON.parse(serializeScene(scene('{"type":"excalidraw","elements":[]}'))); + expect(none.source).toBe(SOURCE); + for (const page of ["openidea-plugin://localhost", "http://openidea-plugin.localhost"]) { + const fixed = JSON.parse(serializeScene(scene(`{"type":"excalidraw","source":"${page}","elements":[]}`))); + expect(fixed.source).toBe(SOURCE); + } + }); + + it("reads element skeletons and refuses what isn't a drawing", () => { + // (Labels become text elements in the browser; jsdom can't measure text.) + const s = scene('[{"type":"rectangle","x":0,"y":0,"width":10,"height":10}]'); + expect(s.elements[0]).toMatchObject({ type: "rectangle", width: 10 }); + expect(typeof s.elements[0]!.seed).toBe("number"); + expect(parseScene("{not json")).toHaveProperty("error"); + expect(parseScene('{"type":"other"}')).toHaveProperty("error"); + expect(parseScene("")).toHaveProperty("scene"); + }); + + it("names exports after the file", () => { + expect(exportName("diagram.excalidraw")).toBe("diagram"); + expect(exportName("Flow.Excalidraw.json")).toBe("Flow"); + expect(exportName("notes.v2.excalidraw")).toBe("notes.v2"); + expect(exportName("")).toBe("drawing"); + }); +}); diff --git a/plugins/official/excalidraw/web-src/src/scene.ts b/plugins/official/excalidraw/web-src/src/scene.ts new file mode 100644 index 00000000..f31ec443 --- /dev/null +++ b/plugins/official/excalidraw/web-src/src/scene.ts @@ -0,0 +1,170 @@ +/** + * The one module that talks to `@excalidraw/excalidraw`'s data API: reading a + * `.excalidraw` file into a scene (through Excalidraw's own `restore`, so old + * files and element skeletons work), writing it back in the format + * Excalidraw saves, and showing a scene that changed in the text. + */ +import { + CaptureUpdateAction, + convertToExcalidrawElements, + getNonDeletedElements, + getSceneVersion, + restore, + serializeAsJSON, +} from "@excalidraw/excalidraw"; +import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; +import type { AppState, BinaryFiles, ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import type { SceneCodec } from "./sync"; + +export interface Scene { + elements: readonly OrderedExcalidrawElement[]; + appState: Partial; + files: BinaryFiles; + /** The file's `source`, kept as it was. */ + source?: string; +} + +/** `source` of files that had none (Excalidraw would write the page's address). */ +export const SOURCE = "https://openidea.dev"; + +type Skeleton = NonNullable[0]>[number]; + +const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); + +/** + * Hand-written or generated files often hold element skeletons + * (`{ type: "rectangle", x, y, label: { text } }`) rather than full elements: + * expand those, keep full elements (their ids and versions) as they are. + */ +function expandSkeletons(raw: readonly unknown[]): unknown[] { + const out: unknown[] = []; + for (const entry of raw) { + if (!isRecord(entry) || typeof entry.type !== "string") continue; + const skeleton = "label" in entry || typeof entry.seed !== "number"; + if (!skeleton) { + out.push(entry); + continue; + } + try { + out.push(...convertToExcalidrawElements([entry as unknown as Skeleton], { regenerateIds: false })); + } catch { + out.push(entry); + } + } + return out; +} + +export function parseScene(text: string): { scene: Scene } | { error: string } { + let data: unknown = {}; + if (text.trim()) { + try { + data = JSON.parse(text); + } catch (e) { + return { error: e instanceof Error ? e.message : String(e) }; + } + } + if (Array.isArray(data)) data = { elements: data }; + if (!isRecord(data)) return { error: "An Excalidraw file is a JSON object (or an array of elements)." }; + if (typeof data.type === "string" && !data.type.startsWith("excalidraw")) + return { error: `Not an Excalidraw file: its "type" is "${data.type}".` }; + if (data.elements !== undefined && !Array.isArray(data.elements)) return { error: '"elements" must be an array.' }; + const restored = restore( + { + elements: expandSkeletons((data.elements as unknown[]) ?? []) as never, + appState: (isRecord(data.appState) ? data.appState : {}) as Partial, + files: (isRecord(data.files) ? data.files : {}) as BinaryFiles, + }, + null, + null, + { refreshDimensions: false, repairBindings: true }, + ); + // The page's own address (what Excalidraw writes here) is no source worth keeping. + const own = /^(openidea-plugin:|https?:\/\/openidea-plugin\.localhost)/; + const source = + typeof data.source === "string" && data.source.trim() && !own.test(data.source) ? data.source : undefined; + return { scene: { elements: restored.elements, appState: restored.appState, files: restored.files ?? {}, source } }; +} + +/** + * As Excalidraw saves to disk (`serializeAsJSON(…, "local")`), with a final + * newline, keeping the file's `source`: Excalidraw writes the page's origin, + * which here is the IDE's internal plugin URL. + */ +export function serializeScene(scene: Scene): string { + const json = serializeAsJSON(scene.elements, scene.appState, scene.files, "local"); + const source = JSON.stringify(scene.source ?? SOURCE); + // `source` is the third key, before any element: the first match is the file's own. + return `${json.replace(/"source": "(?:[^"\\]|\\.)*"/, `"source": ${source}`)}\n`; +} + +/** What is saved changes iff this does: pointer moves, zoom and selection don't. */ +export function sceneSignature(scene: Scene): string { + const elements = getNonDeletedElements(scene.elements); + const a = scene.appState; + const files = Object.keys(scene.files).sort().join(","); + return [ + getSceneVersion(elements), + elements.length, + a.viewBackgroundColor, + a.gridSize, + a.gridStep, + a.gridModeEnabled, + files, + ].join(":"); +} + +export const codec: SceneCodec = { parse: parseScene, serialize: serializeScene, signature: sceneSignature }; + +/** Shows a scene the text changed to, keeping the view (scroll, zoom) and the canvas's undo history. */ +export function applyScene(api: ExcalidrawImperativeAPI, scene: Scene) { + const files = Object.values(scene.files); + if (files.length) api.addFiles(files); + const { viewBackgroundColor, gridSize, gridStep, gridModeEnabled } = scene.appState; + const appState: Pick = { + viewBackgroundColor: viewBackgroundColor ?? "#ffffff", + gridSize: gridSize ?? 20, + gridStep: gridStep ?? 5, + gridModeEnabled: gridModeEnabled ?? false, + }; + api.updateScene({ elements: scene.elements, appState, captureUpdate: CaptureUpdateAction.NEVER }); + void loadSceneFonts(api); +} + +/** + * Excalidraw loads the fonts of the scene it starts with, not of scenes set + * later, and WebKit's canvas doesn't load a font by drawing with it: load + * them, then redraw text (new element objects drop Excalidraw's cached text + * canvases) without recording a change. + */ +export async function loadSceneFonts(api: ExcalidrawImperativeAPI) { + const fonts = new Set(); + for (const el of api.getSceneElements()) { + if (el.type === "text") fonts.add(`${el.fontSize}px ${fontStack(el.fontFamily)}`); + } + if (!fonts.size || !document.fonts?.load) return; + await Promise.allSettled([...fonts].map((f) => document.fonts.load(f))); + api.updateScene({ + elements: api.getSceneElementsIncludingDeleted().map((el) => ({ ...el })), + captureUpdate: CaptureUpdateAction.NEVER, + }); +} + +/** Excalidraw's font family ids (`FONT_FAMILY`) → their CSS stack. */ +function fontStack(id: number): string { + const names: Record = { + 1: "Virgil", + 2: "Helvetica", + 3: "Cascadia", + 5: "Excalifont, Xiaolai", + 6: "Nunito", + 7: "Lilita One", + 8: "Comic Shanns", + 9: "Liberation Sans", + }; + return `${names[id] ?? "Excalifont, Xiaolai"}, Segoe UI Emoji`; +} + +/** What exports are called: the file's name without its extension. */ +export function exportName(file: string): string { + return file.replace(/\.excalidraw(\.json)?$/i, "") || "drawing"; +} diff --git a/plugins/official/excalidraw/web-src/src/sync.test.ts b/plugins/official/excalidraw/web-src/src/sync.test.ts new file mode 100644 index 00000000..3a7500b2 --- /dev/null +++ b/plugins/official/excalidraw/web-src/src/sync.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; +import { DocumentSync, type SceneCodec, type SyncPort } from "./sync"; + +/** Scenes are plain strings here: the text is the scene. */ +const codec: SceneCodec = { + parse: (text) => (text.startsWith("!") ? { error: "bad" } : { scene: text }), + serialize: (scene) => scene, + signature: (scene) => scene, +}; + +function port(initial: string) { + let version = 0; + let text = initial; + let listener: (s: { version: number; text: string }) => void = () => {}; + const p = { + get: vi.fn(async () => ({ version, text, readOnly: false, name: "a.excalidraw" })), + replace: vi.fn(async (at: number, next: string) => { + if (at !== version) throw new Error("stale"); + text = next; + return { version: ++version }; + }), + onChange: (fn: typeof listener) => void (listener = fn), + /** An edit made in the text editor. */ + external(next: string) { + text = next; + listener({ version: ++version, text }); + }, + text: () => text, + } satisfies SyncPort & Record; + return p; +} + +const hooks = () => ({ apply: vi.fn(), error: vi.fn() }); + +describe("DocumentSync", () => { + it("starts from the document and writes canvas changes back, once per saved change", async () => { + const p = port("a"); + const h = hooks(); + const sync = new DocumentSync(p, codec, h, 0); + expect(await sync.start()).toEqual({ scene: "a", error: null, readOnly: false, name: "a.excalidraw" }); + sync.changed("a"); + await sync.flush(); + expect(p.replace).not.toHaveBeenCalled(); + sync.changed("ab"); + sync.changed("abc"); + await sync.flush(); + expect(p.replace).toHaveBeenCalledTimes(1); + expect(p.text()).toBe("abc"); + }); + + it("shows edits made elsewhere and doesn't write them back", async () => { + const p = port("a"); + const h = hooks(); + const sync = new DocumentSync(p, codec, h, 0); + await sync.start(); + p.external("from the text editor"); + expect(h.apply).toHaveBeenCalledWith("from the text editor"); + sync.changed("from the text editor"); + await sync.flush(); + expect(p.replace).not.toHaveBeenCalled(); + // The next canvas edit is based on the new version. + sync.changed("canvas"); + await sync.flush(); + expect(p.text()).toBe("canvas"); + }); + + it("lets the document win a race: a stale write is dropped and the canvas takes the text", async () => { + const p = port("a"); + const h = hooks(); + const sync = new DocumentSync(p, codec, h, 0); + await sync.start(); + sync.changed("canvas"); + // The document moved on without the sync hearing of it yet. + p.replace.mockImplementationOnce(async () => { + throw new Error("stale"); + }); + await sync.flush(); + expect(p.get).toHaveBeenCalledTimes(2); + expect(p.text()).toBe("a"); + }); + + it("never overwrites text that doesn't parse, and resumes when it does", async () => { + const p = port("a"); + const h = hooks(); + const sync = new DocumentSync(p, codec, h, 0); + await sync.start(); + p.external("!broken"); + expect(h.error).toHaveBeenLastCalledWith("bad"); + sync.changed("canvas"); + await sync.flush(); + expect(p.text()).toBe("!broken"); + p.external("fixed"); + expect(h.error).toHaveBeenLastCalledWith(null); + sync.changed("canvas"); + await sync.flush(); + expect(p.text()).toBe("canvas"); + }); + + it("starts read-only and invalid documents without writing", async () => { + const p = port("!broken"); + const sync = new DocumentSync(p, codec, hooks(), 0); + expect(await sync.start()).toEqual({ scene: null, error: "bad", readOnly: false, name: "a.excalidraw" }); + sync.changed("x"); + await sync.flush(); + expect(p.replace).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/official/excalidraw/web-src/src/sync.ts b/plugins/official/excalidraw/web-src/src/sync.ts new file mode 100644 index 00000000..8af22cc8 --- /dev/null +++ b/plugins/official/excalidraw/web-src/src/sync.ts @@ -0,0 +1,128 @@ +/** + * Keeps the canvas and the file's text in step through the IDE's document + * bridge. The text is the truth: the canvas starts from it, its edits are + * written back (debounced, versioned) and edits made elsewhere (the text + * editor, Undo, another window) replace what the canvas shows. + * + * - Canvas changes that don't change what is saved (pointer moves, zoom, + * selection) write nothing: they're compared by `signature`. + * - A write that lost a race (`stale`) is dropped and the canvas takes the + * document's text. + * - Text that doesn't parse is never overwritten: the canvas stops writing + * until the text is valid again. + */ +export interface SyncPort { + get(): Promise<{ version: number; text: string; readOnly: boolean; name?: string }>; + replace(version: number, text: string): Promise<{ version: number }>; + onChange(fn: (state: { version: number; text: string }) => void): void; +} + +export interface SceneCodec { + parse(text: string): { scene: S } | { error: string }; + serialize(scene: S): string; + /** Equal for scenes that save the same. */ + signature(scene: S): string; +} + +export interface SyncHooks { + /** Show a scene the document changed to. */ + apply(scene: S): void; + /** The document's text doesn't parse (`null`: it does again). */ + error(message: string | null): void; +} + +export class DocumentSync { + private version = 0; + private text = ""; + private readOnly = false; + private valid = true; + private signature: string | null = null; + private pending: S | null = null; + private timer: ReturnType | null = null; + private writing: Promise | null = null; + + constructor( + private readonly port: SyncPort, + private readonly codec: SceneCodec, + private readonly hooks: SyncHooks, + private readonly delayMs = 250, + ) {} + + /** The document's scene to start from. */ + async start(): Promise<{ scene: S | null; error: string | null; readOnly: boolean; name: string }> { + const doc = await this.port.get(); + this.version = doc.version; + this.text = doc.text; + this.readOnly = doc.readOnly; + this.port.onChange((state) => this.external(state)); + const parsed = this.codec.parse(doc.text); + if ("error" in parsed) { + this.valid = false; + return { scene: null, error: parsed.error, readOnly: doc.readOnly, name: doc.name ?? "" }; + } + this.signature = this.codec.signature(parsed.scene); + return { scene: parsed.scene, error: null, readOnly: doc.readOnly, name: doc.name ?? "" }; + } + + /** The canvas changed. */ + changed(scene: S) { + if (this.readOnly || !this.valid) return; + const signature = this.codec.signature(scene); + if (signature === this.signature) return; + this.signature = signature; + this.pending = scene; + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + void this.flush(); + }, this.delayMs); + } + + /** Writes what is pending now (also waits for a write in flight). */ + async flush(): Promise { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + while (this.writing) await this.writing; + const scene = this.pending; + this.pending = null; + if (scene === null) return; + const text = this.codec.serialize(scene); + if (text === this.text) return; + this.writing = (async () => { + try { + const { version } = await this.port.replace(this.version, text); + this.version = version; + this.text = text; + } catch (e) { + if (!(e instanceof Error && e.message === "stale")) throw e; + // Someone else changed it first: theirs wins. + this.external(await this.port.get()); + } + })().finally(() => { + this.writing = null; + }); + await this.writing; + } + + private external(state: { version: number; text: string }) { + this.version = state.version; + if (state.text === this.text) return; + this.text = state.text; + // The document moved on: what the canvas had pending is based on the old text. + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.pending = null; + const parsed = this.codec.parse(state.text); + if ("error" in parsed) { + this.valid = false; + this.hooks.error(parsed.error); + return; + } + this.valid = true; + this.signature = this.codec.signature(parsed.scene); + this.hooks.error(null); + this.hooks.apply(parsed.scene); + } +} diff --git a/plugins/official/excalidraw/web-src/src/test-setup.ts b/plugins/official/excalidraw/web-src/src/test-setup.ts new file mode 100644 index 00000000..1ecf7bcd --- /dev/null +++ b/plugins/official/excalidraw/web-src/src/test-setup.ts @@ -0,0 +1,10 @@ +/** + * jsdom has no canvas: Excalidraw probes a 2D context when it loads (and + * measures text with it). A context whose methods do nothing is enough for + * its data API. + */ +const context = new Proxy( + { filter: "none", measureText: (text: string) => ({ width: text.length * 8 }) } as Record, + { get: (target, key) => (key in target ? target[key] : () => undefined) }, +); +HTMLCanvasElement.prototype.getContext = (() => context) as unknown as HTMLCanvasElement["getContext"]; diff --git a/plugins/official/excalidraw/web-src/tsconfig.json b/plugins/official/excalidraw/web-src/tsconfig.json new file mode 100644 index 00000000..3e73e29b --- /dev/null +++ b/plugins/official/excalidraw/web-src/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/plugins/official/excalidraw/web-src/vite.config.ts b/plugins/official/excalidraw/web-src/vite.config.ts new file mode 100644 index 00000000..fd731090 --- /dev/null +++ b/plugins/official/excalidraw/web-src/vite.config.ts @@ -0,0 +1,48 @@ +/** + * Builds the page into ../web (not checked in: plugins/build.sh builds it). + * Relative URLs throughout: the IDE serves the page from + * the plugin package under its own URL scheme. Excalidraw's fonts are copied + * next to it; the page points Excalidraw at them (src/assets.ts). + */ +import { cpSync, existsSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import type { Plugin } from "vite"; +import { defineConfig } from "vitest/config"; + +const require = createRequire(import.meta.url); +const outDir = path.resolve(import.meta.dirname, "../web"); + +function excalidrawFonts(): Plugin { + return { + name: "openidea:excalidraw-fonts", + apply: "build", + closeBundle() { + const fonts = path.resolve(path.dirname(require.resolve("@excalidraw/excalidraw")), "fonts"); + if (!existsSync(fonts)) throw new Error(`no Excalidraw fonts at ${fonts}`); + rmSync(path.join(outDir, "fonts"), { recursive: true, force: true }); + cpSync(fonts, path.join(outDir, "fonts"), { recursive: true }); + }, + }; +} + +export default defineConfig({ + base: "./", + plugins: [react(), excalidrawFonts()], + define: { "process.env.IS_PREACT": JSON.stringify("false") }, + build: { + outDir, + emptyOutDir: true, + target: "es2022", + sourcemap: false, + assetsInlineLimit: 0, + chunkSizeWarningLimit: 4096, + }, + test: { + // Excalidraw needs a DOM even for its data API; its packages ship JSON and CSS imports Node can't load. + environment: "jsdom", + setupFiles: ["./src/test-setup.ts"], + server: { deps: { inline: [/@excalidraw/, /open-color/] } }, + }, +}); diff --git a/plugins/official/git-toolbox/.gitignore b/plugins/official/git-toolbox/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /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 00000000..9ad60b81 --- /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 00000000..98e03ddd --- /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..9479f183c540106fab7dc769999554ec26fa1fb4 GIT binary patch literal 108855 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 K)*pF~#{Uof)6LQV literal 0 HcmV?d00001 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources new file mode 100644 index 00000000..fd509401 --- /dev/null +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -0,0 +1 @@ +ad545605b33c6f4e55a06a376333de3162739ed920b5d7d277942e50a4420083 diff --git a/plugins/official/git-toolbox/plugin.toml b/plugins/official/git-toolbox/plugin.toml new file mode 100644 index 00000000..ff5bbfce --- /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 00000000..5201eae9 --- /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::().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 { + 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 { + 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, Error> { + 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(Error::NotFound(format!("command {id}"))), + } + } + + fn handle_events(&mut self, events: &[Event], ctx: &Invocation) { + if events.contains(&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, Error::Failed("no network".into())); + 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/graphviz/.gitignore b/plugins/official/graphviz/.gitignore new file mode 100644 index 00000000..2aa0170a --- /dev/null +++ b/plugins/official/graphviz/.gitignore @@ -0,0 +1,4 @@ +web-src/node_modules/ +# Built by plugins/build.sh, not checked in. +web/ +web.sources diff --git a/plugins/official/graphviz/Cargo.lock b/plugins/official/graphviz/Cargo.lock new file mode 100644 index 00000000..f7341f4a --- /dev/null +++ b/plugins/official/graphviz/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-graphviz" +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/graphviz/Cargo.toml b/plugins/official/graphviz/Cargo.toml new file mode 100644 index 00000000..69c6235c --- /dev/null +++ b/plugins/official/graphviz/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "openidea-plugin-openidea-graphviz" +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/graphviz/NOTICE.md b/plugins/official/graphviz/NOTICE.md new file mode 100644 index 00000000..cf8b4298 --- /dev/null +++ b/plugins/official/graphviz/NOTICE.md @@ -0,0 +1,28 @@ +# Third-party software + +The page in `web/` is built from `web-src/` and bundles: + +- **Viz.js** (`@viz-js/viz` 3.31.0), MIT License, Copyright (c) Michael Daines — + https://github.com/mdaines/viz-js +- **Graphviz** 16.1.0, compiled to WebAssembly inside Viz.js, Eclipse Public License 1.0 — + https://graphviz.org, https://gitlab.com/graphviz/graphviz + +The MIT License: + +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software +> and associated documentation files (the "Software"), to deal in the Software without +> restriction, including without limitation the rights to use, copy, modify, merge, publish, +> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or +> substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +The Eclipse Public License 1.0 is at https://www.eclipse.org/legal/epl-v10.html. Graphviz's +source code is available from the links above. diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..fa2c8114e3846abbe9244214d6b59a223f906514 GIT binary patch literal 124849 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 + + + + + diff --git a/plugins/official/graphviz/icons/graphviz_dark.svg b/plugins/official/graphviz/icons/graphviz_dark.svg new file mode 100644 index 00000000..545c3b97 --- /dev/null +++ b/plugins/official/graphviz/icons/graphviz_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/official/graphviz/languages/dot.toml b/plugins/official/graphviz/languages/dot.toml new file mode 100644 index 00000000..fa0f70c9 --- /dev/null +++ b/plugins/official/graphviz/languages/dot.toml @@ -0,0 +1,8 @@ +id = "dot" +name = "DOT" +extensions = ["dot", "gv"] +line-comment = "//" +block-comment = ["/*", "*/"] +indent = { tab-size = 4, insert-spaces = true } +# No DOT mode in CodeMirror: C's highlights the strings, comments, numbers and braces. +ui-mode = "C" diff --git a/plugins/official/graphviz/plugin.toml b/plugins/official/graphviz/plugin.toml new file mode 100644 index 00000000..04f87a2e --- /dev/null +++ b/plugins/official/graphviz/plugin.toml @@ -0,0 +1,38 @@ +id = "openidea.graphviz" +name = "Graphviz" +version = "0.1.0" +description = "Graphviz DOT files (.dot, .gv) with a live preview beside the text: rendered as you type with any layout engine, syntax errors in the editor and the Problems view, export to SVG and PNG." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: live preview (Graphviz compiled to WebAssembly), layout engines, zoom, errors as problems, SVG and PNG export." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-view:preview"] + +[capabilities] +# Problems: published for the project's file (project.read), on the lines +# Graphviz names (editor.read: the previewed text). +required = ["editor.read", "project.read"] + +[[contributes.customEditors]] +id = "preview" +title = "Preview" +patterns = ["*.dot", "*.gv"] +priority = "default" +layout = "split" +web = "web/index.html" + +[[contributes.languages]] +path = "languages/dot.toml" + +[[contributes.fileIcons]] +id = "graphviz" +patterns = ["*.dot", "*.gv"] +icon = "icons/graphviz.svg" +iconDark = "icons/graphviz_dark.svg" + +[[contributes.fileTemplates]] +id = "graph" +name = "Graphviz Graph" +extension = "dot" +path = "templates/graph.dot" diff --git a/plugins/official/graphviz/src/lib.rs b/plugins/official/graphviz/src/lib.rs new file mode 100644 index 00000000..cd76c651 --- /dev/null +++ b/plugins/official/graphviz/src/lib.rs @@ -0,0 +1,141 @@ +//! Graphviz for OpenIDEA: `.dot` / `.gv` files open in the text editor with a +//! live preview beside them. The preview is a web page (`web-src/`: viz.js, +//! Graphviz compiled to WebAssembly) that renders the document as it changes; +//! what Graphviz reports it sends here, and the plugin publishes it as the +//! file's problems, on the line Graphviz names. + +use openidea_plugin::views::{View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Diagnostic, Error, Invocation, Plugin, Position, Range, Severity, host, register_plugin}; +use serde_json::Value; + +pub const VIEW: &str = "preview"; +/// Problems published for one file at most. +const MAX_PROBLEMS: usize = 100; + +/// One message from Graphviz, as the page reports it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Report { + /// 1-based, when Graphviz names one. + pub line: Option, + pub message: String, + pub error: bool, +} + +/// The page's `{"op": "problems", "items": [{"line", "message", "level"}]}`. +pub fn parse_reports(message: &str) -> Option> { + let v: Value = serde_json::from_str(message).ok()?; + if v.get("op").and_then(Value::as_str) != Some("problems") { + return None; + } + let items = v.get("items")?.as_array()?; + Some( + items + .iter() + .take(MAX_PROBLEMS) + .filter_map(|i| { + Some(Report { + line: i.get("line").and_then(Value::as_u64).and_then(|l| u32::try_from(l).ok()), + message: i.get("message")?.as_str()?.trim().to_owned(), + error: i.get("level").and_then(Value::as_str) != Some("warning"), + }) + }) + .collect(), + ) +} + +/// `reports` as diagnostics of `text`: on the named line (clamped to the +/// document), the whole of it; without a line, on the first. +pub fn diagnostics(text: &str, reports: &[Report]) -> Vec { + let lines: Vec<&str> = text.lines().collect(); + reports + .iter() + .map(|r| { + let line = r.line.map_or(0, |l| l.saturating_sub(1)).min(lines.len().saturating_sub(1) as u32); + let len = lines.get(line as usize).map_or(0, |l| l.encode_utf16().count() as u32); + Diagnostic { + range: Range::new(Position::new(line, 0), Position::new(line, len)), + severity: if r.error { Severity::Error } else { Severity::Warning }, + message: r.message.clone(), + code: Some("graphviz".into()), + } + }) + .collect() +} + +#[derive(Default)] +pub struct Graphviz; + +impl Plugin for Graphviz { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + Err(Error::NotFound(format!("command {id}"))) + } +} + +impl Views for Graphviz { + fn render(&mut self, view: &str, _viewport: Option, _ctx: &Invocation) -> Result { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + // The preview is the plugin's web page; nothing is rendered here. + Ok(View::message("The preview is shown by the plugin's page.")) + } + + fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + let ViewEvent::Message(message) = event else { return Ok(()) }; + let Some(reports) = parse_reports(&message) else { return Ok(()) }; + let editor = ctx.editor().ok_or_else(|| Error::Invalid("the preview needs its document".into()))?; + let text = editor.document()?.text; + // A file outside the open projects has no problems to publish to; its + // preview shows what went wrong anyway. + match host::publish_diagnostics(&editor.path(), &diagnostics(&text, &reports)) { + Err(Error::Invalid(_)) => Ok(()), + other => other, + } + } +} + +register_plugin!(Graphviz, views); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + #[test] + fn reports_become_problems_on_their_line() { + let msg = r#"{"op":"problems","items":[ + {"line":2,"message":"syntax error in line 2 near '}'","level":"error"}, + {"line":null,"message":"Warning: no such engine","level":"warning"}, + {"line":99,"message":"past the end"}, + {"nope":1} + ]}"#; + let reports = parse_reports(msg).unwrap(); + assert_eq!(reports.len(), 3); + let d = diagnostics("digraph {\n a -> }\n", &reports); + assert_eq!( + (d[0].range, d[0].severity), + (Range::new(Position::new(1, 0), Position::new(1, 8)), Severity::Error) + ); + assert_eq!((d[1].range.start.line, d[1].severity), (0, Severity::Warning)); + assert_eq!(d[2].range.start.line, 1, "clamped to the last line"); + assert!(parse_reports(r#"{"op":"other"}"#).is_none()); + assert!(parse_reports("garbage").is_none()); + assert!(diagnostics("", &reports).iter().all(|d| d.range.start.line == 0)); + } + + #[test] + fn publishes_for_the_previewed_file_and_clears() { + let host = MockHost::install().with_capabilities(&["editor.read", "project.read"]); + host.open_editor("/project/a.dot", "dot", "digraph {\n a -> \n}\n", &[(0, 0, 0, 0)]); + let mut p = Graphviz; + let msg = + r#"{"op":"problems","items":[{"line":3,"message":"syntax error in line 3 near '}'","level":"error"}]}"#; + p.handle(VIEW, ViewEvent::Message(msg.into()), &host.invocation()).unwrap(); + assert_eq!(host.diagnostics()["/project/a.dot"][0].range.start.line, 2); + p.handle(VIEW, ViewEvent::Message(r#"{"op":"problems","items":[]}"#.into()), &host.invocation()).unwrap(); + assert!(host.diagnostics().get("/project/a.dot").is_none_or(Vec::is_empty)); + } +} diff --git a/plugins/official/graphviz/templates/graph.dot b/plugins/official/graphviz/templates/graph.dot new file mode 100644 index 00000000..450238b6 --- /dev/null +++ b/plugins/official/graphviz/templates/graph.dot @@ -0,0 +1,11 @@ +digraph G { + rankdir=LR; + node [shape=box, style=rounded]; + + start [label="Start"]; + work [label="Do the work"]; + done [label="Done", shape=doublecircle]; + + start -> work -> done; + work -> work [label="again"]; +} diff --git a/plugins/official/graphviz/web-src/index.html b/plugins/official/graphviz/web-src/index.html new file mode 100644 index 00000000..adf0f73c --- /dev/null +++ b/plugins/official/graphviz/web-src/index.html @@ -0,0 +1,25 @@ + + + + + + Graphviz + + + + +
+ + + + diff --git a/plugins/official/graphviz/web-src/package.json b/plugins/official/graphviz/web-src/package.json new file mode 100644 index 00000000..3e26eb31 --- /dev/null +++ b/plugins/official/graphviz/web-src/package.json @@ -0,0 +1,20 @@ +{ + "name": "openidea-plugin-graphviz-web", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "The Graphviz plugin's preview page; `pnpm build` writes it to ../web.", + "scripts": { + "build": "tsc --noEmit && vite build", + "test": "vitest run" + }, + "dependencies": { + "@viz-js/viz": "3.31.0" + }, + "devDependencies": { + "@types/node": "^26.6.6", + "typescript": "~6.0.3", + "vite": "^8.0.16", + "vitest": "^5.0.5" + } +} diff --git a/plugins/official/graphviz/web-src/pnpm-lock.yaml b/plugins/official/graphviz/web-src/pnpm-lock.yaml new file mode 100644 index 00000000..84c2010e --- /dev/null +++ b/plugins/official/graphviz/web-src/pnpm-lock.yaml @@ -0,0 +1,669 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@viz-js/viz': + specifier: 3.31.0 + version: 3.31.0 + devDependencies: + '@types/node': + specifier: ^26.6.6 + version: 26.6.6 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.16 + version: 8.3.5(@types/node@26.6.6) + vitest: + specifier: ^5.0.5 + version: 5.0.5(@types/node@26.6.6)(vite@8.3.5(@types/node@26.6.6)) + +packages: + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + + '@rolldown/binding-android-arm-eabi@1.2.16': + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.16': + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.16': + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.16': + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.16': + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.16': + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.16': + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.16': + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.16': + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.16': + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + + '@viz-js/viz@3.31.0': + resolution: {integrity: sha512-r7zlQdRvcwvIpjHGgs+KNWHeE/P5/Dq7k8ZAKaMCbZqomxCBJV78gVuQYaHzFVca+kB0mX0fMW9UFevCOBG50A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@1.4.5: + resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.16: + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + tinybench@6.2.3: + resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + +snapshots: + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.156.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.16': + optional: true + + '@rolldown/binding-android-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-x64@1.2.16': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.16': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.16': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.16': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.16': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.16': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.10': {} + + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + + '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@26.6.6))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.5 + optionalDependencies: + vite: 8.3.5(@types/node@26.6.6) + + '@vitest/spy@5.0.5': {} + + '@viz-js/viz@3.31.0': {} + + assertion-error@2.0.1: {} + + chai@6.3.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@1.4.5: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + nanoid@3.3.20: {} + + obug@3.0.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + rolldown@1.2.16: + dependencies: + '@oxc-project/types': 0.156.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.16 + '@rolldown/binding-android-arm64': 1.2.16 + '@rolldown/binding-darwin-arm64': 1.2.16 + '@rolldown/binding-darwin-x64': 1.2.16 + '@rolldown/binding-freebsd-x64': 1.2.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 + '@rolldown/binding-linux-arm64-gnu': 1.2.16 + '@rolldown/binding-linux-arm64-musl': 1.2.16 + '@rolldown/binding-linux-ppc64-gnu': 1.2.16 + '@rolldown/binding-linux-s390x-gnu': 1.2.16 + '@rolldown/binding-linux-x64-gnu': 1.2.16 + '@rolldown/binding-linux-x64-musl': 1.2.16 + '@rolldown/binding-openharmony-arm64': 1.2.16 + '@rolldown/binding-win32-arm64-msvc': 1.2.16 + '@rolldown/binding-win32-x64-msvc': 1.2.16 + + source-map-js@1.2.2: {} + + std-env@4.3.0: {} + + tinybench@6.2.3: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@6.0.3: {} + + undici-types@8.9.0: {} + + vite@8.3.5(@types/node@26.6.6): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.32 + rolldown: 1.2.16 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.6 + fsevents: 2.3.3 + + vitest@5.0.5(@types/node@26.6.6)(vite@8.3.5(@types/node@26.6.6)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@26.6.6)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.5 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.5(@types/node@26.6.6) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 26.6.6 + transitivePeerDependencies: + - msw + + why-is-node-running@3.2.1: {} diff --git a/plugins/official/graphviz/web-src/src/main.ts b/plugins/official/graphviz/web-src/src/main.ts new file mode 100644 index 00000000..04f8d5d8 --- /dev/null +++ b/plugins/official/graphviz/web-src/src/main.ts @@ -0,0 +1,213 @@ +/** + * The Graphviz plugin's preview: renders the `.dot` file it is opened beside + * (read only: the text editor next to it is where it changes) with viz.js, + * follows the IDE theme, zooms and pans, exports SVG and PNG through the + * IDE's save dialog, and sends what Graphviz reports to the plugin's code, + * which turns it into the file's problems. + */ +import { instance, type Viz } from "@viz-js/viz"; +import { ENGINES, type Engine, exportName, problemsOf, renderOptions } from "./render"; + +const RENDER_DELAY_MS = 150; +const MIN_ZOOM = 0.1; +const MAX_ZOOM = 8; +/** The most Fit enlarges a small graph to (opening a file never enlarges it). */ +const MAX_FIT = 4; + +const $ = (id: string) => document.getElementById(id) as T; + +function start(bridge: OpenideaBridge) { + const stage = $("stage"); + const canvas = $("canvas"); + const banner = $("banner"); + const engineSelect = $("engine"); + const zoomLabel = $("zoom-label"); + + for (const e of ENGINES) engineSelect.add(new Option(e, e)); + + let viz: Viz | null = null; + let text = ""; + let name = "graph.dot"; + let engine: Engine = "dot"; + let zoom = 1; + let fitted = true; + // How far fitting may enlarge: 1 until the user asks for Fit. + let fitCap = 1; + let timer = 0; + let lastProblems = ""; + + const foreground = () => bridge.theme?.vars["--openidea-fg"] ?? null; + + function setZoom(z: number, keepFit = false) { + zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); + fitted = keepFit; + // `zoom`, not a transform: the scroll area follows the scaled size. + canvas.style.zoom = String(zoom); + zoomLabel.textContent = `${Math.round(zoom * 100)}%`; + } + + function fit() { + const svg = canvas.querySelector("svg"); + if (!svg) return; + // Graphviz sizes its SVG in points; these are CSS pixels at 100%. + const w = svg.width.baseVal.value; + const h = svg.height.baseVal.value; + if (!w || !h) return; + // The canvas's padding (16px a side) scales with it; a pixel spare for rounding. + const pad = 32; + const z = Math.min((stage.clientWidth - 1) / (w + pad), (stage.clientHeight - 1) / (h + pad), fitCap); + setZoom(z > 0 && Number.isFinite(z) ? z : 1, true); + } + + /** What Graphviz said, to the plugin (only when it changed: each one republishes the problems). */ + function report(problems: ReturnType) { + const key = JSON.stringify(problems); + if (key === lastProblems) return; + lastProblems = key; + bridge.postMessage(JSON.stringify({ op: "problems", items: problems })); + } + + function render() { + if (!viz) return; + const result = viz.render(text, renderOptions(engine, foreground())); + const problems = problemsOf(result); + report(problems); + if (result.status === "success") { + canvas.innerHTML = result.output; + canvas.classList.remove("stale"); + banner.hidden = true; + if (fitted) fit(); + } else { + // The last good graph stays, dimmed, under what went wrong. + canvas.classList.add("stale"); + banner.hidden = false; + banner.textContent = problems.map((p) => (p.line ? `Line ${p.line}: ${p.message}` : p.message)).join("\n"); + } + } + + const schedule = (delay = RENDER_DELAY_MS) => { + clearTimeout(timer); + timer = window.setTimeout(render, delay); + }; + + /** The graph as Graphviz draws it, without the theme's defaults. */ + function exportSvg(): string | null { + if (!viz) return null; + const result = viz.render(text, renderOptions(engine, null)); + return result.status === "success" ? result.output : null; + } + + /** Runs an export; a failure (no dialog, the image not drawn) shows in the banner. */ + async function exporting(run: () => Promise) { + try { + await run(); + } catch (e) { + banner.hidden = false; + banner.textContent = `Export failed: ${e instanceof Error ? e.message : String(e)}`; + } + } + + async function saveSvg() { + const svg = exportSvg(); + if (!svg) return; + await bridge.saveFile({ + name: exportName(name, "svg"), + data: svg, + filters: [{ name: "SVG image", extensions: ["svg"] }], + }); + } + + async function savePng() { + const svg = exportSvg(); + if (!svg) return; + const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" })); + try { + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("the graph could not be drawn as an image")); + img.src = url; + }); + const scale = 2; + const c = document.createElement("canvas"); + c.width = Math.ceil(img.naturalWidth * scale); + c.height = Math.ceil(img.naturalHeight * scale); + const ctx = c.getContext("2d")!; + ctx.scale(scale, scale); + ctx.drawImage(img, 0, 0); + const png = await new Promise((resolve) => c.toBlob(resolve, "image/png")); + if (!png) throw new Error("the image could not be encoded"); + await bridge.saveFile({ + name: exportName(name, "png"), + data: png, + filters: [{ name: "PNG image", extensions: ["png"] }], + }); + } finally { + URL.revokeObjectURL(url); + } + } + + engineSelect.addEventListener("change", () => { + engine = engineSelect.value as Engine; + fitted = true; + schedule(0); + }); + $("zoom-in").addEventListener("click", () => setZoom(zoom * 1.25)); + $("zoom-out").addEventListener("click", () => setZoom(zoom / 1.25)); + $("zoom-fit").addEventListener("click", () => { + fitCap = MAX_FIT; + fit(); + }); + $("zoom-reset").addEventListener("click", () => setZoom(1)); + $("export-svg").addEventListener("click", () => void exporting(saveSvg)); + $("export-png").addEventListener("click", () => void exporting(savePng)); + // ⌘/Ctrl + wheel (and pinch) zooms; the wheel alone scrolls. + stage.addEventListener( + "wheel", + (e) => { + if (!e.ctrlKey && !e.metaKey) return; + e.preventDefault(); + setZoom(zoom * Math.exp(-e.deltaY / 300)); + }, + { passive: false }, + ); + // Dragging pans. + stage.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + const [x, y, left, top] = [e.clientX, e.clientY, stage.scrollLeft, stage.scrollTop]; + stage.setPointerCapture(e.pointerId); + stage.classList.add("panning"); + const move = (m: PointerEvent) => { + stage.scrollLeft = left - (m.clientX - x); + stage.scrollTop = top - (m.clientY - y); + }; + const up = () => { + stage.classList.remove("panning"); + stage.removeEventListener("pointermove", move); + stage.removeEventListener("pointerup", up); + }; + stage.addEventListener("pointermove", move); + stage.addEventListener("pointerup", up); + }); + new ResizeObserver(() => fitted && fit()).observe(stage); + bridge.onThemeChange(() => schedule(0)); + bridge.document.onChange((s) => { + text = s.text; + schedule(); + }); + + void (async () => { + const [doc, v] = await Promise.all([bridge.document.get(), instance()]); + viz = v; + text = doc.text; + name = doc.name || name; + render(); + })().catch((e: unknown) => { + banner.hidden = false; + banner.textContent = `The preview could not start: ${e instanceof Error ? e.message : String(e)}`; + }); +} + +const bridge = window.openidea; +if (bridge) start(bridge); +else document.body.textContent = "This page runs inside OpenIDEA."; diff --git a/plugins/official/graphviz/web-src/src/openidea.d.ts b/plugins/official/graphviz/web-src/src/openidea.d.ts new file mode 100644 index 00000000..3b370f10 --- /dev/null +++ b/plugins/official/graphviz/web-src/src/openidea.d.ts @@ -0,0 +1,40 @@ +/** The IDE's frame bridge (`window.openidea`), injected before the page's scripts. */ +interface OpenideaDocumentState { + version: number; + text: string; +} + +interface OpenideaTextEdit { + from: number; + to: number; + insert: string; +} + +interface OpenideaFileFilter { + name: string; + extensions: string[]; +} + +interface OpenideaBridge { + postMessage(text: string): void; + onMessage(fn: (text: string) => void): void; + readonly theme: { dark: boolean; vars: Readonly> } | null; + onThemeChange(fn: (theme: { dark: boolean; vars: Readonly> }) => void): void; + document: { + /** `name`: the file's name, without its directory. */ + get(): Promise; + onChange(fn: (state: OpenideaDocumentState) => void): void; + edit(version: number, edits: OpenideaTextEdit[]): Promise<{ version: number }>; + replace(version: number, text: string): Promise<{ version: number }>; + }; + saveFile(options: { + name: string; + data: string | Uint8Array | ArrayBuffer | Blob; + filters?: OpenideaFileFilter[]; + }): Promise; + openFile(options?: { filters?: OpenideaFileFilter[] }): Promise<{ name: string; data: Uint8Array } | null>; +} + +interface Window { + openidea?: OpenideaBridge; +} diff --git a/plugins/official/graphviz/web-src/src/render.test.ts b/plugins/official/graphviz/web-src/src/render.test.ts new file mode 100644 index 00000000..fe204b45 --- /dev/null +++ b/plugins/official/graphviz/web-src/src/render.test.ts @@ -0,0 +1,45 @@ +import { instance } from "@viz-js/viz"; +import { describe, expect, it } from "vitest"; +import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions } from "./render"; + +describe("the preview's rendering", () => { + it("renders with the theme's colour as a default the graph can override", async () => { + const viz = await instance(); + const themed = viz.render('digraph { a -> b; c [color="red"] }', renderOptions("dot", "rgb(200, 200, 200)")); + expect(themed.status).toBe("success"); + const svg = themed.status === "success" ? themed.output : ""; + expect(svg).toContain('stroke="#c8c8c8"'); + expect(svg).toContain('stroke="red"'); + const plain = viz.render("digraph { a -> b }", renderOptions("dot", null)); + expect(plain.status === "success" && plain.output).not.toContain("#c8c8c8"); + }); + + it("names the line of a syntax error", async () => { + const viz = await instance(); + const result = viz.render("digraph {\n a -> b\n c -> }\n", renderOptions("dot", null)); + expect(result.status).toBe("failure"); + const [p] = problemsOf(result); + expect(p).toMatchObject({ line: 3, level: "error" }); + expect(p!.message).toMatch(/syntax error/); + }); + + it("offers only engines this Graphviz has", async () => { + const viz = await instance(); + for (const e of ENGINES) expect(viz.engines).toContain(e); + }); + + it("turns the theme's colours into Graphviz's", () => { + expect(graphvizColor("rgb(200, 200, 200)")).toBe("#c8c8c8"); + expect(graphvizColor("rgba(0, 128, 255, 0.5)")).toBe("#0080ff80"); + expect(graphvizColor("rgb(1 2 3 / 50%)")).toBe("#01020380"); + expect(graphvizColor("#ABCDEF")).toBe("#abcdef"); + expect(graphvizColor("var(--x)")).toBeNull(); + expect(renderOptions("dot", "nonsense").nodeAttributes).toBeUndefined(); + }); + + it("names exports after the file", () => { + expect(exportName("flow.dot", "svg")).toBe("flow.svg"); + expect(exportName("Flow.GV", "png")).toBe("Flow.png"); + expect(exportName("", "svg")).toBe("graph.svg"); + }); +}); diff --git a/plugins/official/graphviz/web-src/src/render.ts b/plugins/official/graphviz/web-src/src/render.ts new file mode 100644 index 00000000..a0507acc --- /dev/null +++ b/plugins/official/graphviz/web-src/src/render.ts @@ -0,0 +1,68 @@ +/** + * The preview's logic apart from the page: rendering options, Graphviz's + * messages as problems, export names. Kept free of the DOM so it is tested + * against the real Graphviz in Node. + */ +import type { RenderOptions, RenderResult } from "@viz-js/viz"; + +/** Layout engines offered, the default first. */ +export const ENGINES = ["dot", "neato", "fdp", "sfdp", "circo", "twopi", "osage", "patchwork"] as const; +export type Engine = (typeof ENGINES)[number]; + +export interface Problem { + /** 1-based, when Graphviz names one. */ + line: number | null; + message: string; + level: "error" | "warning"; +} + +/** + * Defaults a graph can still override: transparent background and the IDE's + * text colour, so an unstyled graph reads in a dark theme too. Exports use + * none of them (`null`): they look as Graphviz draws them. + */ +export function renderOptions(engine: Engine, foreground: string | null): RenderOptions { + const options: RenderOptions = { format: "svg", engine }; + const color = foreground && graphvizColor(foreground); + if (color) { + options.graphAttributes = { bgcolor: "transparent", color, fontcolor: color }; + options.nodeAttributes = { color, fontcolor: color }; + options.edgeAttributes = { color, fontcolor: color }; + } + return options; +} + +/** + * A CSS colour as Graphviz reads it (`#rrggbb[aa]`): the theme's variables + * are `rgb()` / `rgba()`, which it doesn't know. `null` when not one. + */ +export function graphvizColor(css: string): string | null { + const hex = (n: number) => + Math.round(Math.min(255, Math.max(0, n))) + .toString(16) + .padStart(2, "0"); + const value = css.trim(); + if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)) return value.toLowerCase(); + const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+)(%?))?\s*\)$/i.exec(value); + if (!m) return null; + const alpha = m[4] === undefined ? 1 : Number(m[4]) / (m[5] ? 100 : 1); + return `#${hex(+m[1]!)}${hex(+m[2]!)}${hex(+m[3]!)}${alpha < 1 ? hex(alpha * 255) : ""}`; +} + +/** What Graphviz reported, as problems (on the line it names, if any). */ +export function problemsOf(result: RenderResult): Problem[] { + return result.errors.map((e) => { + const line = /\bline (\d+)\b/.exec(e.message); + return { + line: line ? Number(line[1]) : null, + message: e.message.replace(/^(Error|Warning):\s*/i, "").trim(), + level: e.level === "warning" ? "warning" : "error", + }; + }); +} + +/** The name an export starts with: the file's, without `.dot` / `.gv`. */ +export function exportName(file: string, extension: string): string { + const base = file.replace(/\.(dot|gv)$/i, "") || "graph"; + return `${base}.${extension}`; +} diff --git a/plugins/official/graphviz/web-src/src/style.css b/plugins/official/graphviz/web-src/src/style.css new file mode 100644 index 00000000..013d14e2 --- /dev/null +++ b/plugins/official/graphviz/web-src/src/style.css @@ -0,0 +1,89 @@ +html, +body { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + font: var(--openidea-font-size, 13px) var(--openidea-font-ui, system-ui, sans-serif); + color: var(--openidea-fg); + background: var(--openidea-bg, transparent); +} +.toolbar { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid var(--openidea-border); + border-radius: 6px; + background: var(--openidea-bg-elevated, var(--openidea-bg)); +} +.toolbar button, +.toolbar select { + height: 24px; + min-width: 24px; + padding: 0 6px; + font: inherit; + font-size: 12px; + color: var(--openidea-fg); + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; +} +.toolbar select { + border-color: var(--openidea-border); + background: var(--openidea-bg-input, transparent); +} +.toolbar button:hover { + background: var(--openidea-bg-hover); +} +.toolbar .sep { + width: 1px; + height: 16px; + margin: 0 2px; + background: var(--openidea-border); +} +#zoom-label { + display: inline-block; + min-width: 36px; + font-variant-numeric: tabular-nums; +} +.stage { + position: absolute; + inset: 0; + overflow: auto; + cursor: grab; +} +.stage.panning { + cursor: grabbing; +} +.canvas { + display: inline-block; + padding: 16px; + user-select: none; +} +.canvas svg { + display: block; +} +.canvas.stale { + opacity: 0.35; +} +.banner { + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + z-index: 1; + padding: 6px 8px; + white-space: pre-wrap; + font-family: var(--openidea-font-mono, ui-monospace, monospace); + font-size: 12px; + color: var(--openidea-danger); + border: 1px solid color-mix(in srgb, var(--openidea-danger) 40%, transparent); + border-radius: 6px; + background: var(--openidea-bg-elevated, var(--openidea-bg)); +} diff --git a/plugins/official/graphviz/web-src/tsconfig.json b/plugins/official/graphviz/web-src/tsconfig.json new file mode 100644 index 00000000..da498883 --- /dev/null +++ b/plugins/official/graphviz/web-src/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/plugins/official/graphviz/web-src/vite.config.ts b/plugins/official/graphviz/web-src/vite.config.ts new file mode 100644 index 00000000..17c7581d --- /dev/null +++ b/plugins/official/graphviz/web-src/vite.config.ts @@ -0,0 +1,18 @@ +/** + * Builds the page into ../web (not checked in: plugins/build.sh builds it). + * Relative URLs throughout: the IDE serves the page from the plugin package + * under its own URL scheme. + */ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + base: "./", + build: { + outDir: path.resolve(import.meta.dirname, "../web"), + emptyOutDir: true, + target: "es2022", + sourcemap: false, + chunkSizeWarningLimit: 4096, + }, +}); diff --git a/templates/theme-plugin/README.md b/plugins/official/harbor-night/README.md similarity index 74% rename from templates/theme-plugin/README.md rename to plugins/official/harbor-night/README.md index 63f9a27a..34eaa5e8 100644 --- a/templates/theme-plugin/README.md +++ b/plugins/official/harbor-night/README.md @@ -1,12 +1,13 @@ -# Theme plugin template +# Harbor Night -A complete OpenIDEA theme plugin with one worked example, **Harbor Night**: a -dark theme with slate surfaces, a teal accent and a warm syntax palette. Copy -this folder to start your own theme. Full documentation, the token reference -and porting guides are in [`docs/plugins/themes.md`](../../docs/plugins/themes.md). +An OpenIDEA theme plugin, **Harbor Night**: a dark theme with slate surfaces, a +teal accent and a warm syntax palette. It is also where new themes start: +`openidea-plugin new --template theme` copies its theme files, or copy this +folder. Full documentation, the token reference and porting guides are in +[`docs/plugins/themes.md`](../../../docs/plugins/themes.md). ``` -theme-plugin/ +harbor-night/ ├─ plugin.toml # manifest: id, name, version, the theme files └─ themes/ ├─ harbor-night.theme.json # UI colours, every themeable token, grouped like tokens.css @@ -17,8 +18,9 @@ A theme plugin is data only: no code, no WebAssembly, no build step. ## Make your own -1. Copy the folder, for example to `~/openidea-themes/theme-my-theme`. -2. In `plugin.toml`, change `id` (lowercase, `a-z 0-9 - _`), `name`, +1. Run `openidea-plugin new --template theme`, or copy the folder, for + example to `~/openidea-themes/my-theme`. +2. In `plugin.toml`, change `id` (lowercase, `a-z 0-9 - _ .`), `name`, `description`, `authors` and the two paths. 3. Rename the theme files. In the theme file, set `id`, `name` and `kind` (`"dark"` or `"light"`, the built-in base for anything you leave out). @@ -49,5 +51,5 @@ JSON or values are reported in Settings › Plugins with the file and line. ## Licence -The template and Harbor Night are part of OpenIDEA (Apache-2.0). When you port +Harbor Night is part of OpenIDEA (Apache-2.0). When you port someone else's theme, keep its licence and credit its authors. diff --git a/plugins/official/harbor-night/plugin.toml b/plugins/official/harbor-night/plugin.toml new file mode 100644 index 00000000..1fdc1d7e --- /dev/null +++ b/plugins/official/harbor-night/plugin.toml @@ -0,0 +1,16 @@ +# A theme plugin is data only: no `wasm`, no capabilities, nothing to compile. +# `openidea-plugin new --template theme` starts a new theme from this one. +id = "openidea.harbor-night" +name = "Harbor Night" +version = "0.1.0" +description = "A dark theme: slate surfaces, a teal accent and a warm syntax palette." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "1.0.0 — first release." +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). +[[contributes.themes]] +path = "themes/harbor-night.theme.json" +editor-scheme = "themes/harbor-night.scheme.json" diff --git a/templates/theme-plugin/themes/harbor-night.scheme.json b/plugins/official/harbor-night/themes/harbor-night.scheme.json similarity index 100% rename from templates/theme-plugin/themes/harbor-night.scheme.json rename to plugins/official/harbor-night/themes/harbor-night.scheme.json diff --git a/templates/theme-plugin/themes/harbor-night.theme.json b/plugins/official/harbor-night/themes/harbor-night.theme.json similarity index 100% rename from templates/theme-plugin/themes/harbor-night.theme.json rename to plugins/official/harbor-night/themes/harbor-night.theme.json diff --git a/plugins/official/key-promoter/.gitignore b/plugins/official/key-promoter/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /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 00000000..089dc610 --- /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 00000000..9ecb09a7 --- /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..daefc884d5dfdb76569c03d572061b0576f6b763 GIT binary patch literal 98959 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

4yJ-r8~ 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 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, Error> { + 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::>().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(Error::NotFound(format!("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.is_multiple_of(REPEAT_EVERY) { + 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_eq!( + KeyPromoter.run_command("statistics", &host.invocation(), None).unwrap_err(), + Error::Denied("storage".into()) + ); + } +} diff --git a/plugins/official/license-header/Cargo.lock b/plugins/official/license-header/Cargo.lock new file mode 100644 index 00000000..736eea2b --- /dev/null +++ b/plugins/official/license-header/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-license-header" +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/license-header/Cargo.toml b/plugins/official/license-header/Cargo.toml new file mode 100644 index 00000000..efac88b0 --- /dev/null +++ b/plugins/official/license-header/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-license-header" +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/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..6cd6d293da9996636da9bac7c9c13b7c43cd0c6f GIT binary patch literal 143721 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

+ diff --git a/plugins/official/solidity/.gitignore b/plugins/official/solidity/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /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 00000000..d2be61fe --- /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 00000000..697f1b60 --- /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..3cb17fd7ce387e314537ba999335fee7cb97f89d GIT binary patch literal 107192 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 Oeir|tkvwD1)&C#Ha+;_B literal 0 HcmV?d00001 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources new file mode 100644 index 00000000..d7a74f4c --- /dev/null +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -0,0 +1 @@ +3893bd8ad06d3e19c0112081c6643b3bf3c67f5a184098432a6fd338426cd682 diff --git a/plugins/official/solidity/languages/solidity.toml b/plugins/official/solidity/languages/solidity.toml new file mode 100644 index 00000000..27c519dd --- /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 00000000..cc03892b --- /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 00000000..def94bee --- /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::{Error, 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, Error> { + Err(Error::NotFound(format!("command {id}"))) + } +} + +impl SyntaxProvider for Solidity { + fn highlights(&mut self, _language: &str, text: &str) -> Vec { + highlights(text) + } + + fn outline(&mut self, _language: &str, text: &str) -> Vec { + outline(text) + } + + fn folding(&mut self, _language: &str, text: &str) -> Vec { + 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 { + 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 { + 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, + 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 { + let code: Vec = 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 = 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, 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).is_empty() => { + 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 { + let lexemes = lex(text); + let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32; + let skip: Vec> = 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| { + 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) -> 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 00000000..ea8c4bf7 --- /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 00000000..8f0807fe --- /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 00000000..559b812e --- /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..1c33e3e439b141848c4fc7052470cc046a55640f GIT binary patch literal 164796 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 literal 0 HcmV?d00001 diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources new file mode 100644 index 00000000..2dbf488f --- /dev/null +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -0,0 +1 @@ +a88c39d5782369329efa55b12330daa906e5c8d91252bd57c7343648840bac79 diff --git a/plugins/official/string-tools/plugin.toml b/plugins/official/string-tools/plugin.toml new file mode 100644 index 00000000..18ff2bb5 --- /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 00000000..805b78eb --- /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, Error> { + let transform = transform(id).ok_or_else(|| Error::NotFound(format!("command {id}")))?; + let editor = ctx.editor().ok_or("String Tools works on the focused editor")?; + apply(&editor, transform, lines_command(id))?; + 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 { + 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::>().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 { + 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 { + let chars: Vec = 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::>().join("_"), + Style::ScreamingSnake => w.iter().map(|x| x.to_uppercase()).collect::>().join("_"), + Style::Kebab => w.iter().map(|x| x.to_lowercase()).collect::>().join("-"), + Style::Dot => w.iter().map(|x| x.to_lowercase()).collect::>().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> { + 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 = 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::() { + 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> { + let clean: Vec = 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(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; &"), " 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_eq!(err, Error::Denied("editor.write".into())); + assert_eq!(host.editor_text(), "abc"); + } +} diff --git a/scripts/theme/theme.test.ts b/scripts/theme/theme.test.ts index 8224b5c4..bf533329 100644 --- a/scripts/theme/theme.test.ts +++ b/scripts/theme/theme.test.ts @@ -14,7 +14,8 @@ const read = (p: string) => readFileSync(p, "utf8"); const css = read("src/styles/tokens.css"); const groups = themeTokenGroups(css); const sets = tokenSets(groups); -const TEMPLATE = "templates/theme-plugin"; +// The official Harbor Night theme, where new themes start (`openidea-plugin new --template theme`). +const TEMPLATE = "plugins/official/harbor-night"; const themeSrc = read(`${TEMPLATE}/themes/harbor-night.theme.json`); const schemeSrc = read(`${TEMPLATE}/themes/harbor-night.scheme.json`); const theme = JSON.parse(stripJsonComments(themeSrc)) as { id: string; kind: string; tokens: Record }; @@ -47,7 +48,7 @@ describe("theme token reference", () => { describe("theme plugin template", () => { it("sets every UI token in the theme file and every editor token in the scheme file", () => { // A failure here means tokens.css gained (or lost) a themeable token: - // add it to templates/theme-plugin/themes/*.json, then run pnpm gen:theme-docs. + // add it to plugins/official/harbor-night/themes/*.json, then run pnpm gen:theme-docs. expect(sorted(Object.keys(theme.tokens))).toEqual(sorted(sets.ui)); expect(sorted(Object.keys(scheme.tokens))).toEqual(sorted(sets.scheme)); }); diff --git a/sdk/rust/openidea-plugin/Cargo.lock b/sdk/rust/openidea-plugin/Cargo.lock new file mode 100644 index 00000000..9629fb20 --- /dev/null +++ b/sdk/rust/openidea-plugin/Cargo.lock @@ -0,0 +1,327 @@ +# 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", + "wit-parser", +] + +[[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 00000000..3f36703f --- /dev/null +++ b/sdk/rust/openidea-plugin/Cargo.toml @@ -0,0 +1,20 @@ +[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" + +[dev-dependencies] +# Reads the WIT in tests/wit_coverage.rs (the version wit-bindgen already uses). +wit-parser = "0.262" diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs new file mode 100644 index 00000000..187c5f8b --- /dev/null +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -0,0 +1,560 @@ +//! 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 storage_get(key: &str) -> Result, 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, Error> { + wit::storage::keys(prefix).map_err(error) + } + + pub fn projects() -> Result, Error> { + wit::workspace::projects().map_err(error) + } + + pub fn project_root(p: &ProjectRes) -> Result { + p.root().map_err(error) + } + + pub fn project_read(p: &ProjectRes, path: &str) -> Result { + p.read_file(path).map_err(error) + } + + pub fn invocation_project(i: InvocationRef<'_>) -> Option { + i.project() + } + + pub fn invocation_editor(i: InvocationRef<'_>) -> Option { + 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 { + e.document().map(|d| Document { version: d.version, text: d.text }).map_err(error) + } + + pub fn editor_selections(e: &EditorRes) -> Result, 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 { + let edits: Vec = + 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, 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, 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, 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 = 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 { + 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 { + wit::settings::get(key) + } + + pub fn run_process( + program: &str, + args: &[String], + cwd: Option<&ProjectRes>, + stdin: Option<&str>, + ) -> Result { + 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 { + imp::invocation_project(self.inner).map(|inner| Project { inner }) + } + + /// The window's focused editor. + pub fn editor(&self) -> Option { + 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 { + 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 { + imp::project_read(&self.inner, path) + } + + /// The repository containing the project (`None`: not under git). Needs `vcs.read`. + pub fn vcs_status(&self) -> Result, 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, Error> { + imp::project_find(&self.inner, glob, limit) + } + + /// Runs the approved `process:` in this project's root. + pub fn run_process(&self, program: &str, args: &[String], stdin: Option<&str>) -> Result { + 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:` (plain http only to +/// localhost). `{{secret:}}` in a header is filled in by the IDE (needs +/// `secrets`). `Error::Cancelled` when the call was superseded. +pub fn fetch(request: &HttpRequest) -> Result { + 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:` 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 { + imp::setting(key) +} + +/// A boolean setting. +pub fn setting_bool(key: &str) -> Option { + setting(key).and_then(|v| v.parse().ok()) +} + +/// A string setting. +pub fn setting_string(key: &str) -> Option { + 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 { + setting(key).and_then(|v| v.parse().ok()) +} + +/// Runs the approved `process:` 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 { + imp::run_process(program, args, None, stdin) +} + +/// Projects open in any window. Needs `project.read`. +pub fn projects() -> Result, 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 { + imp::editor_document(&self.inner) + } + + /// Selections when the command was invoked. Needs `editor.read`. + pub fn selections(&self) -> Result, 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 { + 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 { + 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, 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) +} + +/// 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, 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, 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 00000000..f59d728c --- /dev/null +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -0,0 +1,637 @@ +//! SDK for OpenIDEA plugins written in Rust (plugin API 1.0). +//! +//! ```ignore +//! use openidea_plugin::{Error, 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, Error> { +//! match id { +//! "shout" => { +//! let editor = ctx.editor().ok_or("no editor is focused")?; +//! editor.transform_selections(|s| s.to_uppercase())?; +//! Ok(None) +//! } +//! _ => Err(Error::NotFound(format!("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: subscribe to events and + /// restore state from [`host::storage`]. An error fails the activation. + fn activate(&mut self, _ctx: &Context) -> Result<(), Error> { + 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. + /// A plain message (`Err("…".into())`, or `?` on a `String` error) is + /// [`Error::Failed`]; return [`Error::Cancelled`] when the user gave up. + fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result, Error>; + + /// 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<(), $crate::bindings::openidea::plugin::types::Error> { + $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>, + $crate::bindings::openidea::plugin::types::Error, + > { + $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>> = const { RefCell::new(None) }; + } + + fn with(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::().expect("one plugin type per component")) + }) + } + + pub fn activate(ctx: b::types::Context) -> Result<(), b::types::Error> { + let ctx = Context { + plugin_id: ctx.plugin_id, + plugin_version: ctx.plugin_version, + api_version: ctx.api_version, + capabilities: ctx.capabilities, + }; + with::(|p| p.activate(&ctx)).map_err(error_out) + } + + pub fn deactivate() { + with::(|p| p.deactivate()); + INSTANCE.with(|cell| cell.borrow_mut().take()); + } + + pub fn run_command( + id: String, + ctx: &b::invocation::Invocation, + arg: Option, + ) -> Result, b::types::Error> { + let ctx = Invocation::__new(ctx); + with::(|p| p.run_command(&id, &ctx, arg.as_deref())).map_err(error_out) + } + + // ---- 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( + d: w::Document, + pos: b::types::Position, + trigger: Option, + ) -> Vec { + let d = doc(d); + with::(|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| k.as_str().to_owned()), + 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( + d: w::Document, + pos: b::types::Position, + ) -> Option { + let d = doc(d); + with::(|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( + d: w::Document, + sel: b::types::Range, + ) -> Vec { + let d = doc(d); + with::(|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( + d: w::Document, + pos: b::types::Position, + ) -> Vec { + let d = doc(d); + with::(|x| x.definition(&d, at(pos))).into_iter().map(loc).collect() + } + + pub fn line_markers(d: w::Document) -> Vec { + let d = doc(d); + with::(|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( + view: String, + viewport: Option, + ctx: &b::invocation::Invocation, + ) -> Result { + let ctx = Invocation::__new(ctx); + let viewport = viewport.map(|v| v::Viewport { first_row: v.first_row, rows: v.rows }); + with::(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out) + } + + pub fn view_event( + 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::(|p| p.handle(&view, event, &ctx)).map_err(error_out) + } + + pub fn handle_events(events: Vec, ctx: &b::invocation::Invocation) { + let events: Vec = events.into_iter().map(crate::host::__event).collect(); + let ctx = Invocation::__new(ctx); + with::(|p| p.handle_events(&events, &ctx)) + } + + fn offset(n: usize) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) + } + + pub fn highlights(language: &str, text: &str) -> Vec { + with::(|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(language: &str, text: &str) -> Vec { + with::(|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(language: &str, text: &str) -> Vec { + with::(|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 00000000..33ce121d --- /dev/null +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -0,0 +1,732 @@ +//! 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, +} + +/// 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, Option)>, + pub cancelled: bool, + pub ended: bool, +} + +type NetHandler = Box Result>; +type ProcessHandler = Box) -> Result>; + +/// A process run the plugin made. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProcessRun { + pub program: String, + pub args: Vec, + /// The project root it ran in (`None`: the data directory). + pub cwd: Option, +} + +struct Editor { + path: String, + language: String, + text: String, + version: u64, + selections: Vec, +} + +#[derive(Default)] +struct State { + capabilities: Vec, + root: Option, + other_projects: Vec, + files: BTreeMap, + editor: Option, + notifications: Vec, + logs: Vec<(LogLevel, String)>, + status: BTreeMap, + storage: BTreeMap, + subscriptions: BTreeSet, + tasks: Vec, + vcs: Option, + fetches: u32, + fetch_error: Option, + diagnostics: BTreeMap>, + secrets: BTreeMap, + net: Option, + requests: Vec, + processes: BTreeMap, + runs: Vec, + refreshed: Vec, + view_messages: Vec<(String, String)>, + terminals: Vec, + settings: BTreeMap, +} + +/// A terminal the plugin opened. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MockTerminal { + pub title: String, + pub program: String, + pub args: Vec, +} + +thread_local! { + static CURRENT: RefCell>>> = const { RefCell::new(None) }; +} + +/// Handle to the mock installed for this thread. Dropping it uninstalls it. +pub struct MockHost { + state: Rc>, +} + +/// 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 { + self.state.borrow().notifications.clone() + } + + pub fn logs(&self) -> Vec<(LogLevel, String)> { + self.state.borrow().logs.clone() + } + + pub fn status_items(&self) -> BTreeMap { + self.state.borrow().status.clone() + } + + pub fn storage(&self) -> BTreeMap { + self.state.borrow().storage.clone() + } + + pub fn subscriptions(&self) -> BTreeSet { + self.state.borrow().subscriptions.clone() + } + + pub fn tasks(&self) -> Vec { + 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) { + self.state.borrow_mut().vcs = status; + } + + /// Makes `vcs-fetch` fail. + pub fn fail_fetch(&self, error: Option) { + 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> { + self.state.borrow().diagnostics.clone() + } + + /// Views the plugin asked to refresh, in order. + pub fn refreshed_views(&self) -> Vec { + 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 { + 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 + 'static) { + self.state.borrow_mut().net = Some(Box::new(handler)); + } + + /// Requests the plugin made, as sent (secrets filled in). + pub fn requests(&self) -> Vec { + self.state.borrow().requests.clone() + } + + /// What running `process:` does: `(args, stdin) → output`. + pub fn on_process( + &self, + program: &str, + handler: impl Fn(&[String], Option<&str>) -> Result + 'static, + ) { + self.state.borrow_mut().processes.insert(program.into(), Box::new(handler)); + } + + pub fn process_runs(&self) -> Vec { + 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(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 storage_get(key: &str) -> Result, 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, Error> { + with(|s| { + require(s, "storage")?; + Ok(s.storage.keys().filter(|k| k.starts_with(prefix)).cloned().collect()) + }) + } + + pub fn projects() -> Result, 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 { + with(|s| { + require(s, "project.read")?; + Ok(p.root.clone()) + }) + } + + pub fn project_read(p: &ProjectRes, path: &str) -> Result { + 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 { + with(|s| s.root.clone().map(|root| MockProject { root })) + } + + pub fn invocation_editor(_: InvocationRef<'_>) -> Option { + with(|s| s.editor.as_ref().map(|_| MockEditor)) + } + + fn editor(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 { + editor(|s, e| { + require(s, "editor.read")?; + Ok(Document { version: e.version, text: e.text.clone() }) + }) + } + + pub fn editor_selections(_: &EditorRes) -> Result, Error> { + editor(|s, e| { + require(s, "editor.read")?; + Ok(e.selections.clone()) + }) + } + + pub fn editor_apply(_: &EditorRes, version: u64, edits: &[TextEdit]) -> Result { + 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, 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, Error> { + with(|s| { + require(s, "project.read")?; + let root = p.root.trim_end_matches('/'); + let mut out: Vec = 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 { + let rest = url.split_once("://")?.1; + let host = rest.split(['/', ':', '?']).next()?; + Some(host.to_ascii_lowercase()) + } + + pub fn fetch(req: &HttpRequest) -> Result { + 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 { + with(|s| s.settings.get(key).cloned()) + } + + pub fn run_process( + program: &str, + args: &[String], + cwd: Option<&ProjectRes>, + stdin: Option<&str>, + ) -> Result { + 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, 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"); + assert_eq!(mock.notifications()[0].title, "t"); + 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", ""); + 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 00000000..c10f75cf --- /dev/null +++ b/sdk/rust/openidea-plugin/src/providers.rs @@ -0,0 +1,167 @@ +//! 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, +} + +/// Icon and sort group of a completion item. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CompletionKind { + Text, + Method, + Function, + Constructor, + Field, + Variable, + Class, + Interface, + Module, + Property, + Unit, + Value, + Enum, + Keyword, + Snippet, + Color, + File, + Reference, + Folder, + EnumMember, + Constant, + Struct, + Event, + Operator, + TypeParameter, +} + +impl CompletionKind { + /// The name the plugin API uses (`completion-item.kind`). + pub fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Method => "method", + Self::Function => "function", + Self::Constructor => "constructor", + Self::Field => "field", + Self::Variable => "variable", + Self::Class => "class", + Self::Interface => "interface", + Self::Module => "module", + Self::Property => "property", + Self::Unit => "unit", + Self::Value => "value", + Self::Enum => "enum", + Self::Keyword => "keyword", + Self::Snippet => "snippet", + Self::Color => "color", + Self::File => "file", + Self::Reference => "reference", + Self::Folder => "folder", + Self::EnumMember => "enum-member", + Self::Constant => "constant", + Self::Struct => "struct", + Self::Event => "event", + Self::Operator => "operator", + Self::TypeParameter => "type-parameter", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CompletionItem { + pub label: String, + pub detail: Option, + pub kind: Option, + pub insert_text: String, + /// Replaced on accept; `None`: the word at the caret. + pub replace: Option, + pub sort_text: Option, + /// Markdown. + pub documentation: Option, +} + +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, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CodeAction { + pub title: String, + /// `quickfix`, `refactor`, … + pub kind: Option, + pub preferred: bool, + /// (absolute path, edits) per file. + pub edits: Vec<(String, Vec)>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineMarker { + /// 0-based. + pub line: u32, + /// `navigate` (default), `implemented`, `implementing`, `run`. + pub icon: Option, + pub tooltip: String, + /// Opened on click. + pub target: Option, +} + +/// 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 { + Vec::new() + } + + fn inline_completion(&mut self, _doc: &Document, _at: Position) -> Option { + None + } + + fn code_actions(&mut self, _doc: &Document, _selection: Range) -> Vec { + Vec::new() + } + + fn definition(&mut self, _doc: &Document, _at: Position) -> Vec { + Vec::new() + } + + fn line_markers(&mut self, _doc: &Document) -> Vec { + 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 00000000..74e860a9 --- /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, + 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, + pub kind: SymbolKind, + /// The whole declaration. + pub range: Range, + /// The name; where navigation lands. + pub name_range: Range, + pub parent: Option, +} + +#[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; + + fn outline(&mut self, _language: &str, _text: &str) -> Vec { + Vec::new() + } + + fn folding(&mut self, _language: &str, _text: &str) -> Vec { + 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]) -> Vec { + let mut folds = Vec::new(); + let mut stack: Vec = 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'}', std::slice::from_ref(&(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 00000000..7f34e968 --- /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 { + 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 } +} + +/// 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 +} + +#[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); + } +} diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs new file mode 100644 index 00000000..5a74f67d --- /dev/null +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -0,0 +1,278 @@ +//! 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 for String { + fn from(e: Error) -> Self { + e.to_string() + } +} + +/// A plain message is [`Error::Failed`]. +impl From for Error { + fn from(message: String) -> Self { + Self::Failed(message) + } +} + +impl From<&str> for Error { + fn from(message: &str) -> Self { + Self::Failed(message.to_owned()) + } +} + +/// 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, +} + +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, + /// One of the plugin's commands, run on click. + pub command: Option, +} + +#[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, +} + +/// A workbench event (see [`crate::host::subscribe`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + FileOpened(String), + FileSaved(String), + EditorFocused(Option), + 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, + pub commit: Option, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + /// Merge / rebase / cherry-pick in progress. + pub operation: Option, + 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, +} + +/// An HTTP request (see [`crate::host::fetch`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HttpRequest { + pub method: String, + pub url: String, + /// `{{secret:}}` in a value is filled in by the IDE. + pub headers: Vec<(String, String)>, + pub body: Option>, + pub timeout_ms: Option, +} + +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>) -> 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, +} + +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, + 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 00000000..3da8e506 --- /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, + 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, + pub label: String, + pub icon: Option, + pub detail: Option, + /// 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, + pub detail: Option, +} + +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), +} + +#[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, + pub total_rows: u32, + pub first_row: u32, + pub rows: Vec>, + pub editable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Body { + Message(String), + Tree(Vec), + List(Vec), + Form(Vec), + 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

peZhV{o0Pq}4z|gIeGxU+E;EQV_ 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 literal 0 HcmV?d00001 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources new file mode 100644 index 00000000..01859b4b --- /dev/null +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -0,0 +1 @@ +6a4210a3978f058b3526427eb41ef398924e8c011cc4dc53c19f5419d31501f3 diff --git a/plugins/official/license-header/plugin.toml b/plugins/official/license-header/plugin.toml new file mode 100644 index 00000000..af970e2e --- /dev/null +++ b/plugins/official/license-header/plugin.toml @@ -0,0 +1,35 @@ +id = "openidea.license-header" +name = "License Header" +version = "0.1.0" +description = "Keeps a copyright and SPDX license header at the top of source files: files without one are reported when they open, a quick fix adds it, and a project scan lists every file that still needs one." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +change-notes = "0.1.0 — first release: missing headers reported on open and save, Add License Header quick fix and command, project scan in the background." +engines.openidea = "^1.0" +wasm = "dist/plugin.wasm" +activation = ["on-event:file-opened", "on-event:file-saved", "on-command:*"] + +[capabilities] +required = ["events", "project.read", "editor.read", "editor.write"] + +[[contributes.providers]] +kinds = ["code-actions"] +languages = ["*"] + +[[contributes.commands]] +id = "add" +title = "License Header: Add to Current File" +category = "License Header" + +[[contributes.commands]] +id = "scan" +title = "License Header: Find Files Without a Header" +category = "License Header" + +[[contributes.menus]] +menu = "tools" +command = "scan" + +[contributes.settings] +title = "License Header" +schema = "settings.schema.json" diff --git a/plugins/official/license-header/settings.schema.json b/plugins/official/license-header/settings.schema.json new file mode 100644 index 00000000..83600dfb --- /dev/null +++ b/plugins/official/license-header/settings.schema.json @@ -0,0 +1,21 @@ +{ + "properties": { + "owner": { + "type": "string", + "title": "Copyright holder", + "description": "Written after the year, e.g. \"ACME Inc.\"; empty for none.", + "default": "" + }, + "license": { + "type": "string", + "title": "License (SPDX identifier)", + "description": "e.g. Apache-2.0, MIT, GPL-3.0-or-later; empty for no SPDX line.", + "default": "Apache-2.0" + }, + "reportOnOpen": { + "type": "boolean", + "title": "Report files without a header when they open", + "default": true + } + } +} diff --git a/plugins/official/license-header/src/lib.rs b/plugins/official/license-header/src/lib.rs new file mode 100644 index 00000000..7fd5d789 --- /dev/null +++ b/plugins/official/license-header/src/lib.rs @@ -0,0 +1,395 @@ +//! License Header for OpenIDEA: a copyright and SPDX license header at the top +//! of source files, in the file's own comment syntax. +//! +//! - A file without one is reported (an info diagnostic on its first line) +//! when it opens, and again when it is saved, so adding the header clears it. +//! - The quick fix and *License Header: Add to Current File* insert it (after a +//! `#!` line or a Python encoding line, which must stay first). +//! - *Find Files Without a Header* scans the project in a background task and +//! reports every source file that needs one in the Problems view. + +use openidea_plugin::host; +use openidea_plugin::providers::{CodeAction, Document, Providers}; +use openidea_plugin::{ + Diagnostic, Error, Event, Invocation, NotificationLevel, Plugin, Position, Project, Range, Severity, TextEdit, + register_plugin, +}; + +/// Lines looked at for an existing header. +const HEADER_LINES: usize = 15; +/// Files a scan reads at most. +const MAX_SCAN: u32 = 5000; +/// Files larger than this are skipped by a scan. +const MAX_FILE: usize = 1 << 20; +const CODE: &str = "missing-license-header"; + +/// How a file writes a header: line comments, or one block comment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Comment { + Line(&'static str), + Block(&'static str, &'static str), +} + +/// The comment syntax for `path`, `None` for files that don't get a header. +pub fn comment_for(path: &str) -> Option { + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + let lower = name.to_ascii_lowercase(); + if matches!(lower.as_str(), "dockerfile" | "makefile" | "justfile") { + return Some(Comment::Line("#")); + } + let ext = lower.rsplit_once('.').map(|(_, e)| e)?; + Some(match ext { + "rs" | "java" | "kt" | "kts" | "scala" | "groovy" | "gradle" | "go" | "js" | "jsx" | "mjs" | "cjs" | "ts" + | "tsx" | "mts" | "cts" | "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "m" | "mm" | "cs" | "swift" + | "dart" | "proto" | "sol" | "zig" | "v" => Comment::Line("//"), + "css" | "scss" | "less" => Comment::Block("/*", " */"), + "py" | "sh" | "bash" | "zsh" | "fish" | "rb" | "pl" | "r" | "ps1" | "yaml" | "yml" | "toml" | "cmake" + | "tf" | "nix" | "ex" | "exs" | "jl" => Comment::Line("#"), + "sql" | "lua" | "hs" | "elm" => Comment::Line("--"), + _ => return None, + }) +} + +/// Whether `text` already starts with a header (copyright or license notice). +pub fn has_header(text: &str) -> bool { + text.lines().take(HEADER_LINES).any(|l| { + let l = l.to_ascii_lowercase(); + l.contains("copyright") || l.contains("spdx-license-identifier") || l.contains("licensed under") + }) +} + +/// The header's lines, without comment markers. +pub fn header_lines(year: i64, owner: &str, license: &str) -> Vec { + let owner = owner.trim(); + let mut lines = + vec![if owner.is_empty() { format!("Copyright (c) {year}") } else { format!("Copyright (c) {year} {owner}") }]; + if !license.trim().is_empty() { + lines.push(format!("SPDX-License-Identifier: {}", license.trim())); + } + lines +} + +/// The header as `comment` writes it, with a blank line after it. +pub fn render(comment: Comment, lines: &[String]) -> String { + let mut out = String::new(); + match comment { + Comment::Line(prefix) => { + for l in lines { + out.push_str(&format!("{prefix} {l}\n")); + } + } + Comment::Block(open, close) => { + out.push_str(open); + out.push('\n'); + for l in lines { + out.push_str(&format!(" * {l}\n")); + } + out.push_str(close); + out.push('\n'); + } + } + out.push('\n'); + out +} + +/// The line the header goes on: after a `#!` line and a Python encoding line. +pub fn insert_line(text: &str) -> u32 { + let mut line = 0; + let mut lines = text.lines(); + if lines.next().is_some_and(|l| l.starts_with("#!")) { + line = 1; + if lines.next().is_some_and(is_encoding_line) { + line = 2; + } + } else if text.lines().next().is_some_and(is_encoding_line) { + line = 1; + } + line +} + +fn is_encoding_line(l: &str) -> bool { + l.starts_with('#') && (l.contains("coding:") || l.contains("coding=")) +} + +/// The edit that adds the header to `path`'s `text`, `None` when it has one or takes none. +pub fn header_edit(path: &str, text: &str, year: i64, owner: &str, license: &str) -> Option { + let comment = comment_for(path)?; + if has_header(text) { + return None; + } + let at = Position::new(insert_line(text), 0); + let mut insert = render(comment, &header_lines(year, owner, license)); + // After a first line without a newline (a lone shebang). + if at.line > 0 && text.lines().count() == at.line as usize && !text.ends_with('\n') { + insert.insert(0, '\n'); + } + Some(TextEdit { range: Range::new(at, at), text: insert }) +} + +fn missing(text: &str) -> Diagnostic { + let line = insert_line(text); + let len = text.lines().nth(line as usize).map_or(0, |l| l.encode_utf16().count() as u32); + Diagnostic { + range: Range::new(Position::new(line, 0), Position::new(line, len)), + severity: Severity::Info, + message: "No license header".into(), + code: Some(CODE.into()), + } +} + +/// Reports `path` when it takes a header and has none, clears it otherwise. +pub fn report(path: &str, text: &str) -> Result { + let lacking = comment_for(path).is_some() && !has_header(text); + host::publish_diagnostics(path, &if lacking { vec![missing(text)] } else { Vec::new() })?; + Ok(lacking) +} + +/// The current year (WASI wall clock), from days since the epoch. +fn this_year() -> i64 { + let secs = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + year_of_day(secs.div_euclid(86_400)) +} + +/// The proleptic Gregorian year of `day` days since 1970-01-01. +pub fn year_of_day(day: i64) -> i64 { + let z = day + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + yoe + era * 400 + i64::from(month <= 2) +} + +fn settings() -> (String, String) { + ( + host::setting_string("owner").unwrap_or_default(), + host::setting_string("license").unwrap_or_else(|| "Apache-2.0".into()), + ) +} + +fn project_of(path: &str) -> Option { + 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 LicenseHeader; + +impl LicenseHeader { + fn add_to_editor(&self, ctx: &Invocation) -> Result, Error> { + let editor = ctx.editor().ok_or_else(|| Error::Invalid("focus an editor first".into()))?; + let (path, doc) = (editor.path(), editor.document()?); + if comment_for(&path).is_none() { + return Err(Error::Invalid("this kind of file takes no license header".into())); + } + let (owner, license) = settings(); + let Some(edit) = header_edit(&path, &doc.text, this_year(), &owner, &license) else { + return Ok(Some("The file already has a license header.".into())); + }; + editor.apply_edits(doc.version, &[edit])?; + Ok(None) + } + + /// Reports every source file of the project without a header; returns (checked, lacking). + fn scan(&self, project: &Project) -> Result<(u32, u32), Error> { + let files: Vec = project + .find_files("**/*", MAX_SCAN * 4)? + .into_iter() + .filter(|f| comment_for(f).is_some()) + .take(MAX_SCAN as usize) + .collect(); + let total = files.len() as u32; + let task = host::begin_task("License Header: checking files", true); + let (mut checked, mut lacking) = (0, 0); + for (i, path) in files.iter().enumerate() { + if task.is_cancelled() { + break; + } + if i % 20 == 0 { + let name = path.rsplit('/').next().unwrap_or(path); + task.report(i as u32, Some(total), Some(name)); + } + let Ok(text) = project.read_file(path) else { continue }; + if text.len() > MAX_FILE { + continue; + } + checked += 1; + if report(path, &text)? { + lacking += 1; + } + } + Ok((checked, lacking)) + } +} + +impl Plugin for LicenseHeader { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + match id { + "add" => self.add_to_editor(ctx), + "scan" => { + let project = ctx.project().ok_or_else(|| Error::Invalid("open a project first".into()))?; + let (checked, lacking) = self.scan(&project)?; + let body = match lacking { + 0 => format!("All {checked} source files have one."), + 1 => format!("1 of {checked} source files has none; see the Problems view."), + n => format!("{n} of {checked} source files have none; see the Problems view."), + }; + host::notify(NotificationLevel::Info, "License headers", &body); + Ok(None) + } + _ => Err(Error::NotFound(format!("command {id}"))), + } + } + + fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) { + for e in events { + let (path, opened) = match e { + Event::FileOpened(p) => (p, true), + Event::FileSaved(p) => (p, false), + _ => continue, + }; + if comment_for(path).is_none() || (opened && !host::setting_bool("reportOnOpen").unwrap_or(true)) { + continue; + } + let Some(project) = project_of(path) else { continue }; + if let Ok(text) = project.read_file(path) { + let _ = report(path, &text); + } + } + } +} + +impl Providers for LicenseHeader { + fn code_actions(&mut self, doc: &Document, _selection: Range) -> Vec { + let (owner, license) = settings(); + let Some(edit) = header_edit(&doc.path, &doc.text, this_year(), &owner, &license) else { return Vec::new() }; + vec![CodeAction { + title: "Add license header".into(), + kind: Some("quickfix".into()), + preferred: true, + edits: vec![(doc.path.clone(), vec![edit])], + }] + } +} + +register_plugin!(LicenseHeader, providers); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + fn doc(path: &str, text: &str) -> Document { + Document { path: path.into(), language: String::new(), version: 1, text: text.into() } + } + + #[test] + fn writes_the_header_in_each_files_comment_syntax() { + let lines = header_lines(2026, "ACME Inc.", "MIT"); + assert_eq!( + render(comment_for("a/b.rs").unwrap(), &lines), + "// Copyright (c) 2026 ACME Inc.\n// SPDX-License-Identifier: MIT\n\n" + ); + assert_eq!(render(comment_for("x.py").unwrap(), &lines[..1]), "# Copyright (c) 2026 ACME Inc.\n\n"); + assert_eq!( + render(comment_for("s.css").unwrap(), &lines), + "/*\n * Copyright (c) 2026 ACME Inc.\n * SPDX-License-Identifier: MIT\n */\n\n" + ); + assert_eq!(comment_for("q.SQL"), Some(Comment::Line("--"))); + assert_eq!(comment_for("Dockerfile"), Some(Comment::Line("#"))); + assert_eq!(comment_for("README.md"), None); + assert_eq!(comment_for("noext"), None); + assert_eq!(header_lines(2026, " ", ""), ["Copyright (c) 2026"]); + } + + #[test] + fn keeps_shebang_and_encoding_lines_first() { + assert_eq!(insert_line("fn main() {}\n"), 0); + assert_eq!(insert_line("#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint()\n"), 2); + assert_eq!(insert_line("#!/bin/sh\necho\n"), 1); + assert_eq!(insert_line("# coding=latin-1\nx = 1\n"), 1); + let e = header_edit("run.sh", "#!/bin/sh", 2026, "", "").unwrap(); + assert_eq!((e.range.start, e.text.as_str()), (Position::new(1, 0), "\n# Copyright (c) 2026\n\n")); + } + + #[test] + fn an_existing_header_is_left_alone() { + assert!(has_header("// SPDX-License-Identifier: Apache-2.0\nfn x() {}")); + assert!(has_header("/*\n * Licensed under the MIT license\n */")); + assert!(has_header(&format!("{}// Copyright 2020\n", "\n".repeat(10)))); + assert!(!has_header(&format!("{}// Copyright 2020\n", "\n".repeat(20))), "only the top of the file counts"); + assert!(header_edit("a.rs", "// Copyright (c) 2020 X\n", 2026, "", "").is_none()); + } + + #[test] + fn years() { + assert_eq!(year_of_day(0), 1970); + assert_eq!(year_of_day(19_722), 2023); // 2023-12-31 + assert_eq!(year_of_day(19_723), 2024); // 2024-01-01 + assert_eq!(year_of_day(20_759), 2026); + } + + #[test] + fn reports_on_open_and_save_and_fixes() { + let host = MockHost::install().with_capabilities(&["events", "project.read", "editor.read", "editor.write"]); + host.add_file("src/a.rs", "fn a() {}\n"); + host.add_file("src/b.rs", "// Copyright (c) 2020 X\nfn b() {}\n"); + let mut p = LicenseHeader; + p.handle_events( + &[Event::FileOpened("/project/src/a.rs".into()), Event::FileOpened("/project/src/b.rs".into())], + &host.invocation(), + ); + let d = host.diagnostics(); + assert_eq!(d["/project/src/a.rs"][0].code.as_deref(), Some(CODE)); + assert!(d.get("/project/src/b.rs").is_none_or(Vec::is_empty)); + + // Off when opening, still on saving. + host.set_setting("reportOnOpen", "false"); + host.add_file("src/c.rs", "fn c() {}\n"); + p.handle_events(&[Event::FileOpened("/project/src/c.rs".into())], &host.invocation()); + assert!(host.diagnostics().get("/project/src/c.rs").is_none_or(Vec::is_empty)); + p.handle_events(&[Event::FileSaved("/project/src/c.rs".into())], &host.invocation()); + assert_eq!(host.diagnostics()["/project/src/c.rs"].len(), 1); + + host.set_setting("owner", "\"ACME\""); + let actions = p.code_actions(&doc("/project/src/a.rs", "fn a() {}\n"), Range::default()); + assert_eq!(actions[0].title, "Add license header"); + let (path, edits) = &actions[0].edits[0]; + assert_eq!(path, "/project/src/a.rs"); + assert!( + edits[0].text.starts_with(&format!( + "// Copyright (c) {} ACME\n// SPDX-License-Identifier: Apache-2.0\n", + this_year() + )) + ); + assert!(p.code_actions(&doc("/project/README.md", "x"), Range::default()).is_empty()); + + // The command edits the focused editor once. + host.open_editor("/project/src/a.rs", "rust", "fn a() {}\n", &[(0, 0, 0, 0)]); + assert_eq!(p.run_command("add", &host.invocation(), None).unwrap(), None); + assert!(has_header(&host.editor_text())); + assert!(p.run_command("add", &host.invocation(), None).unwrap().unwrap().contains("already")); + } + + #[test] + fn scans_the_project_in_a_task() { + let host = MockHost::install().with_capabilities(&["events", "project.read"]); + host.add_file("src/a.rs", "fn a() {}\n"); + host.add_file("src/b.py", "# Copyright 2024 X\n"); + host.add_file("notes.md", "no header needed\n"); + let mut p = LicenseHeader; + p.run_command("scan", &host.invocation(), None).unwrap(); + let d = host.diagnostics(); + assert_eq!(d["/project/src/a.rs"].len(), 1); + assert!(d.get("/project/src/b.py").is_none_or(Vec::is_empty)); + assert!(!d.contains_key("/project/notes.md")); + assert_eq!(host.tasks().len(), 1); + assert!(host.notifications()[0].body.contains("1 of 2"), "{:?}", host.notifications()); + } +} diff --git a/plugins/official/material-icons/generate.py b/plugins/official/material-icons/generate.py new file mode 100644 index 00000000..d1f024ed --- /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'{body}\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'' + f'{label}' + ) + + +def doc(color: str, inner: str = "") -> str: + return svg( + f'' + f'{inner}' + ) + + +def folder(color: str, open_: bool = False) -> str: + if open_: + return svg( + f'' + f'' + ) + return svg( + f'' + ) + + +def stroke(d: str, color: str = "#fff", width: float = 1.4) -> str: + return ( + f'' + ) + + +def gear(color: str) -> str: + teeth = "".join( + f'' + for a in range(0, 360, 45) + ) + return svg(f'{teeth}') + + +def atom(color: str) -> str: + rings = "".join( + f'' for a in (0, 60, 120) + ) + return svg(f'{rings}') + + +def package(color: str) -> str: + return svg( + f'' + '' + '' + ) + + +GLYPHS = { + "file": doc("#90a4ae"), + "text": doc("#90a4ae", stroke("M5 7.5h6M5 10h6M5 12.5h4", width=1.1)), + "markdown": svg( + '' + + 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( + '' + '' + '' + ), + "archive": svg( + '' + '' + '' + ), + "lock": svg( + '' + + stroke("M5.5 7V5a2.5 2.5 0 0 1 5 0v2", "#90a4ae", 1.8) + + '' + ), + "git": svg( + '' + + stroke("M6.3 5v6M6.3 6.4c0 1.8 3.7.4 3.7 2.1", width=1.1) + + '' + ), + "settings": gear("#78909c"), + "env": gear("#fbc02d"), + "readme": svg( + '' + '' + ), + "license": svg( + '' + + stroke("M3.6 4.6h6.4M3.6 6.8h4", "#fff", 1.1) + + '' + ), + "shell": svg( + '' + + stroke("M4 5.5l2.5 2.5L4 10.5", width=1.5) + + stroke("M8 10.8h4", width=1.5) + ), + "database": svg( + '' + '' + + stroke("M2.5 7.5c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2", "#ffe082", 0.9) + ), + "html": svg('' + stroke("M6 5L3.5 8 6 11M10 5l2.5 3L10 11")), + "xml": svg('' + 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( + '' + '' + '' + '' + '' + ), + "npm": package("#cb3837"), + "cargo": package("#ff7043"), + "gomod": package("#00acc1"), + "pnpm": package("#f9ad00"), + "test": svg( + '' + 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(''), + "video": svg(''), +} + +# 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 00000000..871bd064 --- /dev/null +++ b/plugins/official/material-icons/icons/archive.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/astro.svg b/plugins/official/material-icons/icons/astro.svg new file mode 100644 index 00000000..7bdb6e1e --- /dev/null +++ b/plugins/official/material-icons/icons/astro.svg @@ -0,0 +1 @@ +A diff --git a/plugins/official/material-icons/icons/audio.svg b/plugins/official/material-icons/icons/audio.svg new file mode 100644 index 00000000..515294d8 --- /dev/null +++ b/plugins/official/material-icons/icons/audio.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/batch.svg b/plugins/official/material-icons/icons/batch.svg new file mode 100644 index 00000000..6d1779b5 --- /dev/null +++ b/plugins/official/material-icons/icons/batch.svg @@ -0,0 +1 @@ +BAT diff --git a/plugins/official/material-icons/icons/c.svg b/plugins/official/material-icons/icons/c.svg new file mode 100644 index 00000000..408c3ee9 --- /dev/null +++ b/plugins/official/material-icons/icons/c.svg @@ -0,0 +1 @@ +C diff --git a/plugins/official/material-icons/icons/cargo.svg b/plugins/official/material-icons/icons/cargo.svg new file mode 100644 index 00000000..47b71a70 --- /dev/null +++ b/plugins/official/material-icons/icons/cargo.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/class.svg b/plugins/official/material-icons/icons/class.svg new file mode 100644 index 00000000..f0ac74af --- /dev/null +++ b/plugins/official/material-icons/icons/class.svg @@ -0,0 +1 @@ +CL diff --git a/plugins/official/material-icons/icons/clojure.svg b/plugins/official/material-icons/icons/clojure.svg new file mode 100644 index 00000000..28c9b47e --- /dev/null +++ b/plugins/official/material-icons/icons/clojure.svg @@ -0,0 +1 @@ +CLJ diff --git a/plugins/official/material-icons/icons/cpp.svg b/plugins/official/material-icons/icons/cpp.svg new file mode 100644 index 00000000..1a7fd743 --- /dev/null +++ b/plugins/official/material-icons/icons/cpp.svg @@ -0,0 +1 @@ +C++ diff --git a/plugins/official/material-icons/icons/csharp.svg b/plugins/official/material-icons/icons/csharp.svg new file mode 100644 index 00000000..0559e508 --- /dev/null +++ b/plugins/official/material-icons/icons/csharp.svg @@ -0,0 +1 @@ +C# diff --git a/plugins/official/material-icons/icons/css.svg b/plugins/official/material-icons/icons/css.svg new file mode 100644 index 00000000..deb851ee --- /dev/null +++ b/plugins/official/material-icons/icons/css.svg @@ -0,0 +1 @@ +# diff --git a/plugins/official/material-icons/icons/csv.svg b/plugins/official/material-icons/icons/csv.svg new file mode 100644 index 00000000..be856f62 --- /dev/null +++ b/plugins/official/material-icons/icons/csv.svg @@ -0,0 +1 @@ +CSV diff --git a/plugins/official/material-icons/icons/dart.svg b/plugins/official/material-icons/icons/dart.svg new file mode 100644 index 00000000..e6b1b6d1 --- /dev/null +++ b/plugins/official/material-icons/icons/dart.svg @@ -0,0 +1 @@ +D diff --git a/plugins/official/material-icons/icons/database.svg b/plugins/official/material-icons/icons/database.svg new file mode 100644 index 00000000..6b2e64bd --- /dev/null +++ b/plugins/official/material-icons/icons/database.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/diff.svg b/plugins/official/material-icons/icons/diff.svg new file mode 100644 index 00000000..387aeaa6 --- /dev/null +++ b/plugins/official/material-icons/icons/diff.svg @@ -0,0 +1 @@ ++- diff --git a/plugins/official/material-icons/icons/docker.svg b/plugins/official/material-icons/icons/docker.svg new file mode 100644 index 00000000..a5d9eeb4 --- /dev/null +++ b/plugins/official/material-icons/icons/docker.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/editorconfig.svg b/plugins/official/material-icons/icons/editorconfig.svg new file mode 100644 index 00000000..ddbdbeba --- /dev/null +++ b/plugins/official/material-icons/icons/editorconfig.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/elixir.svg b/plugins/official/material-icons/icons/elixir.svg new file mode 100644 index 00000000..3e13bbf9 --- /dev/null +++ b/plugins/official/material-icons/icons/elixir.svg @@ -0,0 +1 @@ +EX diff --git a/plugins/official/material-icons/icons/env.svg b/plugins/official/material-icons/icons/env.svg new file mode 100644 index 00000000..cde14e40 --- /dev/null +++ b/plugins/official/material-icons/icons/env.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/erlang.svg b/plugins/official/material-icons/icons/erlang.svg new file mode 100644 index 00000000..50e2c0a0 --- /dev/null +++ b/plugins/official/material-icons/icons/erlang.svg @@ -0,0 +1 @@ +ER diff --git a/plugins/official/material-icons/icons/file.svg b/plugins/official/material-icons/icons/file.svg new file mode 100644 index 00000000..7a89750a --- /dev/null +++ b/plugins/official/material-icons/icons/file.svg @@ -0,0 +1 @@ + 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 00000000..c24ee893 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-api.svg @@ -0,0 +1 @@ + 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 00000000..6230bf1f --- /dev/null +++ b/plugins/official/material-icons/icons/folder-assets.svg @@ -0,0 +1 @@ + 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 00000000..9b63a7f4 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-components.svg @@ -0,0 +1 @@ + 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 00000000..41d451d9 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-config.svg @@ -0,0 +1 @@ + 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 00000000..ccaab633 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-db.svg @@ -0,0 +1 @@ + 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 00000000..0ec80997 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-deps.svg @@ -0,0 +1 @@ + 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 00000000..69f3fb5b --- /dev/null +++ b/plugins/official/material-icons/icons/folder-dist.svg @@ -0,0 +1 @@ + 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 00000000..f3a28601 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-docs.svg @@ -0,0 +1 @@ + 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 00000000..ba7bd394 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-git.svg @@ -0,0 +1 @@ + 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 00000000..6118c98e --- /dev/null +++ b/plugins/official/material-icons/icons/folder-i18n.svg @@ -0,0 +1 @@ + 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 00000000..0accab51 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-open.svg @@ -0,0 +1 @@ + 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 00000000..c75c82fd --- /dev/null +++ b/plugins/official/material-icons/icons/folder-plugins.svg @@ -0,0 +1 @@ + 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 00000000..e8a7cc79 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-scripts.svg @@ -0,0 +1 @@ + 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 00000000..26336c36 --- /dev/null +++ b/plugins/official/material-icons/icons/folder-src.svg @@ -0,0 +1 @@ + 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 00000000..4ac2a7ab --- /dev/null +++ b/plugins/official/material-icons/icons/folder-styles.svg @@ -0,0 +1 @@ + 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 00000000..29b2cbcf --- /dev/null +++ b/plugins/official/material-icons/icons/folder-test.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/folder.svg b/plugins/official/material-icons/icons/folder.svg new file mode 100644 index 00000000..4002def3 --- /dev/null +++ b/plugins/official/material-icons/icons/folder.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/font.svg b/plugins/official/material-icons/icons/font.svg new file mode 100644 index 00000000..d7e454a4 --- /dev/null +++ b/plugins/official/material-icons/icons/font.svg @@ -0,0 +1 @@ +Aa diff --git a/plugins/official/material-icons/icons/fsharp.svg b/plugins/official/material-icons/icons/fsharp.svg new file mode 100644 index 00000000..cbf21c7b --- /dev/null +++ b/plugins/official/material-icons/icons/fsharp.svg @@ -0,0 +1 @@ +F# diff --git a/plugins/official/material-icons/icons/git.svg b/plugins/official/material-icons/icons/git.svg new file mode 100644 index 00000000..02ff7956 --- /dev/null +++ b/plugins/official/material-icons/icons/git.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/go.svg b/plugins/official/material-icons/icons/go.svg new file mode 100644 index 00000000..4525fef3 --- /dev/null +++ b/plugins/official/material-icons/icons/go.svg @@ -0,0 +1 @@ +GO diff --git a/plugins/official/material-icons/icons/gomod.svg b/plugins/official/material-icons/icons/gomod.svg new file mode 100644 index 00000000..a0a8000c --- /dev/null +++ b/plugins/official/material-icons/icons/gomod.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/graphql.svg b/plugins/official/material-icons/icons/graphql.svg new file mode 100644 index 00000000..7126760d --- /dev/null +++ b/plugins/official/material-icons/icons/graphql.svg @@ -0,0 +1 @@ +GQ diff --git a/plugins/official/material-icons/icons/groovy.svg b/plugins/official/material-icons/icons/groovy.svg new file mode 100644 index 00000000..8967bb44 --- /dev/null +++ b/plugins/official/material-icons/icons/groovy.svg @@ -0,0 +1 @@ +GR diff --git a/plugins/official/material-icons/icons/haskell.svg b/plugins/official/material-icons/icons/haskell.svg new file mode 100644 index 00000000..f20b1be3 --- /dev/null +++ b/plugins/official/material-icons/icons/haskell.svg @@ -0,0 +1 @@ +HS diff --git a/plugins/official/material-icons/icons/header.svg b/plugins/official/material-icons/icons/header.svg new file mode 100644 index 00000000..cf7e9e78 --- /dev/null +++ b/plugins/official/material-icons/icons/header.svg @@ -0,0 +1 @@ +H diff --git a/plugins/official/material-icons/icons/html.svg b/plugins/official/material-icons/icons/html.svg new file mode 100644 index 00000000..40dec724 --- /dev/null +++ b/plugins/official/material-icons/icons/html.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/http.svg b/plugins/official/material-icons/icons/http.svg new file mode 100644 index 00000000..c4a7ed51 --- /dev/null +++ b/plugins/official/material-icons/icons/http.svg @@ -0,0 +1 @@ +API diff --git a/plugins/official/material-icons/icons/image.svg b/plugins/official/material-icons/icons/image.svg new file mode 100644 index 00000000..8f67a7ab --- /dev/null +++ b/plugins/official/material-icons/icons/image.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/ini.svg b/plugins/official/material-icons/icons/ini.svg new file mode 100644 index 00000000..a70903c4 --- /dev/null +++ b/plugins/official/material-icons/icons/ini.svg @@ -0,0 +1 @@ +INI diff --git a/plugins/official/material-icons/icons/java.svg b/plugins/official/material-icons/icons/java.svg new file mode 100644 index 00000000..6a72019b --- /dev/null +++ b/plugins/official/material-icons/icons/java.svg @@ -0,0 +1 @@ +J diff --git a/plugins/official/material-icons/icons/javascript.svg b/plugins/official/material-icons/icons/javascript.svg new file mode 100644 index 00000000..e43c32ab --- /dev/null +++ b/plugins/official/material-icons/icons/javascript.svg @@ -0,0 +1 @@ +JS diff --git a/plugins/official/material-icons/icons/json.svg b/plugins/official/material-icons/icons/json.svg new file mode 100644 index 00000000..841dce8a --- /dev/null +++ b/plugins/official/material-icons/icons/json.svg @@ -0,0 +1 @@ +{} diff --git a/plugins/official/material-icons/icons/julia.svg b/plugins/official/material-icons/icons/julia.svg new file mode 100644 index 00000000..4fb97b14 --- /dev/null +++ b/plugins/official/material-icons/icons/julia.svg @@ -0,0 +1 @@ +JL diff --git a/plugins/official/material-icons/icons/kotlin.svg b/plugins/official/material-icons/icons/kotlin.svg new file mode 100644 index 00000000..af61a955 --- /dev/null +++ b/plugins/official/material-icons/icons/kotlin.svg @@ -0,0 +1 @@ +KT diff --git a/plugins/official/material-icons/icons/less.svg b/plugins/official/material-icons/icons/less.svg new file mode 100644 index 00000000..a6534d17 --- /dev/null +++ b/plugins/official/material-icons/icons/less.svg @@ -0,0 +1 @@ +LS diff --git a/plugins/official/material-icons/icons/license.svg b/plugins/official/material-icons/icons/license.svg new file mode 100644 index 00000000..a58c14bb --- /dev/null +++ b/plugins/official/material-icons/icons/license.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/lock.svg b/plugins/official/material-icons/icons/lock.svg new file mode 100644 index 00000000..1ee46f47 --- /dev/null +++ b/plugins/official/material-icons/icons/lock.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/log.svg b/plugins/official/material-icons/icons/log.svg new file mode 100644 index 00000000..0a8efff5 --- /dev/null +++ b/plugins/official/material-icons/icons/log.svg @@ -0,0 +1 @@ +LOG diff --git a/plugins/official/material-icons/icons/lua.svg b/plugins/official/material-icons/icons/lua.svg new file mode 100644 index 00000000..018aa579 --- /dev/null +++ b/plugins/official/material-icons/icons/lua.svg @@ -0,0 +1 @@ +LUA diff --git a/plugins/official/material-icons/icons/makefile.svg b/plugins/official/material-icons/icons/makefile.svg new file mode 100644 index 00000000..7fe3ad1f --- /dev/null +++ b/plugins/official/material-icons/icons/makefile.svg @@ -0,0 +1 @@ +M diff --git a/plugins/official/material-icons/icons/markdown.svg b/plugins/official/material-icons/icons/markdown.svg new file mode 100644 index 00000000..78c8400e --- /dev/null +++ b/plugins/official/material-icons/icons/markdown.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/nim.svg b/plugins/official/material-icons/icons/nim.svg new file mode 100644 index 00000000..defeaf39 --- /dev/null +++ b/plugins/official/material-icons/icons/nim.svg @@ -0,0 +1 @@ +NIM diff --git a/plugins/official/material-icons/icons/nix.svg b/plugins/official/material-icons/icons/nix.svg new file mode 100644 index 00000000..dace2f37 --- /dev/null +++ b/plugins/official/material-icons/icons/nix.svg @@ -0,0 +1 @@ +NIX diff --git a/plugins/official/material-icons/icons/notebook.svg b/plugins/official/material-icons/icons/notebook.svg new file mode 100644 index 00000000..67b85bf8 --- /dev/null +++ b/plugins/official/material-icons/icons/notebook.svg @@ -0,0 +1 @@ +NB diff --git a/plugins/official/material-icons/icons/npm.svg b/plugins/official/material-icons/icons/npm.svg new file mode 100644 index 00000000..aec46265 --- /dev/null +++ b/plugins/official/material-icons/icons/npm.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/objc.svg b/plugins/official/material-icons/icons/objc.svg new file mode 100644 index 00000000..429ce16b --- /dev/null +++ b/plugins/official/material-icons/icons/objc.svg @@ -0,0 +1 @@ +OC diff --git a/plugins/official/material-icons/icons/ocaml.svg b/plugins/official/material-icons/icons/ocaml.svg new file mode 100644 index 00000000..4be3ebdb --- /dev/null +++ b/plugins/official/material-icons/icons/ocaml.svg @@ -0,0 +1 @@ +ML diff --git a/plugins/official/material-icons/icons/pdf.svg b/plugins/official/material-icons/icons/pdf.svg new file mode 100644 index 00000000..777d161a --- /dev/null +++ b/plugins/official/material-icons/icons/pdf.svg @@ -0,0 +1 @@ +PDF diff --git a/plugins/official/material-icons/icons/perl.svg b/plugins/official/material-icons/icons/perl.svg new file mode 100644 index 00000000..82516150 --- /dev/null +++ b/plugins/official/material-icons/icons/perl.svg @@ -0,0 +1 @@ +PL diff --git a/plugins/official/material-icons/icons/php.svg b/plugins/official/material-icons/icons/php.svg new file mode 100644 index 00000000..20d1ae8e --- /dev/null +++ b/plugins/official/material-icons/icons/php.svg @@ -0,0 +1 @@ +PHP diff --git a/plugins/official/material-icons/icons/pnpm.svg b/plugins/official/material-icons/icons/pnpm.svg new file mode 100644 index 00000000..e581f13c --- /dev/null +++ b/plugins/official/material-icons/icons/pnpm.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/powershell.svg b/plugins/official/material-icons/icons/powershell.svg new file mode 100644 index 00000000..35fe8ce0 --- /dev/null +++ b/plugins/official/material-icons/icons/powershell.svg @@ -0,0 +1 @@ +PS diff --git a/plugins/official/material-icons/icons/proto.svg b/plugins/official/material-icons/icons/proto.svg new file mode 100644 index 00000000..49b84d9e --- /dev/null +++ b/plugins/official/material-icons/icons/proto.svg @@ -0,0 +1 @@ +PB diff --git a/plugins/official/material-icons/icons/python.svg b/plugins/official/material-icons/icons/python.svg new file mode 100644 index 00000000..a22894cc --- /dev/null +++ b/plugins/official/material-icons/icons/python.svg @@ -0,0 +1 @@ +PY diff --git a/plugins/official/material-icons/icons/r.svg b/plugins/official/material-icons/icons/r.svg new file mode 100644 index 00000000..31612708 --- /dev/null +++ b/plugins/official/material-icons/icons/r.svg @@ -0,0 +1 @@ +R 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 00000000..30c7b00e --- /dev/null +++ b/plugins/official/material-icons/icons/react-ts.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/react.svg b/plugins/official/material-icons/icons/react.svg new file mode 100644 index 00000000..3c8af66c --- /dev/null +++ b/plugins/official/material-icons/icons/react.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/readme.svg b/plugins/official/material-icons/icons/readme.svg new file mode 100644 index 00000000..39c5652c --- /dev/null +++ b/plugins/official/material-icons/icons/readme.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/ruby.svg b/plugins/official/material-icons/icons/ruby.svg new file mode 100644 index 00000000..ed8beec6 --- /dev/null +++ b/plugins/official/material-icons/icons/ruby.svg @@ -0,0 +1 @@ +RB diff --git a/plugins/official/material-icons/icons/rust.svg b/plugins/official/material-icons/icons/rust.svg new file mode 100644 index 00000000..3bd8a2bf --- /dev/null +++ b/plugins/official/material-icons/icons/rust.svg @@ -0,0 +1 @@ +RS diff --git a/plugins/official/material-icons/icons/sass.svg b/plugins/official/material-icons/icons/sass.svg new file mode 100644 index 00000000..13bd12f6 --- /dev/null +++ b/plugins/official/material-icons/icons/sass.svg @@ -0,0 +1 @@ +# diff --git a/plugins/official/material-icons/icons/scala.svg b/plugins/official/material-icons/icons/scala.svg new file mode 100644 index 00000000..34cc3238 --- /dev/null +++ b/plugins/official/material-icons/icons/scala.svg @@ -0,0 +1 @@ +SC diff --git a/plugins/official/material-icons/icons/settings.svg b/plugins/official/material-icons/icons/settings.svg new file mode 100644 index 00000000..85a771c2 --- /dev/null +++ b/plugins/official/material-icons/icons/settings.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/shell.svg b/plugins/official/material-icons/icons/shell.svg new file mode 100644 index 00000000..bf37b097 --- /dev/null +++ b/plugins/official/material-icons/icons/shell.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/solidity.svg b/plugins/official/material-icons/icons/solidity.svg new file mode 100644 index 00000000..d6d6508f --- /dev/null +++ b/plugins/official/material-icons/icons/solidity.svg @@ -0,0 +1 @@ +SOL diff --git a/plugins/official/material-icons/icons/svelte.svg b/plugins/official/material-icons/icons/svelte.svg new file mode 100644 index 00000000..152e614f --- /dev/null +++ b/plugins/official/material-icons/icons/svelte.svg @@ -0,0 +1 @@ +S diff --git a/plugins/official/material-icons/icons/svg.svg b/plugins/official/material-icons/icons/svg.svg new file mode 100644 index 00000000..1115a97f --- /dev/null +++ b/plugins/official/material-icons/icons/svg.svg @@ -0,0 +1 @@ +SVG diff --git a/plugins/official/material-icons/icons/swift.svg b/plugins/official/material-icons/icons/swift.svg new file mode 100644 index 00000000..1d6d4461 --- /dev/null +++ b/plugins/official/material-icons/icons/swift.svg @@ -0,0 +1 @@ +SW diff --git a/plugins/official/material-icons/icons/terraform.svg b/plugins/official/material-icons/icons/terraform.svg new file mode 100644 index 00000000..5ff38d03 --- /dev/null +++ b/plugins/official/material-icons/icons/terraform.svg @@ -0,0 +1 @@ +TF diff --git a/plugins/official/material-icons/icons/test.svg b/plugins/official/material-icons/icons/test.svg new file mode 100644 index 00000000..b4922455 --- /dev/null +++ b/plugins/official/material-icons/icons/test.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/tex.svg b/plugins/official/material-icons/icons/tex.svg new file mode 100644 index 00000000..8d647f82 --- /dev/null +++ b/plugins/official/material-icons/icons/tex.svg @@ -0,0 +1 @@ +TEX diff --git a/plugins/official/material-icons/icons/text.svg b/plugins/official/material-icons/icons/text.svg new file mode 100644 index 00000000..1ad8d7b1 --- /dev/null +++ b/plugins/official/material-icons/icons/text.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/theme.json b/plugins/official/material-icons/icons/theme.json new file mode 100644 index 00000000..919c17fe --- /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 00000000..e322c4c8 --- /dev/null +++ b/plugins/official/material-icons/icons/toml.svg @@ -0,0 +1 @@ +TML diff --git a/plugins/official/material-icons/icons/tsconfig.svg b/plugins/official/material-icons/icons/tsconfig.svg new file mode 100644 index 00000000..b1e3d321 --- /dev/null +++ b/plugins/official/material-icons/icons/tsconfig.svg @@ -0,0 +1 @@ +TS 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 00000000..2bdef4cd --- /dev/null +++ b/plugins/official/material-icons/icons/typescript-def.svg @@ -0,0 +1 @@ +D diff --git a/plugins/official/material-icons/icons/typescript.svg b/plugins/official/material-icons/icons/typescript.svg new file mode 100644 index 00000000..ff3be503 --- /dev/null +++ b/plugins/official/material-icons/icons/typescript.svg @@ -0,0 +1 @@ +TS diff --git a/plugins/official/material-icons/icons/video.svg b/plugins/official/material-icons/icons/video.svg new file mode 100644 index 00000000..7bc2ac04 --- /dev/null +++ b/plugins/official/material-icons/icons/video.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/vim.svg b/plugins/official/material-icons/icons/vim.svg new file mode 100644 index 00000000..46ff8ce6 --- /dev/null +++ b/plugins/official/material-icons/icons/vim.svg @@ -0,0 +1 @@ +VI diff --git a/plugins/official/material-icons/icons/vite.svg b/plugins/official/material-icons/icons/vite.svg new file mode 100644 index 00000000..402a8dee --- /dev/null +++ b/plugins/official/material-icons/icons/vite.svg @@ -0,0 +1 @@ +V diff --git a/plugins/official/material-icons/icons/vue.svg b/plugins/official/material-icons/icons/vue.svg new file mode 100644 index 00000000..0481473a --- /dev/null +++ b/plugins/official/material-icons/icons/vue.svg @@ -0,0 +1 @@ +V diff --git a/plugins/official/material-icons/icons/wasm.svg b/plugins/official/material-icons/icons/wasm.svg new file mode 100644 index 00000000..b520537d --- /dev/null +++ b/plugins/official/material-icons/icons/wasm.svg @@ -0,0 +1 @@ +WA diff --git a/plugins/official/material-icons/icons/xml.svg b/plugins/official/material-icons/icons/xml.svg new file mode 100644 index 00000000..4f3edab6 --- /dev/null +++ b/plugins/official/material-icons/icons/xml.svg @@ -0,0 +1 @@ + diff --git a/plugins/official/material-icons/icons/yaml.svg b/plugins/official/material-icons/icons/yaml.svg new file mode 100644 index 00000000..e1f12636 --- /dev/null +++ b/plugins/official/material-icons/icons/yaml.svg @@ -0,0 +1 @@ +YML diff --git a/plugins/official/material-icons/icons/zig.svg b/plugins/official/material-icons/icons/zig.svg new file mode 100644 index 00000000..26894c3f --- /dev/null +++ b/plugins/official/material-icons/icons/zig.svg @@ -0,0 +1 @@ +Z diff --git a/plugins/official/material-icons/plugin.toml b/plugins/official/material-icons/plugin.toml new file mode 100644 index 00000000..4c56e7a6 --- /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 00000000..ea8c4bf7 --- /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 00000000..f4c6da46 --- /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 00000000..415bdf6a --- /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..5ed3800aaa646c6e75d7e85dd6872d915651644c GIT binary patch literal 159800 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%0A#vGDPKeX4?v4t+@os(UkXnRBD? z{L^apXz&a~h-rIA^3l-v9Wkq-GxoU>*7vwuUV0FunN0b-6{82r>-^(!N z_T&Rzept_eN~46k@=vO7#cxCfzrrram6~v-ML2-H ze9{@o?F{TTpDam){8pE4LRMg3lT`VeZ14~i(Q6w#3#Z9UnDnQXO*%4jFzJOTK$9-4 z9D~2PoGdriQ)GlDesdwl^-*~EcLG#`@IB&9j~4N5O*u%9nsVsEs5N74Hc;z&oJoc4 zZ1$E@&iX((2uvU}Nl#27d@PM(?X{d%0(}^*tU@1uYbYXAu^iE&8VCkHsfgy$Q9(5{ z5khf26S}FVLN^eo&xLNz#?Sawe6|;oq!gSp|@)Fx;*gdYTa3^FwTQ&Xx z5rR8QPFU6Gkp;Mt(F49O!04%raf@(|gdxsvjMrg^i#3y_HHg*hko9;O3U~qIY@?%2 zrYDS-LnPRn$Z|$;=j#`dmfWqM$saR)ZB{+sXNV3NtTI63VfQ-OYq zvK*MlfLh(3h1)I`nlr>CMlf?G8^X+AvhS?&AhnjgB;amBn)n8oD!b;tVd@cR$jWV6 zZcuoMW%>pyZDmHnnU*C&h!UUXH#Eo~9)KH1dvbfSRi({IZ$~oi)pxS=$5&U(Okome zp~ECeyZr8%36j8BCuotf!2Fm018eZPKN?A zaPkitAjAkFLhyJmxLjIn=@5RA=|!rb_34W-&7v)Pl$<;Urziv^k4edB>1bu8n~+#k zw2!Kh-i!O=RLA7k;y~}KV_Mo+;p0NJ;__Aznd-8@J5yDU4IaHv%wc^+Dg$`8vz)%RFJUmMN>Wnp_7r>ZC`cNIoD^4m-E`j)A3#hUK@oN1|WE-)g85o%t z(IaFjh#hu}UU`U`j(pKTgDvtYTgRIuA_fN;y_0-7g>wOVF0_&L-pEGSqBAAMS+f2~ zh(M$cp;r{nk||)GgTmWjc4{oa`+&mx#2-cDOkf9`)6%(#yjwQrgAk>9V_p$U_?2to zO|xeShrHHz`B(PwnIXCOR$}Z!m8IyBOOIU`3&Sep#D8P1VLA7Mz<%s#Gt|~ zU(rJTTVd)kj;8gsS)TgDtl4yzDihM$M70Id8AV9HqGCS@@|dg3Tum8m=Kn3U$m1UE zKum2eMog?-{!?rzsLpOe{t&+*Eo}yj+aLmwRlg%{!y^xZhEmOK$u1DyJW3uU_?M>) z-fbqSA2@A=4-MyiQa~AFz`Gbs_-{G>!!0yW;onMze=8aOSK~XYVe^8}4fE1{Acl2B#O?l>e6gailSMJX`Y&%M?gl4gacA%rfBBBZ4*k~!uI3} zYIK;p?a59FcPnk9-Zsfa)j$IiR}>fGD~8tg z(V|80gT=qw$&SdtcB@S0E0Q5sgS(mXj-)0Si7iwvXtkIna!fTufXK}ezw z4#K^T-Y+_O!vw_TpZEk!1LA47^uq*vGLasX`AxbAda$W-yH75_kRFUc8RF9M7xbV| zCfXqxqvlMR0LRP(7%QS-6998Wx-FOhe6sXoGXWYw4_aOHV5lyAh5&D1F1b2%p@1b& z>0m4YpET4qe2#dL>%~QQMkfPHHId>cCW%iwb`88=-I`SkUy6!ot5YNjFB=3*249eL zE)tj;OX*2i>+l%txLFL2G~Z)j63dM*;OlO;`zRamDlu^OLdAgVR&i`w2Bb4LFd417 z2!Qm)VEhiVTI*IgRT9`(TAU3)5^P9m<(KBa>s&3xYWW(Io~mSmVhllnc7($(sD<_3 z!bWuan06hQsCc>ee69C<4V^whz@w59AFicDt*tIrOoS#RCNSxm<=I+GJvbyLwk{VF zkEe(UcP>m!pf*iRoFy^^Yc_%gGppu8gv68S>O$hF^g@NiV_K(Sj)3BvQX+%%-R06$ zCgs+FrkA><1(*R%;IeTI!v1Rz3jZujRzgAAdPpi%veT#@4`5hZS8R>=t7U?55i~#0 z*3g$2d01O7`*28GOSwx+i~onTwRCKd3Oq&PCc$X3Qc&7FGo@|MOgT63e#v?iR1y%a zAvpmjo17SvjiBXW$qAN_>^kN)LUlWk6OX5~h1o7SVK7;fCMTc*+f#$Q8kQ4GxycEp z+;%x>RWJw&Z0_1#7!nk~;tSkZZug6YOwZ%Av1?1~@6DoVsTg~_#NhXO2`ds#WLXkS zak-pj6fV<*Cb?{bak*@jjiEf^AYyb=^i8&zab+Al)kEH4ExZ8dUq+?U8MWl0hg~xe z1Yr|bl|L-qv4kZv^Fx)iF+l0{839^BV>(k!q}R$iw$ff%$5z@S>zH+Zd3;UTpVM40 zUN&*T$|-C$yAP#BeP$SZpcuHz@()x%nl#n$vp!=&o$EVcmpdE157fqJqn$L#mj9j_ zkTys1BJ-=HOr&U3OSkgw#};MxgrEqugmbUY5y{xwmV8WkHRnMru64e)UTGona-u=`spJan}Jl`ORUX-WiCR zn21woF0w73gsSuj|0k~E@(&zdi~8fT7fs0J8X3e&WH$=mD9g-nhzET(ac9k1qLHa_ zBWVoC#~AxDrdjUP_T(@n!te(jzNXffLhs|ybe#WAMJ@)X^|K7lA@~NM$c+~eM#hk; zMpTVMgu0QAAzKkP?(rlS;38Hdise(RN+Vk_8qS6&_tz>=JAA~qwYqdtkw=6aZV5J#s{?pD};=&z|Mt~$JtiGv?#L6h~35zR#3?`PDoj) za1scBHGC#`nr|pE>4lfj@j?>Ms6T`x2}ZekjwH4)3PtoK9GMByuF%wgzC0&dc38^| z4p;exD&`l9_9~m88aN|upn^t3gm+Y>==uks~DADK=8y#c2 z$Hs>Gnlu-k!YX5v5@QFl9P%eqC8#K#W@AeL!(M+ZeUdJ z8X6UesbN$!uUK3DM}julr3R4+*lOu6?ieE5*>tgeD$q%ZU?2Qa;7P)=)`9n&CV zQ5&}8QwWhEyl}>qeA~D2@s`XptCRo6pdD|`+nYDS^YTsL-I$D9=*3f88cJ;;9||d5 zr?wCpNlJY1Yq&6DSL^PD(H<^+8K3s>3cuURq|z3vGuxBBD$7)}%T6w8bbGRmszyRv zXMDICUfyTNEeh91067u=z^5tW{Pyn!BdT%}b_W-RpmaJ)H$xf?z$O35;1giL2VkPq z&@X`LPTd2H`lgD&1d9N3r6IKlSo^rJrl!d%F>sVZs2NZXTBE@@dS^xP>xp06d{BSm za&WgfxTF#VaCZt^RzrczwtHmAJy#4sgU7fNK3(-4066@Bw>jAsG8AMQ5PNrQVz2&?BJPfA680IF-p{8dbm_6XQdR)F|G2c-UCAZW?#d!BZ^C`GxS{r} z73*LF6d`6QbWtr==Jhl)_uqH^MVY;Thqg@XP{7)(Tu;Vo3Wn-v^6ct62*5DTVrm zrDB`~1!+_$&g9=Ex&--miOIi5qDlY?B_P7&;3Y(mFbm*JnY0>FK)W|rLApjz=w)!vLPVHoSn_GBNdCw`HuuWpJhFKJTM*6yir#uNfTv*r$3Q9%+Bfnp z!-o3=Fakk$A8rh6r6-3;mLV!DZQdlZs0U3UvgxzY6XZcVtaq~}(1(NJO>x&v)^Z??%;)-n$zKWGq>t%dvX&knEqAL@*^hy^ujn0=&q^i=!qSX|}u&iAr0Nj%4$h~_>QI=e%ct1snaXNW`3X*R;J*ZfVm5wRaVx>o@S$NHlQ*+vjk5OFj z70yy27tcHq`!N>N$%B@(&3u**R@syignT2)!r8&oQVfvNwQieJ+O-4QoM)s(%Nn$| zL=p;rMs#7C5@fQC3oYTiOQc_}*PAg>8H0U1E!|}$jQy+qEe^nyl+qVQdm$b~ui>^9 z@sVw`6T3ZGv)gU)U}MCPB@}^T8zuC?G8I#O6qY&OH}{aiX@(m|6{rVRY^@CO<#NE@ zX@7@UZVCWHm{TMVKr-f(EGHu<|1(#T1GDGN&3K|>{)Y$rmF&_#Ytb#RUSAAiaQPM# zAhZRF42t_WOm2iJS3T$k6;T7y4Jso1r5s551q@pjz$}iW8z8RUj6`61*lsev*=IK! zMRwa0QA|o3R!xK?&bBuqz?44i%`Usy=5IuKDInMzQDj;!M*d2qmZI70Rbe*|g;yfI zRQk`};Us~DRBOCj*r3y6e=%4nPx-$7M6B1#f3Pxy+Nd)dqNson!%{=(-WavVD|Q}K zw#R+()Easq*nua|Lo^q*Qoe?r>b|VQ;|VITwk-6EZ_>j%(g&GN75D8(7qvq@DHmY} z+fP=;Q*4a@*TxIdTl8p=2&Q6l3f)HpH+Tl!6OYw1_ybQ_&)^T<6%&@IYfO3{Jb05j zn})NuCkx&&L)ej?L}2*j?lj46Ymo}25K&QtbB&jF&J^Qb0;}o{j#3RUu2$hH-GO1f zgP(NwQGW-Y=?*;&bv5Lr;!SC#1e&x{HjL)mGFwEd5Q+pWWPdUMmQgwXS(_i|)tDS4 zmJe6V$S!xAk^AC`Wk0Fi;XxVRKHvvF61*{5hqzjre{`S}uT~gC;bp5L>gE6yZcuL8 z#y_mfn+ByOQp1uj^FXc3O<($6E5hx_9|bQ=iK9Ec8UIQ;yap#gB19S-v~AiQVDrB) zUXSyS1)eACdlN-Grw*CJJp6=c}B6wqOL!bAw5UK&EN6U@JU0vGg zIZR|48cJysLQ{abpBa{8>)C}XU$-?cGD?`%jTO-i1nmV9*2gtL462&Y|Mv*XAgSS4 ziLm}^8DYKWT!h827}yGg6|%lPCrwm_)y7oIw-@zrL391qFzImE(m6;6kIcY@Y@`*X z4oAu6`$ko8yfP(BpMw(ee>00rYUqNAVtP4I{4OW%>i7-`C+9Y3P$T1kj?EDJ)#ouK zm!pEhNpp#WA&WD18j~Hqg_&dr5bEIyC(Xq=X)a+cZ_3WbBiRK}3H#WgJ24hHCDzh#(QaM#+-VY8SR4oJ^YRk-GimYjJMA=lE7R3|JSiyZkG;?r7pB2R$Sp!V3 zFQb>}BPtfEPR{jE)&YJB!;#iwQvUr zcI59i*)K>e#0LZcyFEr|NOsQmLy~L@S)T!*>}gmhTtyljOV7yu7e=BY@Q%Bni>$C( z7#NkcyVv4M8T4je({I$bKgtS5m_+4bLfT9bZy+uCOGZN20-DJr)C->d(rVo=RVRLM z;6(x-B}Oy~j~VS){9FNVjD4&Ky0XUvOXy6ikpLZs&XYmsMnf`BBq|#o6)yGo9 zsy7SZRrgD=YK@#1t6BzMf>o`-e~nmO23)}E8{>o@p?GbubcXUk5*f8|{x<_XWD5dJ z;^t&h))^(@YHlb}$`xXtmW7D&@25M`0=DE4y0A!9e>mMDghv-YwYV@?$pI${g*1X@s1DC?3&dRRIR6QgEl2@&Nc+Gf8AQ<6>H;3OC)aS*I)7R&SR{^H z%-8&0)0}qd1}66|RhIz=yIet;%T8VNa4+?>flgj#1D7TLw0|mV%J%{{2F6YUb>vTJ zQwyT5CNqDAkRA%G+%**SVP!7aM7s-`EmrHLcJ#+xP}~@EHtT@z`pO%hbg#sW!Hty# zO%qH%etV!3VtyKtY*wSJKec|0igJ?#MmbI?RkmLDjiVYma_9p2I4b*bexEnx?f#T* z7`Le%9Vp~wY&%I|i|Rm;Ynil#i2t;Jz#H_f;ysdo+yxFsbPWu*#UYv)O7OQJwsNI_U zmc(Y*k^&IEtYzc$+Q+DuQ~qcOYe<*OLDA6iw}@{Xbu=}tKFK=?9AqfLUr<7d;Vh{* zC&SCne?g;9b|psMc60(@DS~S2omMhmMA)MGd(@S@YVJ*FN&iqALB>cteok8oML4Sj zYoCPMA(RF*-ZM0$#SyQmn)j68{phqqRr&jE#^_@!6OJ`YN)a{&mz}<+`U=sWSRNgw zAe3Dcgi>?)=lpACDL|w+NL^%%E(KB%$1|Pp9~+(TcdkC)(_y~<)u2iST!Q6a2a$!G zipnrQf}oTh7)4H!|ExD7)PMtVpiskQXSq{SZ)lOoP(y1TpR;9>e+WR-sFCmr_((MU zjv9g^&m1-H8b!^S)lsuEpyuC}qUOhjoT4E~Yes46#-fGkAVLp)qEIHm1{Xg7MJIncl)hIaPTQtK#)D8e*T3~DOoP3p?jWZ0EE(tR%1u28s~BTfm%X9|ne zK|X|<2~G|`Rin4T7>E6n({aDy-&+)hoGk!F*eg0_t>4^ls#QtC^yChr?N&PM>GKWG zr*EQ=9YqSdXrD`Ma1h?8SPOz06bJF8KOB;jQkg@-fF%^CZnBox!6)N@l4XQYVu9Dv z_VsMj4pe|WyIjF|t4B-;!$I{d+Om>J`NbO5jCrd|bu&(P?nw9WEjZb##R{1^$b}&l zQ7xzD|D+~(J43kokt6SC{8Ax8rx97zdVOrU!i6K#}R+TpGPvc$`Y@_$y_-M$lp z=o@;Q$l@r{KE*-ZZudX0$RP=k2U-R|B2plurdWMng`T?KW_gh-aM;jDm`9IeEGPa8 z`5Z)i$xtOkjPlM+BPK^2v_In=4z=}G4Y2esFshQj-fZx}CTn7R^~nn?u7r+#LIz!n z`B25AG2b{PjHcm?4h5Q|h=fsh8e^Nu6=Qta@^H=t1sIoym2Nlj__fB8E~xXJ%TWG< zp>r1Zu|hTS^F}v!KEshO53PQmP2blVTD>4*``SCtZ&^-k^$}+SViUy-NbP93e7=9^ zKW@I4cmCG@xXzao+Z|sQVmoyq!sX-9GE#qA=w0hc7fS0avWE4ef%S4h^tOIk_iqfu zj;y?(-t*H->FsT!Uhyho^?~IhuY81BaclK6C^TE`yfBDRFDzIs!^ee%)0ZQpf~wXd zN~FACNY*b0QpU>Iv`3e#mNE+LLc~O-!S@@H#W*{`zW(n4s2^Ag)C~h~ZAfxi)=-pxLbbt} zB0yD?)r_v58)X8gC817e%KF*&OHb!c9hj6nRWH#FTb&__^XnbZxbi4mIXsrVP-q+<7 z@-Gjh)Ai?J?@UxnK@G497o?yX+n9)uU&ukiDgRor;U#SbAiE)k1b z&O(wGGTJ8zZz$PB>|q{}$Ki0$us;c7{D=`YK8(lU?zCIUYL0lUa|X2r!ZhdJ0= zlTdLiT-nfQNfX*V(6KXKb(k(CcC&M2iAs^;;K3di_I#DHr$cI(Ut8>CS}gj23!^}6 zqdZ&Zb-aHl(ljL2QFs<;7cC{myeL^_T%X6g#r`^Wx=voGRlHRpVyc=VUmPac7Lq{R zFrVf+Ra*R9wuQXheza5_{LYX$qnKfHlyF!|mL<(E*Qp~dOp|@W-a+MOv-^eYlbtGA z_Q_T+k$p1lB_aidNM2L~W8dwzmo~kzbfpO(OP46HCV&!=2u%Pb+c^*>paYX}`pHUI zA3k^Cj;{;WtCW!ykPf%6igTOEm5_VULI1l(1t9K56<10W?#U7e0#w2Zh7 z!-Q^0b)cF{L>&gJQqP8~5{dJa$&evQk3)msV(k}y)y~juZh+Y`!EE6zd>0Fdp=ar2NmIS(I>V$yB1YJy=A5mW) zE~(H zt1j!Ll}N{6En3N-2sYB8l?+O0pQzkQ7V@Qprj$_RM6?iCr4)2gMFj}cKn2Dt5M8;= zvOZHCZv!n#ZS54}j6)Cnq|?fbGl&s?edwVIMXf*&L?4#W!$_!WWqP<}NS2>lf{Aru zL*}B&TQjc2Vv>H+tNi_y(ivqK_KK{GoPkvN`XJ|`m}Mj=wK8)4YRJmPyr`o3Xz3Lf zk`Y}j{@p#QRz~eu=4eGbcIS{CyW{I@$5!ifZwPj5y=48^@Ii*dvH<2ZOLoX0u?aR& z!0Ewmz-CARvt#WH2;>8#QQNO75?LMAaA&Y;gZ0Ap5i24e*g;}N^uVoF)FK69!Omai z4LoKV@V&g8X8Q>GpjqC>>JT|sL)-Fr_W=^YRp&l3%(MhX@&4Gh`Pz9E_NRp~31@*d zFFS2|8>}q&iQT@8#ojED^Lv$b$I0EEslBGW=XHxz^S;9wy<@d`ODUMQRx@BH&4Z}XuN{*bxH3195%2aS{-`11 z{?HCF-eG{6`NKQ~GYLaTiVBjc)c$6r z?PDX$Y&nl{1i?Dumu~uv{V@*3MfoFDpl<~kPfT%%jIX?rr7^{oCEC56q;V>(mU!=l z>3a@i{xc@KiRsBq!Us~8mfc;!1^-K)C;r#fez%7U&gsj!So)XUT~qYz7b$vdhRA=) z+mrvxV^YHECui)5vcf{0BGc+^PkRvWr2Csy+|1Up9q4&0o?-LFLhLo(OO0D&Gaxvz zo~BXxw_DaOoV`6kO!Rb8#ofAckI?A_80gn~#Iq$@$)B?@G|I_qvOE0E;oG$^7S4pHBQINo`zPwOCzMDq_r2Z6WGo z${e$yj9Z{q=N1B=tyxblp-~^+=<{iR>JhkLb+yg0dX@=b zpIU>!!(@u`44c#uMOquE{LwZ9uy5;O7MlRVQ3%Lu0AXK{g_q2L#I4se$Pi*xiNt?I zPgL?7nHmc?Ycdua3fn<^C|A@&Sr>)^g&P_gd#BNsi23vKDxcl8Bf%{;B8Ct{`;trB_}R!=fd427o_Dh7o_DT zzbm*vblbQfElm?C!=ocphra|rT@rRoe0CuE`T_n8HHhYh8j_VeX@|fXF3dG5ZB7tD zS9@t*c`o+$X33+fL6Kh3&Oc)kt^*|^;M4+J8P`|xjeCAG7=op6h08Z z%cqriquQ~l*gHEcZBf-Jbm}6%%%QcUD+3eQmRpSGWLIjoWz;tkl*F+(hDjWgQOGeG zLXsgzb|g1(19J6i^^kbS+&z~f9f%u2q+M3U7*t7#8XnCmy8ggax1H-LF-9;gE^Nnj zkyQIUh$v>?wn>t_ILVZm~mv`}CxNJWf1 zWD^3zwk{|gNvPe%)%`!8 z5jtp@xmwon`a!#Y&`HJy>H9KVx+AOzB(nJ6Z}FMkZRG+hUdaVYWJ{^;uHXVIzAxs= ztiD%Q?(gO*J;q;5MCY|``Bi&R8#7%qB)a9@l_9~mu;U&s#NkhyaHF_6xp@bck(DrF z(!>5szt~Rs6UMXl1g52*%h_k!m=8t? z_j{(sH8`cy+po%6zk4MS3=2SK)>kk}s5gz9WDsvMuLO%jHkW(`RJ9u=!NBrex;X+L zHqIgC30BX@8ni7h5Nt94RUZN7!e)dbV2CJ+F_P1vVwtF_I=>!hPNyL25C z7GM9uXws{K$+cxRg2YQM28kjlOaYrrlqFZ%BjDI4*N;O7INF@n*7_ExvA+2bW0rKS z?QCZ9;Al))PIOOXFPt?rqNV6Tqz#Yl5~NRNd&EGGbbCFVAwh05(W-`H%Vonf5wHe@ zRg&D8W-r}Cp&)cveqS>zzgsKHZka%)=?&Ws-W@S( zeyN$T5wdc`6p3w($TKW_nFuY(vuQ5eZQ{b@?c@UaZS%XWTp0S5To@ohRoz{IXDnf+ z7-|q^Ern}&P)oN~c^W^gHE`94EJ&8w4{R;a-9Z&MCpSr!S?RRNGJCJ&+mn3kZHkos z-o%vuJRisSPbUj8PRq8+xZuGaV+smEPFb5Kr)+feJS?ZEcQXuPz1<}5v#JC-+sc(! zgy0&J4qD6&2`-(fRK-!>DeRqO-HjhHOTMJkhnrm3VwLRzXGB{f-`Iyrl1&Ep2b)EA z>unP}1rE?cZquGNdlK0;dAqDVeZt|IYSxx)wWUhyb5H|6$ikOVd9xc)kVVx|W#x2a zJ`udYe8MV6Z&cP3y>Y}NCqD>T%30v(_4)c`s+Tw@{xZhlY1A*fKdCsrfQd#CGq6Ji zD@0jac@{O`k*&N{AfgJ~2IV@b0aP#S>-B@L%><>AS)NrTea)ncTt*DUZS@Gd?@UR` zxeQ^k5Mkb|=upW`f>q=unG7_CO6~~T{0nO27TpW z5kKfCjxp>|?WFBZcq5}v*nqsJ(ymw2aXm7J{Lb0b%TsWf%*>j>o)>ARDi>!cP=~!T{H&^ewLsi}Jfm#H-bb!rwOJ47c}`C?lRK3|2PCq# zok}>Ub3w=F1Jtejenu&BrFR`i`1NcSnP<(y>S|Wtv(Ja9)+(LsanP@H$ahiASIvEh z9tvkj*LbzPUhQhC8Q+45hC7o}3TtcmG*tki8gu!mJ<&lbj)ivqHo2u<|aQ5~eLop^f#zr%<-^0v1aF8f+f1qq-%X2<3V* zjeDXKS^14DHdEOlD@%Yl=|GsM3qY8->*K18jM-hc73in};^Y8`oAhf%Psi6|hX9!N zBe6toAY$f_r$aP{k`V=G@|*-AT`ZGJRz`lGbSJH$k!V^T9MNm7 zXOJ!7yG}dW{fSj3O%Ab`k+5DRODZZTk%qVlDD*={`lNx9hxafc?05sFMcRn!ch*Zv z1s-3ysTS-$1Z)LEjQKGNkfN^gw`ykXBqbLNhNt{VWF_sSTziTJJ)o z`+p2H#!!xz!rs6jjb{f7P3Lb?KeULm+3tr3CqRO+DxL?#_2TDGG>Glh=K(x9NZtu6 zAsBr!JDU=z$|B*8ED{Ak_$!menS=vDssUbE$)+NmNMh|FvtOYY5EbYoC?}$_pWo0j z6|zptR0wgFsT3FG@3zpkz9-e7>Jy#iRr1dU)UjH1B^t+k<2Ychf}y%Z23--B3`npXT<(vXIieZa+J_-Nga zk35%#!xvja4QHGY8k#{ncr!Aj{4nxJ5x-Q8VaX0RhCVZrZF9it0Qd~Hts6^LZwzaD zS!0Cw+pHPXpQ9f&q?KSPR`|To=lqP3V=UuiDObA#XN}bx!-!r6%QTIJkI}|<`g^U7 zcR~8n#++B;6Kbj%TNCx@8U1kRU^ks40AuJ4)b$@X2h1Zy-5ycYWI&4k_(=3;cU!q& zU|q=tlY-R?-Ce;265Y!Mg1y@B_8=`pur1T`y>L7(r-B}IV0A#~KCQ%K5=+a!oX>c4 zScZ^wh4)t2?`}02cgqeDgITe1^xCzzj@mfPEifG9!reX^V@j-RXF&nU?>S=`bGZiN zn3r&Ae#h%glJM|aq`S^c=8YY{MngKslv(7;JITD4(Ei#n`&)<05eBW#2d8YJCL)<$VqDw^tB)~R3zWtPJZvXt_7y8a7-f+X+4rM_z9UsTU#&-f9( z7#;k8jx;4+w$g*K4sk~K&38LY7^9gQM(__9BSO$27g?g?$kOX^rn!DKs%@rL-FI_iIG(%W6U zq3FE-v_6~W5qrH0gR1P6EOQmjdtTb_Y^Z*gUO(^B*`P&MZR_Eg6xakxna%st!Bz;F5y2NS{*-lsHO7TgLnR55vh^~p3NwY6 zV*M8LrX(pGAz5eqpu%Ohs!_yoNJi4{cW5D6{;(apPdsE{aRIq&hh^B0g%3o6*tqat zrlYhG)16=oKL!IbTP}(%xD%7k&Z}@(d#Z?zwPt6vtBp-Ek#QTs{3E3s$c&b>NN`5AR1whrFtb-IkbMMF5An86B$1DeiJ!r`(|?=<_3z^UL7Hk$|E}1M3k<5k!y=G{YcNqd^9a7G?8>yAr;dWy~hufJe#_u*H;0vv2;jK2# zF=Hqbo1leUWxNjhUs%XqxCX~>41#8m$7=@K}P`%-qNUe@c88=K(+{Dj@x!J3Loqimcs2RV1LPrEv z`V(ql4JhSqeLp{{^(e(QIbhu zA^rEV4M!y&6wM-1z8c1N$FJWj#1#`D6Bz6?M{+-ee*(+Gzt0ka0wm99H&+-kNOnYx z4*4a{=76Xs3p=QfDFL6FAr8UUL~v0LngPbF`H)Csa=sI$UW(EcCSrnVn3_<8g5*k- zAd}TFOBF{P^k5u;W4w`}YC2#n+A!*{a{!0Ag^Wr`i!fVveAZ^`Ek0X6n+^`@RVqr* zGk}gMz}_G!af}B}gHk+!63Mk#KxR;Iibx=QCdW36MSV&fuq^@3DQd@{ARg#!YqI;n zx8zwR#f`q>5YSfgDI|WAMYokyV)FOJ<2u1sYr`;|F7jlq1=}`$oN?1}u1>(gWejOS z3oEvDO;9#VMZ6SV-M87gHd|1NJbU}g8LYC}ul>0VGvk#zn_+54h11I1Tth^poP0QlP@Eh2Q_h{be z9A}S;kW}WOGhUmChJ4j8Wrc+V6R-lcj(!a9(Jls=?Xp*A z+krp~sDbMD2uViupeW2ca~!CCYoL0isgDr+#uW%&zk3KmNrJjig4a?Z_|Sob7A-rt zl;GtnT7s?yZnFu1aFerWjOkS?9G2(00L3EM%|Qo|ZCYpEdvDVwE-R0jj6nw3n? z_{5E-%qpAFq;R1?&Oe@qz4KTiE)doM4L_o~(mt$6LYTz6x6(mKoRyxkIs!&u_M zdBdQK-7kP+W*s-uv&(A8)b^!=I3$2Bxo?DQ^&}P>@B|{iXKc=Sk()H-etR zMw6#*yYjJer4r3{!nrg~*D1}=1(1jsxXAYdOnlh-hs=;Wx`n-YkIjrS9MkYHqQ~%k ztR0*-j6C86=6*8zf{aTlc4^EIfvB0mODMk$7K0zVmIM#=ktHDX$h>DrE)x~JDZj1Y z-hFgMXnKe?1#zZyQgeY_%10FN2b8eUk>iN8uDjp`5L;dfHokfp!{NfZC4B1;Q^EY7 zq6y5jWMfxBC@I8%9avGVHP8;0G!UA*|yfwsIwKmzE!J!%y*- zps-Z*w-ygzM@##Us<4m>w`??G zXu4=pp%|hl^$3vtx`i5eA10M4CE#??Q`*0vDeaHWr%W@(z^t!2pAf3er`k6n!*^Fs ziK(Wv==r>PP`;WjO)i4GrwK!%*TaQ|4fVLYmU8dw`~a{G&ydSj1)L0`ouX(hSs)wr zM}AYRy|m(83<*U5$Tvcv_+|){I;5r@;vx~1dI|#BMqnaju=7Gx-r>KS{1({~G-O>4 zerj%%LVdag?~~P5Epq*FxMk^v_MZCmBBoIF!*QXrUU+$Wv@`#*8D2g*^5x$=JD#i( zTo#hrO@kSg!O`G93lj@3+*%!ow8NRM*m8uZX0&PLDXRLk*27!!g6AUr)@6GW1b9owEP#({BPogc!*W)6XZ7=yUzKlFbm)#ZyZTBP6T~~!3{S2sUzGp2+Ts~f zhbQuR%p$m%As7r!&_*d251jtRR+IH}%B?4n$B zE0YLAz)S@81Va3^P=MW56bj9yLg6d=Z4WWw3Q_?fP)P;i7+(=z4@rehE)@zbmms2u zvjIJUKC!JC2=JrH9qR%itc`peXe8CBU^U`L78U?W=7Wp}2l*q(l>NFZ@rzqQj|f6J zSv78NLqgD*ejQ8~H!ZjbRc1l-YE+?#YJvi3QWy^i5bBv*m!`DVw*K!VxBNvyBN1sYE!b#h?`c)%7S;pPxT749R{0{W zLP#bFHA;x!$G2@YpgrCIWxbb<>sS-4MbdUOK6I^*am;YZcgR02mD+$R`Tz%fWS++m z)qo0GJPOrl@Kd?uK2(zzol2FkB6eX^GkrlsL)P!~D9vcq2?X+^v?6t*nur1fBdW=y zJMus^>Vm0Zd6y^oyIp!HfWKBPO!n6i>=lyJU5w+BVy&57v?Yzu?|=zyr}%aeh&_BQ z4>5@QlZ*Xll`OGD#iac*5VA7V_?|(HORE}duD8V@8ok-ymaXd*6S!3CBT`r`-92mM zPTny;83d7|dYbO=OH*`;wMRwtvY-S~u}%gtm#fCjRbd1Js)h3|(aw}hpbvv@UD=U5 z;GzwSYd1&H?lTX*?H7OeeLwcgPer$|!=$Yhi`~U7r}v`XY$ZU75WK7lQvVlR>UUO? z`aokhxKT3N z$dqn|er2johVxLQPH_EH@`UgXb=(~*OfTK zM|Hh?d-6zl{!qAIG?l2mQ$aIRyqUrW*t%fxzU^D-S+I3vr5zER^nz57pZFD=x-Md|ej*|EA31t$) zFEWY2h(hPEl<7ZQRx2tLw^2n8#clGZ5Au}2D-B85-C-o6G(gOwKp>|-vp8(!+Ng$P zvVewKL*=JNtzLi;Z6o*P^|^48RFo+aqovqg6Fz)&^g}tH{f6(IK~!ry%mbPbmSr?s z!a143?RtfsLERDD0!DosgF?}QfRw!`J(ZL)C?C8l14`OFs9e(v0;IBn9vKBhP$Dz~ zln;!)Ov{C2W|Qm@UzWk5sjJ>MQuP;yR{RIY!iZ&8dj@-BY^wQnQTiXhy8A4od1!! z3R9@=e0f>Fwa$<0s8}BSH<>8zYQbPkhHq(1Jpyp7;#w3 z04EAIJUkdCGE3etO~|}leV8~f^*9ZfqDopiFtT61;kEF5vC(4zk-ztQ8L12vWifEk z;z7gSW#L^UTTQR5TJ`hc3dDG8-Cs?Uq2#5rgQOQIfw;_1st4gxoEmrqWfTx%OMRF^}F_L(l9UM3gzdpJY!ojhSqWft~T3K zX>+nwKQOUZ{1;M|Xzm&dl!p)NkxTxi-SOP89v z05tAa^B#@l%KX<%V}Um^puR1v0h+QTMe3`fC`DIHd8Fmpid?#nw36;1?`$YvX~^^j zy~b3yH9|cIznly6F)cEYOX48}j0HKnxt$G%(v-25YTASOLvEG(Hx@0!EKszm((5Lx ziI#~$hN5Ll1E9Ss=rK;az8`rg06K2Y#}!PQykNOEI{6Q2JI*5yfcE^Si_MbJ=I`Dh z=D{nWa%li`9DBMyUIjo0T^L1k)SFzKfrV!wPhujqUose?%?7^km0*vF(Ci8W%BMAd z1>!M0Q!}Tm6n9kosOdNb07Mx*SF>f zg>_WtA|an_5Gfn<;Ld_ZvNi{Dj;aVx@U)0 zKoXnznQ3H7DG=|(VzT_lla&0MY*E)aSy8q7(9pli$F?ooq_93oMVN6y8d z09~jvD=7`tOOOOlM#7b`7^QCaj*)gRY>ULki`*jdW0$6q74^?tof(8Pf$c1|voxjt zpM zpMjXLv`1=gQuXQl!_FPtiv-YURMTg6>nh7K_tHF|G9~WT_%)K^Wp{Lq&G$SP|hk7K+qmbw$TKBO{Sx zpg@+NLPdWlLEX3Y5r;C#KPK%-b=k3y;@NojH(t{pCy7p&z~njUm&4vTe+01 zsF*+%_b2YLEPKwhx=Y^S1W8^JJJmnU9;rta-^#?p%IU-VH_-<2k!ARLqG;Y4y(fQ{ z1~M3e5I2}Ds6+M}c~wNuIA20EKufm@D$e0xJIMk?oy{?oI~>rMDJ3pMvx;1T^Zt+B zBEgwsSL^o4vu}HAYi^dfb*ob0qIDdpAm~2jcpl`>K5TBJz^XH7A2!0b&TrLPc>qF4 z4vpmxRfPP6(V-}Li~)qG=9qzhuSUYRBpb=$)jV*-&%+eWQ?(hROc0a5fRX>9^P#?3 ztjT+k7bc%V_u*HJ@OY{hQk_G$y5Z!ZAk>UBG&=wBqoI1k)qE;Q#;gv066=a(6xOrN zm0vMCw}M>c9}=((^1twj-puGkXwwWB9&d}`M)l2r4f&Z{EGC#3`?rz*fw87Na|GAvI2JQ^b`qQ)VEbAX zPTov-Wk-s`_v}bbu<&%39j=Dk z{G#}@*h1pP#LWVCyFt*GteJcIxK*fNuwViN6j&!ugnN<5?w?~C7rTK@%#ap&{82y) zew4;w{-@@ly8*$E^kp5Y-nV?EWQS_)@>MZPdvw5lVPf0ep@}T89!(W5g3;lm(*M)g2?}5!1}d@*9afO{4gei zq($*YiodU zjB2(X@!7H^K{1S_H)OMv9(2x+`uPT_%w#D+ZX^Sn@mT!9sHw1I(g8VUOexbkN{$7z z1SGi1qRRDZVOK?tS2{U{ALKm`IihB2a$H}r-*RgF(?E??iUk7`G2Rv)OIZbhBvlqv z4dM3X)WVm88c4R~HH1H?$rY4znIWjCNqXfkfXsgfN606{6ONBoW0;EdR;R(8-khm| zgxz<>$jx&O4#W}PrVq)Kv@kk75+W`2>< zOyk?*saS9L!VW5kELlQ!TUHhXf7B3Kr8s18UWn3sWW&MqN24H7O1+m2xW#F@!@RbZp`k+Y;4IcDfd!K z_y9*LD>_L23*tZ_K0ldPezC5tFo7ABRjQA!VSUFZd?Dv;&<$sh$#Tav?(o>bA16o5$8{Npmdf-+#DuI;m> zNJfT*Ppu{+faUE({&0_&w4ZCM!_O~gK>E0j)9zyx!obl%7S`6F3Hhcl8y=+n2C>Ja zHQn;KNE5PP++i<4@%y|4Ph3kw90_AUQQnq)PbG6Ufz6 zknGkVQz~(%Ka-eMLyl=;a&eE(tan~}rqsS#kzsR8UAURB*i4EN$wZo`%efF1rnJc) zOzUD{;XZL#c6^gJXyT!%{YDy*@&VW)Dy_;I@=)7`fZQAu>qfgVbwUQ)Q8Oa5XiXcI zdob0oh{Re9dSKbFFrs?nt&Pa6ep6w#BSz>Cnom-_8XUB!`J-Pjq?xIE^5BQ&&p&e( z@oqz;oqmXcm?UF<;ZB=hHICqA;#AtS!)JeUa=DIovuE3Mo*d1t<|^ zjT}2V;v4`A=d3z;BWx`+AOJU3>I2<^cM^7NZ5&>Bbu;M`gF)f5hNVch9x+Tz&P}`p z>RXb*uU^4~4S$RoMc4$k^_-o;A}0dT6RAw|DtjTTwq7Y;lwK|S#C!XHNnhsw^>Mx1_k)?*Mvu&$c21 ztyoAli`qnCvVL?*DLyQwyCK8EHCF4uka;2=7&4$Cbu<{T3_F)>k)34*jhdu@&W!U1 z4aZdJ$r95A+%TV@Po};n?E7nQ+Q@%d^~rQ%;v^)wASASm&N{6^ByZMEKi|U0V^Ia} z0z0f_dbmqL(H;`_iAdIGNQ|?`1>LDRM&HO%vnEZ-E73!*w73^0{dGfT+r^CbBNsW^ zUm1+{toCv&8}8$Xr7+w_ZR9qzCsdTTBJadX=9XB=91$zY^?2qe*iQyQvdea(u=~uo zOB+|1XD%Eb;qSU&az=pWoj0P((=`4@$%Vd_cAUz^Cdg_vakYk3wtHvx?cN6>OD_xUq?#0Q(pQK zg*wZHEnLlIDhv5topS3-wBNs6I7>2aN(Sp?W?l1yeT%l}rE?NidY&lTPBICeT(@1j`xn0^^FNjRrA}aQhBvZ@*N!H^M#G8bAWql^W+-7kia#i^MkoPWNmR(i7 z?|STAuT@pO!)eY9bUduu-D>mbbnpVk1h7}Dff!U!yytQ6cc9W;NxG_^)zzKk6O#&b z@Ii!J35XaZh?=NG8$=EFL@5=oQ4ykoMvNFWDtho56%;X$`}>bE=XzA_>TXE5p7Y&w zy7%5|%{3omj>mtDIp$pUkV?Cb3^orr_4FIn#jCJYb}NObOAXM@$VU_*s_owUY7v|+lzrL+@J&)Gr+2L@Q|Jhyvb)PZu0*vIh>U?C&wRcAje;PDai3> zo08+pQyq;L=*$uiZVb^%o!-!r;NnxQBL(!*h!|{=^5P5&F^x>OVd>!PWqV)lk z7mt~ZS>QmN#R9*U{inIfvmBBLI@CWfkp0v1Ll4l3nmdxG&6}eQT+CQRO%9r$gCDeo z&rB<3UZdXPOW~?NxWjKrHC?AL2fX<7jI=DhQJZhyO5}ibv!IKAi-u@_flxpHr|N3r`HF` z2GPs;eA?uC>Zp`B>lUa>2Om*e!GzQI>B_cIWXh5oF~a>!G^JiykMNoyLxu&2YXr(L zEJ?B185E{9ZGD(0hx8Hy{6MYy8&5`rx{E zYW(0_AyT>ltinEeizSSOaD zLNXSHA!Au#n8`&n*Dj#rR5)sPGKOJDhF=UhIDKsrn2q^`TF{*Z?~$Q{_P3$Ulv`9u zSpZe&^^EWwuYgbjm*qI9EU#U`g_qd~%_;EuKaw zGr&u?72N^R)zKa0>=XRiqJ4*xPEGlzc=CEffjm+ol789|=O``F2Mh54c&0tXGg*gt zPWdT9F~q~DNf58&_ijjegG=ZcCKV0m4QX6_<3KsPFq{oRMLT4PcOcWOFc+(dqvHJE zhKR|&^1&Cx0yM1;qOK5_LBy@Z-{|m`Tiou|J8-S=lGm-ma+Pqo`*zAbR@7J-=O@{n zPRwcEa(QPom00SSg$p1Vs|IRb&IJKTofwiafn*F>0!UK5Qt9#;iD!0+hw(6wZ_mb; zr@&XwukVN2?Q{^rdzN6YXIb#Dh^ZYfLUHpZxc{V;YIXIIgq=bvh5Q)k#FbERjb5_P zX!Rb>`VibG^DSDe!EEH}Uan*7CX(&nBehUWC1o=Bd0AZldQbE9XPU1+)qH(d^YyfS z)d?(RD)PfN{lU-K#o2Ii@U!;w{7xC1=<&fX*p2_N;fA=foEqG1x2d^Rx1RIcWihzR z3Osd0fx%C1e(T+K>))??YVgx`nc1SX!Tqv~TkQBgjcf3`{`2`_Z74LrXzFnT?9#OFk12LB#1I+R%NzWwS9rcJrRZf^ zez_>lr8N$}*wESqp*79I;MF1?9LsV=xcoxsvT+azgXr{aH9Tl9_iDydi1S)Q3O{4z z;b-i^>$-8a)F;7PpC)m^Y!(Y1n#6pm%1(-y+{Q@N`G&m#%p+heR3d{e+Gef?O_4|s zr#30FzB&t-{>4K-TgQf*D0(`~QxflkDHb)Kghq-d>$|UT*c2HViabe*8>GKvzeI7f z%+avnKkEvCPgR8@2Q7_>%%{;>KWa(qmXuS@w=^i~UeU_IEly_PDrsT`FZsX@iyAhb z!E05dFksN|a~k*=?P-`pCd=swiI^bOlkSCZ2D&zo#Y*4?t6&dWK>qeDB+V>@WZG=q zETE7BD1hR1r14i;=nbJ8^G>21$4vqEZJl4nf*CAxnSKFg$l)Zd(iIFA=9I%5IbW4& zgizH@6PIt}BfV_fkex?tYv-n^!rMg{IuDkoq^vXFrpf5#gEzd$2HH1fM`l;LLu4m5 ztXvgsE*Ht|jt(yO&K4ik7^s!vkVJ&gj}YOK5du&w&Iu&z*eYA)=R{%K0LHY*24QXZ z(mA$DbAp|+xP%>B#UstJRe9Apwn~LDOr64RnA*oy*)&s%{NxH^Rx(bc4M`!;b+MVC zT95t~?@rU!xv0In%k=IWhJ9t`F`PcRcs$I@6<|6U`kqXj7?KkZjG5pt3y2x7DPBML zSLt&j41y?b9sIMdxGmMs&-zcmlcN7rXAXnaY61U5oGz#g{>C#gGFGWav}idgE^YaU z!>s!5+t=_CVKrz@ylI%q%kufls)s)5H~m^tJe_B$vq$5g4{@21gyAXa#?vgd2-b7z z{AQPFvmjZLar8Po7KaL;w6O>n4(T}ZBaj$|rjamlS}27?B2M#1NTfH+XZ!hBmL@&+ zc`)LBQRRHFtjEhH!4Z)ReUZU1XzCc0t{S1#UZ*ill6k8&^V)QODD9ZOUS~jp5d+o? zSAG6^aZvcU^`Q9PX=jr`@$45iDC*V8MQX|q(u_oGc!44Mpp?uTB=Q$?RBupy7d58b zC&*zDN`G*w9#o&R=$I*)aa-788J_P0bP5Wu-VVs6opWhMAX6I>BxG z3aFzxh#^u&`=Xd2GIi(JCuwi2l46HPkT(XzK1)iWU~nC!iR>t%aE?9Kh^`WrcJ)3< zbX&@Yz%y|SzeYRqO=zqHeX_D9RRWYChsR}^grgj`rx&0!9JjcJMoYka=S4Ce*oIOw zp&@k*Btn^nAN8$-N`yArJgL=Ycv8X+PioscL$)1%eOokE6xfD1a7hi*Csk1zhtjq< z6rR-iLSgy_30Dz!-Q)lBrc@y_Z= z-#f?nScsd0F*JWh7WqB;$=}IbbQ*b}iur8KR(C{{gLkxrBqf@U8>T~5c>NH^y#NNr zIZ}3R+G?LF3lamO=c*Gc*p@}HX^3sDP2(COlT|nlNIs!lQ*pz#XfV#om4HpF=nM_8*$Lj}%mKdJ;kKNQ>>G`nmVo`U@@g@jB_KAU8vB?^e^y(J!PwcE%8=Gix{nonUnGXGJq~p3yAZ1I4i14aHb3 zdEzLxj$A#Qa>xZyMIJ~6jg3wmiyi1hhxUFV)*B+3u&aOsK*`m*k>|FQ@rCC88MpRfqQE35ZZ$FLa$T zAYwbr@h}XC=psNwD>gF@M8@O~&$1Dnr@#ny1oEt|@rVNv<3$G|B&+xqbP5ARI)xpG zjI}&uRSCr9y_(ZHP$FfYezBwg$CSon=wu9lA#{R9gTI;TSwFjJ!4oEJ{X zk;o3kBpJaXSHa*a0@gr~nD79h1g3O13m6PaMeHR&!`(KRbzcCqX!V#v;G1&(h@ zXUGR?Th^VAzP%PmCV`O?Y_(VZI(p)oKPmfQPs&y5H;Z#`OGjtd;7;9U-Lr^IYTW}z z5kno(J@YuMdwko_Jtae%DHQ_S&^=ikH(w!lgNbRn_l_G+D9C9TCbfK`Ti(?r`pfRea_MnfA_3r<_8!5|=xTBinl)K~+Zz9e~RY3%SU zhQNr!3H&VQ%7@c(E*E$5!9?uf1Hmf&fv@+Ei}?V|(b=|&Fs5u`JJzN8r~HO&58WUv z0gp2BVYWmXof;E9gD9VqU!(Be;60kg;>EfNJFxI!Ys=);drCcwu~jF!*j=)<)PIiE zFTXmcn|flWcb{v@dnuk_6={;yvvEPiQHljv6`tlr*+;r z%bXjTlH$I!Ie;F`mj;$h2X|@|;=)X_>lMm2BgG#}c)0=>`MSg21Ht5>&a4!KQDK%} zbcBP#FXk^Wfr5GV!6?KB>ELz7@4+z=CKdCbP{df4@3e|!3 z@oLSSP;VRFQ%ivNOzJv#Z)=~M!Fy;xM5+z%#~j}4EIHu4&YF{Pz%o^RWy{ghR!uO3 z@&HMI=ybg`BeEEjZQ%W<^X9N!cq{4>nG62%o{_$2=O<6vI}7k20_IWj4a`z8`N7z) zb&R&U$6s=dD5e7^Y;tY!XEH+QDQcfIq*@;?p@S%PB{hV$!zJ_{6-aM8Ca4ly%nLaP z5Xsr9fFCT?{Lm@b$8{SPTa$2;FW;8GW48COBgYH8S{p zAvgcS78(qu%K{P32LFs_Cs8ErUG zae;IV9Zp&|OLTK7l+DCzmLxASv%|@{2G^ab<)o5YQ`d^`?ncw|9o$$=;N}o4K+qt* znPXWn&%=d5q*%$JJV-gNvtR)Q!P|XfD6|uG^BjnQaDWWDu!Fx|ge(^@Ur5(g3WYc6 zivY5RKW&rxZEiR-p0zYdR%&91P;~I%pl2LXyX$!@g_$?!E}=q4@Poe~IF%#?41w0Tb-^lq`pNo=gV6qmCuTH}QNEG#~Tsix^H5NTW{Z z9hjkw)Blg!z`QAOgwBI`S#*2E`}7s6O`MVtZ+P%WRI>Uzwf>pE*Z0Avz1ZDpr`5i< zs;MDsZ-`W&Ixs{@9i|YRHqa54L`upi7EbvxBis$+R3!IW2%IZrs%r+v@6j-+84%5F z=_xr9`lm)Uc12UKLDN|`t&QVU{Ct{eUsBcfgZg)udGv!|33yC_ZW_WP%7aJL6E>>v zWP9|-MPF=_9)xmv@LlA5a5JZWcv_A3B9Fo&$3*1i{dn&v%{S~$bUj4f@7}?wA>4_oqMM8EZlYYDm%) zln{FpND+{h)6r>R&%cR=DFW9<5)?rLy>L?;4Fqha(3rEj!5A?pjr&b$2+2cHtDOET z(0-#p0)ym_vx$7w>5xDI?Ib^dR`T& zbcS8E$fk`JQXC~z0W7xIk}mQd&u`!Z&+Y5I!)T@8=3gAD5-WO#_{JM7+chZ8+4qXi z?^bNO%@iBL%+mg|D2FH`7q%mC`zei>tbTj_;-Ve4-LG~o>V!6RA+0PEBi`6yH!h|d z93|JL%jqY^?8sFSwyc&(x4bI>?y*LMTk)q{c(Pe_PX7 zL~5I~V?2}z3yW9DOJ<^+pEgr!44%1O-oPhsd57px{m}?MVXElg;6rMS=J=2}R{!jr z1;19eKB9*QZ)R@VC4Tjtx?KIXxbD7xup+@`M!ojoag0&_h9D9TalCw3kBfY77KT2< zfPg|db%r)`cJgtBv0|y}B;NaNm!9UghanzYMdnW27|by(Vn*|498 zO?wi--Wy*w6XThF3;596o4!gJ2O8EGYOMD%#KQK@VC6-O!YjA!{_2r$2C1 zv}Np+jze7I`u5k0J|>SDN`tq&5*xWk51`LLTwI`Mr`3zcz-@Qw4so};(D0n_5w^SqvNXye^;5CUCHU5n%21A8!?UC%B;GJ{HE9w zWRQmmZNT=LIax`b=H>2TR(ux9n&^SnB4A{MsyW)IYd0yg=;jxNbnsAmyzcl1McZXTl9LI}#cOClQW% zgD51968{6xrQ^@1n0|?&?7RwR=C(P=bcgMW%^OHoHQNk^s8P){Rowkirp0%4j!A7g z80Fc-beID^>1J+j$M#!e=VH7Rkbw$tO(2%{{*py4xSv%|ix&B>-W+0ITdX#lCs0u{f1 z=qlo;gcb=l*+wSOUdPUID2pw=ZvQ_u497-VpJU=k1y2UQrUuk?f^n?K3vuZV|6H)> zH-nHP<`OezJrdu_p(B(*5Gk%}4aM6fBAHtyYOC^)Sxn(=sayOiVNcsh>oo+BNnba2|A3AP0b zLVq7Vr8X^gS7vP*wBl25$s1(d1d0(H1()Q}Xx*J3p~`m|34|bYZnsOBa#WbxOBC<4 ziFZdw9HgJU!JlcM+_NBlHV9SSXH|)ocCRYns8XdMgLq5dbeaV*W&J6YuJ4Z$Rq%(y z3{-dd#F*D60^<$Pv?X1e{it?F9rCb~{x<_Fdwp`xR4$R8`(xc2=B|5g@qmRDFLP(M ztkXEg#v7L_Mn!pPjYAKiZt%BlYLO#_osvumds+894xymCJo1mLS?Pz1g4rkzH&fpz zR_S`o!+TX)PFPFVGwxp0?F@vUwdQ>12+he*U2_Ic`F&~AUMoH=9V!vSC@Bc!%0nnB znz6Sy#9-vLiRfuk&jKrYEm+Yz1FRs(-+~l0Ywn}Dol#nfW1XI7E+m0#FnCg7X_PK%j>4d-=AoAsH^n{TPvC zV6B&WlNAHx`rtw9Grm|O=ViaRIBT8dMgP(;8nXsSfEd>T6!OlY%i`dd#brtHy=fD4 z=D9P`mvLcLd|abx%(z5hc_SK^?ZLq)mI-(p_P6*Mf|w5e$OaJ%cj#(f_*4%5zzepU?8KJ}mdCU=y%|B#q>S&q@#eim>b?{C>0ds~wv}VAnj6nR!yGU4 z-yh7;(Ippo^FZbolsm*jcs2BY^^>=~^Hs`M9mk{bB`1`N+5^MVn4A=7m)BNoOY}AK z>NfW^pTgaHEbJ70%^%4unzg6+ux+Srt7x9 zSzqP%AnQ_0)qU0kOW#q&dy{vZqMVFF4kQJM)>{16e|Sws@tHJc>h~clPerS)rhh;W z65!q=q&sL6CweDI^$~kUHgIodFgK|-t@aV`PpDQCOo}3Ogq${M(ral=#$q+^Nn~1L z(|WB!O{k0@%0@z^4WW`Qq142g35AmA5Grj(^W;-X-<@vSxx z>I~JcBNQUAQ|<N42KK6=FpWGsdbPHC`=5^Z%`ZrG!`L3l+WT8=>p659&VD%H}!`w1mA@E(P!Vpbn(0AmEPV-{9A$Z#;3c^UG z8TjGZ5g1>LoHiIgsTz!*ti$+}t0y@r{v&3K9!rY?2klk9VflqpTx@ZNip7*&VQH~4 z@KL;E%UEoLsrB$Bk7V;Fu7>4yF*;f#`}~EPLn|Y~B%Zj$biJ zA7L_rjRAhbyYr>Vn7(@YEs=3 z9U-O`8)p$Uscx;*g(7dfV@-x~cJQD`W7A^XkegzwGTTw2%kdU(_gS3nB^m0(0$|`8 zI9f-v5P6$R^#sd|nNi44&ra_C<=?#f&y{AwUC~k{EK?%or};Ko;-Otm698Mn7*U6H z2Ev>ni?~oF27jBWBC!}-jB)HnHhMGofzX4C*Ar4E5e~`wnAsSp9AR1GYI&$o_ zmOTha!$KjD1m;TiKKRKyAN#dGTK(7e-FUNuu)lxJum1Uu|Iz~w?ckkq>IT)cYp_B@ z67=v~c+ZEqzTaId0LZ7; zBt@h2fG>f>{oQafZ>-c>c0a0lR?JZh!B=hjJ;foZ_aBg)4O59K3%yuFCIF5`1ewHy zwoZFy+MsNUbSweaL@1PgyC65v%yRj<=G! zCSdL3Eh=I`99a{^U?YBhlLv589MrJ>b490)EYy*i#f(zui$}%aW=$9a1joNdU*}SO zM5d#&@6MXg&m9xBV@Dx{`>NFmTry|3QcrSmbz<k*HHug*zm2Dp&7j8v|ghgjmO_TY8c$%^hP6+<32ObcZmhfcN9TUNT*UnB> z{pOFXNlSh?J^Mr_(*$7w5Z+3N|1IG)flO-cC9rHFz){_2&+z2|e>k`aRSLoh+J8KO z$+lfq$nmV6VGM+UVTWh+n+aitFav*WGf-L%j`Kz5D3?>_lZ`marBmut<{OPDwU4&L zv;a_~yp84TTORUo&7MJ5x1_q|j%)&#Y{RBd~){y^A8$kERQuHo`g!f$ow6a-BS|GI!QO4}&#et5b z)K)~2Vy0y9=Wa@6tjNaUUlqU5AC=kZQxqo3)VSOeh26D7 zO#$>OKZ*933!AEPP!m)_TiQ+t-jA)DC|*hq$CUdzuGfC^T0uWJRU2mXYsq&}*(-ce z+Kf-lOyx*kwg)@GW{QgUfmVadw+-HE_QK8_j`m`ru@_^Fz34+^y)*U9e(HLAVX4W% zUi3%W3v4ePBvM5RMPo1YoX_~2SfJ0qCZff#q8#-hQjCjoc|$qN%gy$PawTaJsCFGY zF`*VjH>H_!CtEV2Lq%DMvXBuXbK`PeO$;+S-Ds6dfoo$5O;Mm94u)MQ(IEw(;vtmguE>7^&+7$@$=xQ)qEz8fpyu18l*X ziN<`o^6+qPv5j9rT)*4wbfcz=Y3>OXTY&0+#}Zprf^1YWm&fEsrR@b1AS(fj#O%g0 zm3ttc(KFgMYv|^*BkjR1N&?hxIxU4^4wLzG%n2@sSBCC>uUkw-V6kO{v3!yHdqe}@ zUS5NPP}1o$2RpSaYGP+~h&<`ww4N=#kzQE`<`R0sprvTA_5-#IaKXNpd>54l?+hf@ z_aAG0|0%1A_2Ja2(my?Kzj&jDZBh<&LdyADOL^W#g?S_8StI2=^?`@ZEagd+T$<3f z93I%fqYJ2Ck26?qt}hX|iDt8Sl@4WFi5>9gDmId9aGlhu{N}v9zlO;`y99eRS2$?#h9~ju?dbdQr}EH)&fN; z2xX=oezXk$Rzxm?z?Zq?HY!;liGyvrLnqW#j5k$;M=fk^9o3d@T_kj^*G7#yEK|e0 zHv!e{A$AgNC(xe_3J(n9?C_^fcH0}`d%a@%1K5SorQGq+&~`Gz4_+|Anec#x)B5t(|GDZp=+=@L5*cBC`K z{x3mW8yY#;iP67Y4eJ*@X(Np-bAs3ka1Oah2U{k84rh z;xgErxI&<<4ZUxF?c6RxGp#8y1&Fntn*F&3Xw0FK0} z$Lu&VdqP=hWB%ZH^-%qw-NS{~Jrx-bHF(LideX`2s5JsxGBBDmV%A>5e{JU8JP~xh zOxzRYgvejkP|j>NU$2>y6~b)Z-TMAkeJ_3{UAN3tLjw~GUl*W-X`6e3pzK`PD|el3 zt!!N(pdKK{Cw!S(tNco*gr{VNEh*}$hzV+z*TeXF2(IQ<<>qOprpHrDb{QJ&EbxrUvKU;q%pD?ZBbKoYrZJU)>Ii^bp3P!;A(X zhkf$nlI-=Zs0U>b!8>G>)o5;d9h#eNqd8kE6=hfrvN|?WgPBx-tVNVBHaFn`19J55 z-!3=deOV`!zt&wAaIck0NA5*%MI4cRL4cq${GlNG z_SCGN@`w8_`z(|A!*-to9CcsC`nCM4sapP-PtS(n+9fQqq{KATH-BOE9*Ky= zZn-kF$d1sf!Vac>>ktn^mNhZ{bwMBq(ef<9M8Y+}>v#uS+PX`#t;NCYe{$jijnLs$ zBr6mnk5lo7;dltSgii~(V5lwRf@Dfd$B>IDDPW{RF8HMuaxtQZsEb_II_ko6qoOV} za}H6LagVy-R=2F@SbO?n8ge~-cb`EjU}5L{t~K0DDsb?lRPb`;bCn9q)V?Iq!$B&@ z;%!NVPNqRBRIHH-m9C|N#H5x~fWZ~%%K(!U63)gP{rRiW=!}I_W#XTpnlne3(1;#k zYLU?rq}!wn=~j&-Ov=;3a3#fm9gULImt^n>^#u;WP-#_-kLl>ds)ypS5P6m8hOLaP zj&#H^7{jI$moZHJDe>P0^0N@zrJ>;1EAl9XWqm*&`$}5|;*E)`ECam^i@)9MVAZT& zLUM>|!oXtb;7y8QsU;1hUK^DOk3m`4bZN$4r|9{pJuIU2YFe-R z_p^>qD%P;zC!}8W`=-Ki9j-zs8Y@@)iVKc zVxo;!G>qt?pfo*{8$cM9Z?wuGR~$WH7wR2__oV;U6)NWc-rU#1zz zF{XJnh9Pn&9amZwjV7M#geRqIgJ04dN5G?8xO}hWXRX%gh+QRi<%aKkp(QI=9X}}K zMjbHwnrcv8^w^iwtH<6*hL_2mFc0W)Z}M&Idogrq>Ri$D#~@zo$IF!oLl-;a+7NEK zCvC7rljaS_XMD!$K}suH z{om{<_xZ`0qeaap7G^c6J`v|A_{>V0$_5`&Sx!e!15aEeNyTlW>nXQw>5$F}gjiN# z^#PeQl_>T2Z|iaKf5L3r2~}|oEVTM)%%P~)j{J({?+kuLkNdkz#j_BC4^jstVb*J8 zUTLtyo^&qp>E4}M+oN-NwffmLc+Sq3A2Mt30#pmFhI_WapHrHU8oVVQdQPtRDQ41#XL&>s44yD*i`677JkHx4G1OE8GDe}>Ja*eE@#CTb`Bxey*jmC8);;H!K+PE51Z^Vsy5 znJ@MR4;6>j0wJnF6%Kwubp>YHDsGpBAKay@4lvYNITJRt-r)UqM@Ohpxzh1+L|$(F zczlQ};1-RQ66_T6L81zNR*zEdPWyz_cu|2grldq|q2SmL4GZg}K1IM$nY@*hcS({D zsPo^0uxnIQON~FFnrwiqb&d_*qn}7=>m~$rN_S??4sN8Fj`93)tK-WUw^y%Td!4dr zHmP^A0L!rhrUwXztIw>RVV$w>vc}`DVL4h2+dM!ppBV7_S(;E!FIo&JbFBCd(5J{P z;~U-IWSGq*#$e)A$Rov0iC_m99|_%NEfCxKzMC932o&L`mEs$UU8W4G0={uULKzDP z56mL_*YA>gCLE6rJwFMZTGzA}wB4&-P|G+2KcU|qwBNR`+l$&s40ig+b{DdBuzhfg z8ZGIc9c80_Mss3^qwGqO!S|_1;`P~9SWa|ry8$s|A`%%T0UU738?0uGN2s~+Rf=n~ zR?0R#%ENpxd4pT^m_%HEG_A=jv=)aU63T1CNHXI;7$|L11YN-pQ&XXD28Ub?O9 zos!3E_DHf4YcKwfPQRO0W#rw>AV9o0u4PuHE( zUlRplQ04dgsN`u{mC`(^`JJkgZ?VdTO0a>=<{R~>4j!_!e|@nGe|<57Kq7N{HqtO! z+a6$fRi%xM9`nf(`AQ+2D5FMY0UQ z+vw^;=KSr8EiR_shool4_#?Y1@b?K56(#>Xt6p z8;u9(!_)+Q@FS+$WJ|L>txB_QrKq{g-%tcQow;ssgM?|5dN+2K%uzEfXX~@XgFc6f zd22kXlu<}?)ows)yBo7T66$GJkDRn;#Po~d%!2O4ph(lfPqs=DUXdzG2k)|rH0)cl zu3?6~E@|07c=;pMjfOorf3+Jwk!2`$LN1&VN3l{Vc7 z>;t9nm5W!WbxW-A3+4MCQ|##G*(^&4b^KpmEzZ@tz6{>ycf<}ffbq))cenZ=i+r{| z@KOmnS>2kFw?~)$UBXJ#>Z;bGR!i&0w!+Zcc3sA9wD$aTx*Ip`%BLCpH+4b>nxMpZ zKZkx*DY}syD^mgY`hA=xZ2sV9>ih7Z9Gbx|`hB{F4Q$=7ao(kx4b!EKRH2)GNWSgh zhpj$XNt0N;VDMY|O*kfzC6=yC(C&*UaW<%$c+0urmh#Q~kS_bCAVctd0=6?fJNSsI z;}$xXumaUoFSu-mp6elb0Sv$^WWr3Q0<^rG#7X*`(<@bNKqukxA&#IvaI7E*z>uD{4o0H0;Krtg`cFrZC&Drau!w^fEPSxtGPnkPHub0YvxmvyH z)u*mwNmCydC8_u}ad0lnLs_CD$48chcdag}9?JZWCZM`)(9rR!I`XOwt@JN2wpT5Zu1q&i@=*a z6bP&xFh&WnU!aC@IAd&4-p6l=(wS(*U7MghyvJzpL5bW`5%7xwG_HOBVH#p3#{Z_z zc{-*6hgWoXM%m!Qs-;-4DuN?=tNH0HFA7g}t__hztYF*}nN|1x=o&6PB6PChu212gpD?q z)O?G#u*fOzS&*fxz5@oz#oFQ|XAFT$os71bO%dUi6Fd(g3pxy{``yqo3BW2&3?gSD}VgE7C4T zwJ%o9Qt{1+TAoS17Sp#9=7EJ$V`e)jUbm~k$vx-}&z)-mB7zIL$#Zd|is8sVFb=tW zS6ajwS@|0#;t3irmX-9uSCJ;G@T?2Y&LSKiNmYBK5^~L%wtU?T&FtT}|U9$d<*+OVuAH z@OHS>^UHH%osJU@;ZP;sUi7@|18R=g(pvZ&@RQ5$)lWW8>yzepD7eWk(Q5Suo!iu@ zjcg-EcW* z%k%OvVcA}?SY6D+-Wy2mR8+7hP@Qmt;CSDuL5b@aHZS2G^6mpvsSa)@7_>wJaP?wK z(V7NO;D#*=OKRTXoKNbwjCzja%oBPbYcr|--D34fevB`LHu#elKJ1-@8fJ-9L6=f2 zQXMOM%T;#~7(3&gO-zM2kKuDVXIIg|}#2 z-PSj1RfS<7)4aDJ?#GGUpBPSXm;vKcKzl?#%ic-_C0J3bn7*auW7iBP)nS`zI!x>8 zF#oWU*F;sldICi^l#|yU8|qj+b$Lt&6aXy50@E5n>?{4&26}eO`edu?l=2CncZ!X7 zXu-fHs_Tv|LNqi7W8^$%nkJlPIyMK@ zbNEA1HeMTwec;PhokpdjmP?;;WqRw$5|@~C*R0NikxNYd# z*vO;P&Mnjal24V|8Ww=ak8*NwZJ=w4ClipGEYRVTb4(g@uQKWl579#%jOf340By~| z(GVn=<~=MonjU}ujfjc~1#Ep36+VhW3EHY(J#9|=1&}Txc6unUzCx^g@@mpG*;|2> zV9=MU7qTCKR*Yzx=Z&m7(Gb|IlBQTKP-9X(1)fMxF7fZvF59*;r=}+()gy&j17hu> z9Jg5^;lgUs46(()-o-766A83I22QTN>}1tD#+(j@#buoQP>yo|IG0oEFWI7?l_EP< zn@tMK-X%n7q86oc>X@dvOPjJ^Gm0Ik4kKwET8uz9FBjA3nvvh;MG;~xVkOQUllo%) z*2*yaVM|YOcYH1UkW)ium&5sUs5yO^MhEd$7Q2WJ>M)N+)3=NJZxcl-Q_6i+(ciHFHdhx*&!3IG5D!OXURG@ zTpvpRaJg2FeQ8^xt7&;SVvN7_>WkBeB9d{*n~ZkY`Jxx-tn1VC5Z=@a6wD8mc+$6^ zcRNL$q~}YPrDiA|dX$;|Muu9fT?2_m{l!ZJIvJ5yO+#NCxL`kE4K5&-(q4(5N@|#9 zQSR4SHk%;UUqQ&FVR4K^B&DRb!(StaaZhfJ`vxMN&KbQHRkhtaDLHyx2kCSx>kw;K zaxdZ5V>5KeI8T7PQ=nSI)dGjmU96N4SKNwfc5jhwJr)We=qoWG+u);y|5?GfY=*vw z>qCW+upD+MKc7_&b1YE=zycDtjRePdc~e|(?3I|)Z=#l>`n=W~uHb8g9w_=;2C388 zof(9&bIy2F_Ko4Q;8XHKW%g1Pp=;#uf|jQVA8~J~gywbq{kjmM&R~6vm!YvyT8u;; zs_sj)1WQl+Ei3F>D3;a@5xLfI#+JZ8-gixhj;o$fEo0sj-%-Ty^uj8?$wS4G-+d~3 ze|1$2y=X?OVusrLR3ZLhajS&L91qa7jt)pPt61WDnViW;yp;D6*yPN?ZQ+~pnHdJY zgMZMPPWk6D%*a?TUNi>sRqG(JhIoLhP#DoYEkB%j*$I+mke&IEXuntMbN;7uPfn<& zs&g@Nn>4L>%_o5OcoT8b72`6(YF1_ENohw*IPwv6X`)ky2jLPE62~F6_(4EPN#X(M zoIx8phZH)c1;bQhTMF(FAZJTUr&Ts&!^{HYJp<%2((qfa5+DcO=&JyE_$ol&s|3jH z0ye~p2#}Ms%Jon|C@(;6SH=X$4G6HggSio~1|Ui@UTl261ZXrZ>)nH+;~*cN@p%@#>YYHDzRZYnAV|S%+J8iPNBA%5q5nBM&Op6z$#4~`y z0F5WPCXk!}2|67>gIl;=)YiKMNWicTq>_xdHP3s`q|%`-#ce9-H$v0B&Rk?Z7eL*mAyCMu z037zgsg~-i(KZlqv8v2#fUJW>T$#ygJ9G;3HWjJbE2p|-1CBdtHtNyL)3f{XuO`ZF zU)T65WF0vet})J@#O6JZg4}W@pR9J6K8N2ZEB<6;%o0NtPPexX+pf%Bbpw77+@h;Y ztO{#k+D2x!Vw~WCOjSoY0Pm{Lr0zDLo|#hk15#Ex;9#`V01`5%fy@kp5KI>+0kXrP zfl((oJc6P4i^ra3sav!j0PT4771X^B&@NXmJg${WLjf1C{TlZ_9;z5b1HuW>N^s{A z*HFG&6;YByR#?<8HSS(0IW9$Q4E%-a@f#`6pxee!A@Wuctf{8 zA_DM+Hdi8WqcG7r1SUiZ5?Qai<#D2+-B09x4Yq)42CRT!l4D8)e5E&EV3#;ByNL;D z3B#mo%sPl1s=&H`US?o7$&}~&!UVR7>)~mjPUy`uCyNpT! zHVFfw*wZY;Zj>;5fhb=B2~+g5&#r!n6|u+-Vg+b5s&QA0m8gh1r>=@{EFmIfG>wqq z*2>MqL6MM+na&@x{4+WJJ*oY^#Ei+4m{AU%k(iMPRC`5;nR-DJO@q^+T{Woi1^$WJ z_YTt=VFvZhLKrr5aHO;>Y-|=x2-@am-Ao{`7oh9EAT}nl(_&&m%*4UO#H1z$Cbn-a zCYEu?_DDOJv$%nYxe+|e&6!xD+G1kr*vP~XK=32@U!q4;0xc%q7*o=L4RuTjX58wh z5><6X37=p+3*#Rc3vY-i8PgK|bobFF1kajZUCZtsCh=g4J}E-2$x=waPQd6Fe>;(m zv!VddaT`riVHQS|axpc-Ej7t91w!EVqCVg*q7}WZxoZw>ezf^Z$t7Sd0X&mCmwsoN z^-+VZ8>)euZE0IeE-;~mROK^gL5c8HA&j@&btJLqbB3ST;)<;n< zd<(D|k7z^BwWNVAWeMb@heSa??BNx;+4&KLQjrY;OkS138V1u-D?o7;m84QsfFj)6 z>RAZAiok|m<)dqs!yqL_U}Ic?jdAQ_%EF$?h;%$8wzvEG%>#y4Jg{M5C^{#8^JY4e zpcXCwnM(AapIZ>lD+(lI-Le}>Wa*qHz(3`9NB%fHV^Fft0;1LE%!t@G1i@~9-jKi>5olKw~C$KObtTt-WG6Z)B zUxWyz;a>VEqChj8n3T3ODpV4WHL(#Qc67p6N&=xjWtYj3DG4hNn@*s!#!KQ?jRZE2 zIoJU$n@~s}0B(XlkP?;nV}A8qsE>y=)M+jmo(Ri3lw`xN21%ZmTT4A{UVF|pM87Z5 z?B*ZOs2fV~et9)*+*dQ?%(zwv%X5G82&O_Q$5@d+=O7A41 zrY-|S0nf0jZ~xdpG8&?NS)rAg$T zs{?^V(j<@*1*9j?Bn>_72BFh z@ygVuhtO1q#Aq5PvOc!j67u?{Kp>+V(|Zu5ePrRfGeu)T12ShWw^El3(%CoQWyWQx zp}@8gEl{->SXkK&8VuagZpu8TN;7DxNF2@Y^&Fj>In$lM(Rt0$Tr-YV)cK3!Xt+-} z-&wdGwZh^ zBLim>*OxjKw`%YhaBrdP8Ac|=kb3$F-IOzAbmAg(LRGTMf#UU1o^B#BM1g#sPOwfr zNOP5103aRT-eOf2#lcL;=pu-cPo#roQ6)EvZ+l-Y=qM> zsgnAEWtrGUJ|_QFi@Oq7z}6S!kZ}p-wA1sG;_Z`UqIgRr!`gGkV`;UA+FQX3rQ(o! zkSg`y=rKOYJfJJ8-j26m)&SOFCZ$%~Ft6GPYY>~zxy=4jAV7?Q5*=LmCVK@hRq5P1 z94x^Sm8Apr2*kmLGfajmCB9r`#E4!G>M}5U5|hQ=^1(l0a-v!PA6eF9t;6FzJY+Wy_A1rKrW7n z%B5eFHbprQP(uK_ArIefK)xvt#|37XY?y(y(Ka)Lbm0*)mQshym@TLpE{TH1y++V9 zSwZ<^Qe9Fd)vJgZpj`DL!~7VT3-FL}thz|Ze|_F&GB97D!~sL=6}Qh=FoNSgg1b)C z#$US%z48G3xn`-e0+x*=dXL54sU{?7%_9Op39ABiWrQ*`4+JwN?zl9 zzQN_%HLY$1oF7Rf1<=}E|M3ku;!ufV1PCC>; z(Hz^IaP3!#z@Cc4!d6?Cg#)nXP9V8OpzM`FTnoV7qEKH13j^$>XPjs2Q8#4Am&Go@ zlr^yLl2;vmBAC*Ey{)2fOln|C`_^L05XOpw-oTU%g^XKkrfg=j85iF+0(;4409_+u zSPFc_7#1rBnFS6V*ee>jGE@kpqvntbNje=3dkkO|h0s#5`ZT=btm_I3;#>0od#y_l z3=QiXDIqKpf(c>e9>U6F2#aZPy_oOH42iT`qPp;P6H9R9dxeSV-SZU zCW-aBIdv2JbC`)c}0kIGj7cSBFUui~!#z&;Y););hsBn)W31 zqKsru!WGD6-9<FOB|n0}ySQCw%K>NYFDHJR`LzK!zL1vKwj^L%6^=?abX4pg{r zDWu6N2(DuytBY)qg4YAy=#>rr#1;buZ$J@#+a`)$ScZCWcOH)=#fs259#g0mSHf_& z1uv`8_gM__mPH6U2c%ZM2<_}4xW5tMb4&GgQ2vl3A=uaDK85Ar(^(y=91Sv!mJatl zW}3?<(Cjen$~+}nK@pseWa*eNey*=lVJswEu}$lLK<9kPLTADVGSM;xP+oFQQI_p( zIh@O~0YbG7V^oA7F>mX&tHwj9G2@N85ViOUO{)-B9JYuWs?w~fydy@&jw;Or=|1WS zoLg6=_E6ixLX zw&46eDH-(hFVg1d8@60zk-FAtJR+8ks!M zF?=xIA*@Y1P6mHWaT=ny;UFA|a!M3IDl)!t2EA-Qp=fxQjOAj}cTwNejk*(;=MX(L ztAFZ=gP8UE2EJT8WJxGU{olNMZ57$u4%t5VDSeGEh4^pX_x{wZrmn|7G-~lueR8&- zd@}7DG93ol0q>T=I-c)gH5r^j;G1det=Xw5`i3jW9zNqW)>n7yQH8Bpdt1~VH=Qk| z9|Exx*WpI>S8V%f?--upIEy7;c9JQeZ7{yRWcEdi*x$e+s4ti4aavKuT&H5iX~wLV zC^~vcx&;o$;Bs^dzw{mXfNNen!?f|%9(J1h8O{vP`Hl<3+?k$#z6rijR-TZll)nyA zH?3r59svp_>_H=KnWDDsbWffLGtjI4)o0-MDa`>z)cYwfObYF%Z9?LkB=A>~pd&$OW8U5{UAzH_G!RDfs8kDMHsf}ZD zYHk+%iHzVbo;IgS8YCY8Z9S)^Ih-o+hSkK;I$}F^guw=M`iD+2J>pCyUzw>04@)b! zVpF9;?$U8|1uaQU+;+WZw=y2iDtwMqH)xl|D!at`f?d*qM_3M(vdk4qfts>POJV#Q zN@>GNNnIKOm&w!<_+bRQlCfcL8)bXnz~Im@3_tR7e1H+tuV4miF$jRe7B&eZf%2sC zm@WZbcTM@=w)t! zgG*khxh+sllN_-6u0QMjZvn>l_fGyNCNG>!egiP2+k>)y=TJ4*4JIS&PVUBTx=`rw zs{Tu5^FpPpO>C@G-A1edJMzu+Cw5}7LOhlRuN9Ni-HZtiyqg#6-bnE}l_@^e+x%TP zNRqGbqhZ*rMk|&d$s{V^m7bJTO}E~nX=!J6v4$p@poyjAXnf3H-vJ8TBj7NJd67z) ztkwW3@)Zqs0lXw&y}ft9!n`9$reE)pu{bZ+&8cl2f+z*Wft=xtE#pnq4%lh+&ZLah zjg07^l@`r47T%0O(QJ1@me2YpxUMHmsVbe6ZZIGY;N&TI zob;K=l>?CKBi(KXP)TWmg1N}D7lSllX=ByM!LYO<6?%U7<+?RyYrkW44aH((!GVf; zv#y1nQcKg2oRTxvch{>&*9RRm17C1`e|j$UopqEWYxz*WdHGy(M3)o#O#hbY?9Q$)8KPU8K+|=2F%7T(!d*x#FC+r27=4*NR%nTnl5yqJFud8dX;`Mf46@ILK z{USSyIM!CB%FFCyb#+hc;?2NVDjhs3TQ@TUa{z69`7gk4sZl27(3sn~#Vwhcr}eJ&CQM49VfJBw>Y<+BjmDX2PY3=91!@ zDmC3)5{lEML33$}_QnXc0|c&CV2=1rzxr2s@8~He`P25*%8)@taA&c4&@Kt>rDI=B zxxD%uSGI(@V~=JTW4=3`y)>DqrWCng$T~|OO~6U`JXE`J9KOUoV939SJjJi{^S+%= zpY1U?ZWi@=g_=-6S7@It(Wb@fRV=;pdNPQc=cZ%C`c#vPAj7e>A&^*yWD-+YQYcLh zpC&T*LTvc569g46Qx1V0w1eFf&ZjA56re(jy{r6pYuPrOQN+Uh-0}ySA7XDb3xVte zDEKdz1H!U6%k_H`%n1Bvmu$h3j4Jg>d;v3g2$^cJ4Ndew@sXrBh*hmp zUxTH!&zO2kP>frYf{EO(UTo{7)kBZHXt8<--HHrUtDon`Bj)Avqt>K$E2>aQQcBNp zYb7n5WKsOF-VjwKb1_8mz{gBu$PeU=o7DU0UySK+3`Q^`-qui9sfX{YVujlU8AgOg z72jhgAGVG!&HZ?bx!CL$#NX@|BpZ3FX#k{`U#31?jy0#W?kI^Msh)gtE~|d7&}vL3 zFZDTBNSp}^=nf)k0jWOTfC{6ldC6ERZfAtmjmH-)h#(yy4aKoH3X!5JYQ_3%YbLmv z1XpC?Eu<_-@GO1SLKu_G_hZFsN|;IHje<`T%4i;D)#Bppwj_`L_2QTCDWOcN)m@iq zx~kQO5`7`!zz<>Kq`Eo1jPG}(mo0}`-F_3=CfqlDk{tuwZ)a;z$JfcvLmzLE7idte zKAH#eA_QlYpUtT8L=b9wO{b|RmQJ@D!oa;ciy*A0)2)BfqEjC_{o*?!8~$6;>6ta1 zCZAY3z5g8PbX7ZOwce=`Dl<l2-wM^=rKg_)XY&HX(R7Hzo; zWk_UI0YC*<{oQ~lEX4$sj)~uk+9SdO2r^ZQuZzJk3&*A>bBm*HLlN^j>#54lgyFjm z5R(@SqM3BmMHq#1N-$#OB&wC_l})B?-EdDebs4XQC3{P1p^y&c$COq&r>_8(Wb(q- z701VI7gt;0B0V!l?ira+`5r${jcwbu9X1)xRNs5sUH2!)m&zG{n@%|_9Lo)h`cAPN zPE8CScIu;`T~bc7m}yJDQ(@a2P3AO{X&8wQgRYl!Rbf*~X*9~$L3>k{YqY2+mj_fB zq5wlf14@eOY8Z|iPV7pis#S%iRLT*tM$SF><#R4DlhtSa3lSR*3)of> z=i$&Em11qY9kzn4>`{1bENi|8Ko3plDpFn4!~`;ex;}~`xwI_m3`xlM79SByex!hyMioF5gESe z6kURxv648Fc!T~CVwb$H8v#rrQZtM|3_`0UA%SF1dC%FpK%!EYu9{vR(@UFJ-Qd_= z#h?~<7)i>cc!v;rTN1R*HYhr&SANOH)J3axje1lAOdwPrpX6q ze(9#^Jo6Cc^h?nq+QRe-y|J$finCRfb+QW|vs4u}fp_v#A57y?eFR1Ci1}(S2-*jnJ;{ z@}TbWCG!6_>N%a3#L)Yu=a3}&l9+l^Wug;m20EEqasVd+7>C$OdvK1JaFNH;;=s`6 z<;cBh`A^KzM(5|bFoQS(Os_BNrxs(SK!;e7H}gp=$r&qta4eg^?XR9mBEQTS4m$j4 z3Ikto9L!&(Yz!dj+AS6w&wYhynOJQM5NFV1JC=&32j3+Tv(<53w_>)l6u0u&+!PPV z8cCBuPWfLL-D75R>^hvnGMn`LOz_4R%NfYg`jNGN>jqf2AKX`*swX$}Tp*5;Tf}5q+t6 z`;-hF8mFaIQ56_%No>L!1%|VCYGxk0{#bRIPo=Z5w}A>weN0(nLo&<7AA|4KB=Lq1 zW}?(Uti`#k=>w*<+kAD=sOEcS#UNzatvTb0g30u_V`wsKk>$hCVp6xL6r#)NEg#>4 zbe?(CL;`GRqZ>(~$Si9nwqvBGz_?RjszU)xkM(n^+s`>7r8C8UAG6J%*&axg1QbU1 zNEH*`64=ry9K`Ny{ZeTpzUbrZpn;$O_#rF^f6yti%qFW>UC~@+^i*A(*o5N(Ij)oJiLa4%KFD>heD50uYVF7}&VkI%sn zL}!F;ki%yssRCl+zcHrN6$sK!sy^|6!7cKEh-{Mb9JHo51oVjKC~ zW))`k3U1G|8!`{Bjyc$n_@?{;CVpBn-fqAkbE(dh|dRnYTb|3XxnBD$1IQL^SjW^ibC>5M@U6%v5t} z#3ufMX+AcWLFq~UPB0;av5kuf=$h>lmrgPR3aM#dMr1S!w)l}G(wYTTXZ-_8aNL79 zQU|-BcTM}MDw|g%K2}IcRcY*r_9ZZb3Y#b{OXw7X6_d$^VZqkvx{CN4Wz3`~w4*6f zv9^LKe&LK*S~DYshiqyBz?##;$<7L?F)NU)1+VKpTd$44sq=`;EtXyVXaFyxm%|db z>wH&)Xq_h^!yGU&z*?PFAeCunJ4Jlj2Dwu5BcbhR#5xn=k`UUEYI-K5im8OO79^|3 z9&n?~0MQso$Mn1|l0fw9lAbDRXl$j&s_)&rw4iYd438DJWX*~wt82v$Y}hKSpmyVg zbsI!!02#El0lR5!1D+5nA{~=FOjGM)gIAwYEw~vdyD9&L|5xlvW-vH>VBDAIob@bG zum;^&gk;z-LL^2cL=Cz(5(3w=CWSGY2v>vdQRV%VsW@Wc?jP>6>BxCeN+6eIUt7O> z+^z!E&_Kc|vmE*;D4xN2noNge&@MnSt={`2Q^=O`F({4A>f1A>6v(_dX24y`Jj$;G z=;PN8C!!2vds<3C3;C@(Ev3NfFNzVRpv`3-pJFZhT()AhAv5>2G@HN)hiVo;B)}L#5XK7jT|B2TgwvY zt0hZqs9V%|nPN*${lEYyE7riDA_!L6RHrD?Q_E0GS^iMaz+Q&5PkNH7j(Bvi$6z_r z-6G_UK7t^L;WU1Sdt{Ir+><&`QW1loX?Ru#6SQ;+&I+p13b{ydzpeW`Jw-;n1?RjC z+>-2a5l|r$Jk`zH$l~$Jn@XK%SNw-FN@j7-bUvvq#M;ZVM|7j_dP#KLfH>7cE+8uZDi(BKJlnB=cJ6eM0~?sA_Oo#8no4Z%=5 znqbo6GnQ?`uW9`nYDJE^Cd+9I4)y4Hf=)~j2V*VHP2r|hA)ux+_xhI&r?*i6>sd~3 zq?;64Cm3@5$o(`&0FJ+GcxT}|MB6(?U!GNyTK_h050Nei5% z0~NyvHtWA{;Hdkr=hc54g;D*V7~TIf_S6Rk7_z#%y+@J6Kav+QYdLxjt302u39W9mp6Un33gBvYZL8;AK_QKs2Bp9Z+#@2HW=MW*OLIpmj~x2c53} zq@_6M+7pPGj)n6*d$s5ch$#Rl&rXup@|%o+=4)Di)!lOT6#vc5GH)#a&nN;wh$ehX zJnpqvq9-h+ISc6gZDek7Ny#+dGGllex&}_RH7!NA`^MM29sv%8UuSUZ)DN|y)^z%> z1yQ$XUZ=|mw!7#S3>z7Jng_=&8}`NMJXZF%Y4jXik5CvkUf208?ap%+il0^6?Y#2e zi*ifsJVr);HtW2&VzbU~5fco&^IN`%&cml$U`763v295K2)cpmAGl^pg3Bi3Vo0f<>B_`wKO5@1sz+}^3Fq4fzS`4CIxr#b5l&qQDl*wR(;Jj(JFo8QslW?W71NDDL0`Ra;8O+HN@|wrL9)7;`HhGfFm-ugrVFk`R7k-e+n#`(#(n8g+4R zSFypE4v?x3p5u+ps`_}=$;Qlf7!0hYa4$hlbq;LxiE$X+F2@{LvT0cU8rzTq%btrN z)~X`;^)NX$d>qu7uysZ%Qc|bDjwuD!z+!cV6j;`g-}PtGU|P<|WXFBQYtFWaWyAx#VvPw; zaex5GgInD$JvNo~u-4n1)EA*=oa(4A3(Ob?RrTYNoHU(^MWjF0bJP`F0I|drmd@Gb1|i8@?{Nz5{+HvCWp!>dC>CKGpmf*1_)v>*$8@ z7Z%0Q_e*q;GNrf);p03(L4joGni+|~z#b$TlGQ=pkdUq3RwTX2+C*A?EomTJeA=-& zwUJk^P~%B8w!&UuFGeh6MP)O}l6qmKjtX6;l$%~zS1MO2Zahi5vK~ozLUSaJWW{Iu z56e4LDl5~lHBMABfnmZZcuEDU-_^giUx4zy$h$Ml=Id`8HJj=bqi25KKn)38qJ1G^ zC;{jee~LW%y`G&-1Mq;ebEN7w26oQH&SGL^){%6`H(sYfRrwyHHV^uAL^%*c+seTv z3~d!sjWIN<93m9~1&paEHef8sB=aL?5al;#1|Fr<`qp6)sxbbs@&3=n3{m{fg&!Uo z$q&838v`FWXGncU&UoCCe_uLkYiPw<7_`=!4w2KZ)E72+;u8+jwpS<#mubX*8vL^W zu``e&9C>wM=|l$We}XeS|A(U-Nhn6SVf6>(AV~rpQixPBL4z*qM-~CJ5h4;M*rHXc;Xsu zVrFrsp~=0mpPKMdQ{mf>;pvcZV^r;lmPS?kW>iIvovOYQP3xJPkL?@?dd|f3K**Wf zI|o{R0qu48uva`aiQ4_6((ctWN!&5l4Li z_T2P{KGp@i*_&^g%CwXhQz74wT)ZJA(_y}$j0li6#|(`dGIU;YP6@q8)b=E zh4)q}{3L8=X+!xD1xD-l<25q)Np~ zS<*=zqioSnpf>GWs)5i0o8vMae#N_e_#1rppvr~RjcOu}0Vhdkqqzu6%5mHeHx@qLA^M`N$r!}N;5Mw`W zV&c>@4Lxmp)Mz0sa|-;jiS%O7g^ zV8^V|ku*v5zK`_JG0PkZ$kwxFU1y>H8)upSU6W=0Y`F^{k0&V0{P*p=0K6IaNRH!X zS?24L_}}}-E%H7Y^4@<}Os*wD2h$_S4f#4r{2WX8MUYqGx5=bzcB|=NJiT|CX zP8sg%RZo9n$@?$ABQl+xyd?S=C!2XH!mE&fo9|)ytA2 z%g47eDfotqk1kE)qj~jvao8Il4IzUK-DDKa^Yv=_i04Bzk#@N$gM61C8X(ohSu1|+ zBZj=NYWlgc5&`;pz;#9SO8mwLNQYAQGOfoC#4DCmy46XBFBRLX`=Bh5x@YQza|;uz zV5@N%Vfm0+Dqh?gotKz18t-kGmwM%VV>g+{_>wuz3q0A-^bdSaKz3a2x(=AFEDA$! zy)f_^y*QgXEs9~anOHv~+iEgCc`iJwSP(p`SOYyFA?AzZ*?O!t$AW-o=2`HSXoBbS z6H+rIdNb}O=~j#D_okhVyg~C+N-CAk8R8_-Yof~vG2ySvl6~yh3|% z3u~Rsjsn}@&|3yg%FY4hsUsfJa_!aT;`=wj>@+f$R}eM_Q&zJmF)e`xcPlt`n_za3 z{25@oSH(tG=(`DQ|LaXx2b=~3N`N;{J26a-QwP!hdYQmF`~T}D0tiD&+S5qqg}}Z@ zv3Q1s0?x{Ssq7yK(_Ln>Zyis!wEVbyhr4uG2I#LC6C@w%3m9C1s_|B=$Z)bPA9zxl zWdPEZbcu!VgruBw4$<|*TV`vT6;a!5kr}9;25SswC7oMr6FdOCdI!O+wA@k+wb;3Z z{n7fAxC5MQC!sC4dvJf2iy^6ZRw*M}Jczx;ko*?;dDB#`AU3O&3Stk+S>=vQGq5cl z%N{6vT`bR8=|VrkBg;H9i!&4oTS!|=l?rKZEX<82q`id)iQ}-wn$rx4g{=@?t`OdA z$;b2%r};c})lwf!#Y`|bNKy#b;ZpKJPdAV166a&NF>KCc(LDyFEzI9972T&o{GW_H z)u@YM(MpSxDOErYEx}rT5cNmh3cNv#5q8tehIk`(QLNLMeOoBtMhWaWuWZ+DJC%*h zt4g#+RzMI&;HL766a z%x%SaMEms#Jvc;&Q=5!>iDI*bzw8z7%SPF<0P||p^Km!d1S~ei1i z4rt*5CT;^RcsK%%T33V|JA<+_LxZwJmRY=kWCmtYDFOy!R|5v{_xg=3#yzc#E&N`O zX2ecmlpqapTiO7FQRSrx(mmA)flBGKD8bM$|OF z00usiIcRSxT;ZH7gd9I2`0wjr9B*b(PIN|%7=H0hYv$H5_a3=ZB@NlqFD{UGW>9bj zL;we#Es*!q*(f?lLyF(dwA}FH4iB{gBIcdTt0Z6=Z}v+RhlFp>(%utGt`6TP1(9ow zc4!~d=b1ZbM!dLG5-A9YL5V@te+lC4!EL3H2j%~HztIa(?^!fn$z$Q?H%hYH(?pxl z9845EI5i*#DlxAui%ymQ6!&(aYSBxor2gj_?NjSKH5%p(r(PDtUtPy47qa zfg^{@R~}rr3fliKxjn&-N(HAs7GJC zZ~pM%BP-?nzI_YFncwo|*DNp0?=N3+WZ(S3^4nI9EYCB`C+82ISSa_Oa568K^H;4b zESLKiApD7a>aKdRmg(Q!-_8h*?_Rm)$f5b;yDwZmaeU>((cLRYjvU-~&HRDG$9FTU zR~}s0H9x<+@0#a5XV?7Eqx{-GzkKb1!@Cb2xN^6i(C6{xefC`!m*9s+Gz-X~Hs@chXGSI@63JQ2eYjgHPAH1h1e@WS0E4j;H~_sW4o zkE`;Uh3bmFWV~}mIvv^n#0&!_I&kC_X6zpi&1x%NiFdt{H zn%P*MzxEX;7f{`gZtDDy ze{w$AJqlfYh0v9Off8H2?2rx1due%KMYpit2lg+NYZK*8fil2aA**UI*W;{D zm^9n@ba2HBhDkbHONYsmu|P+buP<$4<_#y&fwdPI(mNh8(($DOM~?~+UOB&SX=QmH zOnVgMvZ6~M%lVT>4(ujxnl|1*V`4wF`!JR`3rK#FWLbZDI zz~KWt0{Atx`7AF08J<Pjux{+asbn%f^p$J*SC%{1W}n_XF6^#~)2ZYsv44QL1&zdNs+*=n zgFGE+UQ#aXzk1=qYZe4Q%NM+$Jic)7stXTG2Zd|9%4ctU2-J2VAbjW3^~nC?EB@oi zVTN(=#MK85mn+vF;dO@&J|8GZ^&5M3VE=XH^UJTRURr(K z#s)$Fm8G&REUSb{vT^v*futM0$&iFB z^K}9PBPTSI=}ePlfJr9M10YjQpsDo zzy2p>U)8(s-R0bK&vx%Q_tp5s?(41{pV+ZAF^RS9$(e=h9L+4&I@YWamrT#(=4aE8 zHw95*!kWWE46S5xAk9simgY_^r007!Oh&M3!(b+NEG$e~D<;O?{rcbE`oG2NUwQ4> z`PrYPpY3|$bn6Gd{j=Y^Z{LlF^nc%T(+LRbg(I`sQ;;l??Mt8zT1#!3%V05tImf6; z4uPmn%$Jo+ma!ipiyt7-s`S(XB-Gqdmq4=1I6a}9T;NfiTrno>rJyYjPiAH@@TuFE zW)?CLxnxOCpG?n9(X`BDBhq}xMGj|9XR&m}w9PEotM5XwUD*S2k!%;5!G40ZKxf;s zcWjwHJr^0DJAH*M^`_EGIRH18or?jbSU^)ps|21UoM>)nq#20FENJ5J(h=|h@HBP2 zFjJdMYvK=NK`_%9(90|qxEM_K#0-`d)4gkx#F3@hSuDAM&vXPcbU2$sAC5BVGl?q6 z3cqzh>V_q7pxvU8{;M`$(8wmYMg*g(s`*+h13&f4<;|K>7_m;&Gl;=F)5k`>dmQB_>{ z2=NuWI-8S5+|nGV1m+#h$oTX$+9Ja)f--E|LiRWaYJLVt+SR|$o=;~AY_N$ZtZMlw zu$~}G^IMpqU9_E4$X*AnXN^)~bjKZ;DO*X{9XY7Nlr51YX5;n+PzBiKbY|y{vID12 z9L_?nNDPAfv-5KDrC!Jvx(<_%O|{bzDBU^PtsJI}V2nxP8#`E23p4W|D8QPZvu)V_ zDtSy}oq{z6yV0`77!`65A!#p9ZYvH@N@45jAx~V>n16a0Ih9>PgqU^vT z$q!-#5If0jIa&J`KsVVX?0=qugaKlU*aotD3MSDs+i4pgvVLCY;sL;bD#?+h1)CWo z2a!Q=6G!X<7cS1g0$D(hq?4xE9)KPpIU{9E4t4}ev;7O%#Vm{ud&UR{cANI>=SUMR zvhC1WGjo=rw~>UFr7D?b*Vs@L5SDND6qGP*bTSc%XYJPVj|nA(X)3y%Tbc*|fjuQ4 zg`7j;N75&VHK~RCL&-w+i1NHp+{fO`A}qV(wECF_-kFnkb5S|YoU`h=8Bto`);w71 zDq<;v!hjA)b(-K0Qk^|1@#%{6h99akEPxxbK3fARr}KAH!xd zxc{9?S%}2U9a9-t)0h@mGc(7s^H>y+Fr&^V((~!VGqbQLu-Jq34J|t}HAAdveU`*t z_9A~-{fgSaKdk+Os8fH&{#fD4sP=jOe!frXSHtZ4+NW8S5>}q$|3~q#|EMuOEnedp zEh+93-xV=skNQLH--%zTEt*IBqS&p~iaXe~VvF*c@(TN!@?Q1Z>`#@`T9=sPCzKBL zbLw{XCVz~5K$&De;)ePO^%iv_yMd?p$Jle)gM32l+ z%1y3vt)4?ys{Wv6aLy0$)+#Q9%DIlXD%_~y2l&7CJj%QA8(kaL2XO~JI?g@Ks@m$) zM!69@!ab@-RZK7m!hHlvn6lD7-?gRZ-7rm!8OW z0Sm4JJOuC+y#5eCVg!o7+ok=ghCBGL^PT`&splcZgKt!%Hig=P-;$B{O=``0qM;4E zx`WE&1wM2SzW|{cTAhxoDy0Fiabb9XJnpFyflZpB^FiuU;|=&*%@v^$Zi5KJxzfYC z)M_=vyCz0ZOAC#es_3SoVk2ij2Th17HXya(T(H*H1uf`LvrR6ZYZkI)c|l}EaKP6LxBu>#RPIi zBLv`sx9ej1G?iCwU`h5P9r*WKBOEn&sNQR!CHKEbKW9p#*U*Hf9?}*5F0N`D`7M=^ zc!;qvHqj@T5;8?izvfYHU+)!1#kDoHyuBgm-_Ch2DsaVY*E$2O${_bw@KDIq_=q=H zRj=$Ia2ga5!Y?f97>zFb-pGUcowdwgui||W;(%O$DKQdSgVrgj|9TnMlCG5R1epp4_9@mU22af>GuKC zM6dR@lJqlgo#|=Nd6av1=pc|@!nde5>%DE`Xw|GU(OdVZF2;aTjVP@(+N!j6dU#9o zFrHQis^)f$3}nk3#?#XGJ^gC0HJB5>Ou21Z{~T3I7BmAPz7*yLuk-9pd#_myA6=3HA;9J z@$i?2h0&;YTuGv1^RVDoHH%=a@s-rku{JOjC`j-yI19XWvq$s^A`;O&D9p;MHD0yr zsON_EHZPA6btS@(55Zl8vtIN=9Epm+m*DG=APMSwS-rQCZ%~vh-^Y$*E5FXt6|7Qk zq}6;s*8Odj`zpg#@sQe9t5vhu{}_ujipJjEYRae_1FU|-%Qi&PIR?Rk7Fh*vg>VR} z*b_Le1r)Dgs+=9n3Dm>tX@NR!>C0PDT2zlT7TfRuSyBAm9S(TM( z;XIGZl)j8xkX1zKHIS{{VgTej0xhQd7~di?!ObEeDtHS}>eD*3f5>wIG?efHZD459 zB%%@ZinJ4IkbZ(5A^l`jnnn5vjD|_Y^s2%SMOdVvWV;FpQM6lSuBD;y82X|XN=)h~ zK^jjrY#k*`qfXw3j&h!mj-r00HWK|ZiQiG_D25HLc4;G2)gU}MH3jF~j|GA?* zAU6zCLyV$JhiHl#Q*OX0cz@sfp-n1U)Gn{oCw!e6j6RHhY<+_F{9L3y;a7XSap)7g zJ-JzH?jBWZ#DJqugrb^;*BFbeSN+~$rByU*O@`_>_Dj8D^m$CZTWR%(7uuku6an4h z@wWGQqIFH(Ej9Jty?}1J$wi}K>O;KBv)((&YkXC_Kd(=G`ex14i!=)83cNxTtr@~l zG+(J!L4zLA`fjd;ulAUa4vX%N9X?YJRBr-p)U-5<3bp<#Tc!{A-TK7uHFuIe@ikxV zIS|-on1?(!bOcSEbV<-`MaX@zF7aYyjwaCoeXnZ1-e{06X+6j*1_IC~T=RXMO$C@q z+5|!MMjJQPcXCq?3*Fpln&1%9Dx03aNh@d-&38eaB0TK33e)Q=Dl7U#y?1fT>*jj# zTl&{pr(+b*s47@n6qpg8n)eD}}A7DzQ5>Q^!!Rr-%bre|d@r3aUUn1yT&pbg9 zz%K@*U`m8=&on;HkFL^LjLl;MAAAg52llzz7q1QRi~$J)8=#Yin{V=M@_KoLD%S+s zMrE@%+Q1_)r#aRn(9Wi4<(sHFc|RJ>w} zFn;w6mI7+_KzyQAg=DLNKw@f@EFOq`nQsv(oayi zNYQ5`2nhk<$Gg1zh_tl@6pN~a@Xkj8p;iGw#MBnBWLUsfF`|l z`CkA4(2`CA(?FK0}%3tRHMq{v9G_^-j`JbSVaR~Bj^qszhdM6*Q*nqK`N&@W~e5=x> zanBVH<4LTbJkWW!DgsJM@Q1J^k>h5}@wT;}#tDN>;ypQELIx)$&3nw}O=g}nRectJPntrX3*=0X zanw9!7}Y1tN!;66!yuVh4O4y!Wm|{xclrJVS5KOUP+vDOwYqLTKPWV|Wp5n|W1@JY z7eBS8e$WhiIAZA6S1|J_lj#qb-RQ3l^&tOD7W1bn1#9k7TQTYPXn$!kPouHkyBmw% zWoQM_H|9->u(De_=4tS3Fqj^v-r62Eqw2hIPxvl#i+R6k)VwK2z|12MaiRSy=xX#6#^jckCPF!LYbG``Ek5Bq^e_r!oilFxre6odOSI60CWO{Rw8Ly4h* zp~0c#(9qEE(8$o}P--|noERP$9vn^%4-F3wj|`6vr$*uv0*{UzdgKSs z9Adv7OV7P=Hhsl!+2Omyv(qES%FNmDv)Kna+dp}K|J|R8C)*!*bK>p?uG+~TWa;q- z*|GG4soTyxICkRM2VcDJ%tPlsKfc`g#g*mXeCgRU>?!^k_WkzHJo;k#5%#zIKivQF z%I98qW#uvUYWnl>*H*rSqflQx`{>JG4S!iY_1Jf|J+<=V_dj*ZtIs{fekGm`ztQnD zV-rvBuAX}OJ;@IsegEk@6vpCOgawUBzN;#tJXE*bQ`fc}i*_xKEN-0K`itJPTR-$j zhRm(m|*Po4P)`}VURdHg&4qZq?Szx{*s zr|x^{%%|Aju6*kLm+yYyg;$<^==Pu7{V+Q>{^|eu`uOK=cq9E7V=sU4?4$30JpAQP zKmOQvUVeP##~n}H^6KFy*snhQMEH&8o?z^UPwcK%o_tTT^U1f~olMo&318RhQ zRGVZsS1+@w`be8UGTHXi-pP&8_}PuwN0)oWXCtxjn~^^D*5vTRZ!Zu3mbZ^^Gda*Ibb(8we#$|mzvTRIt zEqhO{KWqNBchYCXmwg{gob^Q>UG~R68wsBK+;TAVb|hr*bZAAK3EkjHSH9t2shSL) zsoP(ZuHRp`(s;HZ-E^)w-MqhTrI~fLx5m?FS|@K?X`MgO9=>a?z3ms-blbUapJ{vM zzpu1?=LhYPv6Yp`xtH79&%Kgv|BJsn(|-F;+B?{5XFATkxzhQ@n*&|@fUg#PlHFRl z%pR{l%Y2atpH3`u;6^Mzbw=dAcLpV1RamG`WuaSD3YxgzGs%JtlQ;lzmcRX0L_8g8 zSK`St%JNg~YVLb!7y^%}cd@tCzP<=P`*Kr=zd^L-wC#1-jZ3);9lv7&4rMrt$(dXb zzvJ5`w(r<^Bs~j<;lfg8Z+3Afh2aPD_re?vrsxh?5^p_)zTYcJ0Ew=%`Yw5*W2MS zzK&d~a138h!3OWjEM)D7_?-w4K!*q5)g|YgbfxUDJt@>ET!4GX_3SwMw!w)^9?vD| z3a1iuXFEWp!AZYxCbwNWhI6QU4Lq0Rfy>#TPzCaDIv&wIaEMYD@Rqqe(+A<9vAtji z^C2-ivPS^_z9ZMj$hlo}@R~_Ck=+YiNpsYCZXZH877or$!S#4FyD;;f%rt0WA~QRS zU^~mlgqdB)p2|$q9u*~u2_nM)o64*biT21SRoQtzz9~+Ko>QEVi4<^s({rjvP)^3-QFlDjNjQ-9Pq)}FFH8^7!SKEr(9uL^1*b~|pu zqG65reuoia@5T2yq-$Jx1saQhaGqDAr7q+<^born-!}jYaeVJlt@eJ7=QXbUAuzZ* zzr)UxB6Qlk4p<}rf9?&*!MqnRb-MD$k#Be9Paq#fp6bxxsVy|ieu?@t;1ftOSH2(D z&8|Gb+u+I%;F@4`>JQ?Y5PU?0*!%JQJ@nV-dcGIeVORbDuCYs+uYWzRH@Na?Toe3G z`9;f409kdGkm^!Hul zsXr%w9PoF!^8eM&6SMturN75qI}Ap&;pB2ss79{#O{m>Yn z3x*`kQXlj|vutc6#Qqv`We=|FFsH;bLN3Y|3@IN-i>dP4cLXt=a1uh zJJLf+h~12Ds!Q#SAy4HAAH+M~M8Es+9mTkaPCf})^DcbTm?-Z&@4@p3^7zVs=$d3s z0*PeJAW{-(2#M&6_}#$~@xdq(@jSt~8EFgBR-|uWH-zSb=8N$C_$XniFwQG*eHZY5 z9li-xYS+nMgKL6wH{jlbZ^AL<>Eqz(N-HbzPPiW{$&VxNp3iN_J9wkM2~Q54y%PeN z=5yj-FySXQ-u9azIlnsq$4(^rTxEUb{}MgXXP5PL$$#GfZ|z4dnj6wd??7_qb2qLD z--LhXL-ep0=^r6;sPB(pGKmi8+6wjK>Bm&R+`Vdfm5PSC;$19{1-~{Cjm^4 zt32h2XQlx!!Sy2mLj3zCXr16+Mxr@%@_&eHn(rHth}LgH>fLP79MK0Q;t~4Lm?#ks z(Z_jyi29QtBK=#WN0I&$($h$PiS#1U|3msUl7StaR-_F`Nu(`EJCODx-Gp=uX%Xozq>mvz zfb?0U$C18<^diy<(oc|njie5QSP&_U)QQC2?L`36^wJc&e~NAn_9ggB0tXbx_F=qDn-uSR zADdL)hYy?7=nLN*Nuetx7+~1$Q!yoS7XsVT^E?nRCW1VU>IugA026!=o2p4{*bXs` z4>O&QU^8))d3cI>MIz2jkx1Yb9*F_w_jDnQ4y&3RXXY6m@UaNPn-xft_$uTz4tgu& zypl6tWj-HK#|8HLd|uoc#|}R81?*?zYNd)7Aj;ghR?>=2t=P{DdM4|8sJ^`N=C92B zl`ntgrz_UK)=co#Gch#K7w6yL7mZwTU62tPB_0_ek`X2up^^cYcrXma1Dm)Sr3Wee zA%xQ3g#0^zzXhDq+<2=wooTn`P16mJ*TjqQE_EE(fJWDPzp6m2&u4Q8Tg%SjT9e1J zap9$d7cm^&SfmnIc*y`o<0bA}P*of?J(ly#;!yh%LL_qrBF88|`IJhPa>sMW!wZIn znBL56PPc-|J5*YUclfMhDKS~jK*;kfLcBY@IZ%we*AbY`PRW2)*o?FEI#}kvuuFlZ$rc))k)}$kbsyAVmBfb`U~BUzk}iw+$eP=&DnJsuer%O7IU+G zr*9#fT|{Kas@qsM$Btmt%9N(kXGbt6@)=@MJa#)drs;6yGs7A^0XYCC7NQtRAd$#n zx{fFTs*Pjjw7RGAgF2i}Os&gzXFuyZObd7q-2I?<9dRr)%%s=~&qsYp<4$wTSfdr@ zQK<~`LJELI{4=x}5wniK5DZDZitYyx;a&V-NFTN^4hBT`uzm1%gfmZ@18z~3zTDDb zihDksk#J)_|F3`xy}1NlclXR}DS2AdBJ;JE?+JVMQ^#ZSs0Q;lmoH;I4Rn=%D)Ea$ z4Y4D$OSxk#c%dP&3LWu*EEq(<{t3h&&&K392!F$(6XR?x8Ax$C7Sd}_y`mz2+Zrg< z9}!)sA6~1zRS>~bHWiGDS`@DU8^&Dci&zyr>YN2soK;wq_=nR4{(CNOuxcIAp%C^| z3#UbA=425tW(A5A?y?#kH*osTE{DElK}1i4P|$Uo)yk5la|kQck0fzObOdZ66tlDp0AUUJLW%N)sIx{Cf)7cV%bt?(=V*>6X6!k6Rv+WY1noitra?an6!HPjWrOLTWjBDRiYn_`G>*nnHTC2Q2H9OOv z$Eu`F`?(%o{oJA{yMWP@FW@35`=Q4IkXk3ovhR9p@>S#n+P6&$L@JEi=)}J7E4q(q zvhVxLfbMQDPzLm(hJ)n`6ndy=w*iGT&Ec5!91a@b%>=P260bHCp%!1BvSNW$9<*m^oBNgvd5Xzv+-Mtg@T{kwr+=$s$#& zihycl5t8##is({`(AY_Ki`|GobhUz-lsogMB z*OQgnLq_TbGW(+-C012!G>ZAIF-3t5*DlhFtp;8-bo6Lup@WT5zz4`DS}Hm~Cnq6F z7IK#8=%l3&4lBc$q~b|QZ7@$4Z@u`6o&cooMi{oBO073#gv9Z6V9c-Y)kC0Mi)AcV!Y0%7wJH^Q#C*vUimf;{wp$&**8C}Bz1unb&j1jPKtF)k;M zJ;N>*F>>Bcq!*5Zur3iX?y3@)K^vIlxcW^$t^Kg_Vnz+e0))z*_R{MgFbSE3(`FNx z%3A-%taIdrg_Ti(8d{EC6vh?2^@^Ja;;D2OwpU&P5!)zf<*Wsj+00q3yQphzrpn^> zTF9!j3JhoU1_@x`_lhsHelrnZS2PS~O$K|q7!GIQ7^hW@!jI?72FL+7q!J=LR*ZLu zvdo0dc9-S z7+k!ouuj8I+DOU{FIz{2ivV{O&hc*FAOg1>a>kJeXCtI|Cj|SV`K=JeU>Czoz90;S zJOgcH#vTRxBR!>@5R;q`D@%EO1x|<+b3)8li(@3E)Zfq6rs6)I6g!q?b5U-B5ut4; zCbU5xBGtSF;z}|iuAyS$imf89VIOVGxd{sUg_zH1(R{{A=QAa%xuylbxj<|jx7fHB zY`orK;~3k5xNl|_h7KE%lj=G?U=f)t&aUt^l9Vk~7Ex7uz1a|j0eM+AB?dr#syoIz`%;y$VueFO1o0-l9 z?Mr*JIOj{x&%~Sz)|G|h)64|dRu^k0dry`d6a01^o67Vi4351@SX9{BWL~U+V@D`n z3pWkyS5rdux_!CmtzrP%huDk-}FE2Y@(v3G)@zC^KABa7pG$xGgKFvLAJa?5DFd>OB=UKIA9K>_v#w92f0d{#du^ZO7g;mOy7=nL4* z+Y%}N2!bxCeGrR1u9t_a$>9$PZedD!bJ+>#Es_Zq+G0-2lEv~!>n&nt8)dPw98p49 zS}ikLlw*Cjas2GZm?A(t#2DXT3O4FH*z-)kQL;xzDSJR2(92<+#U$QhM1f_RxCT;$I63V-#Jmnz}!cFgmO*iO?BJomqLS%_!%g10(tTMYD0_9M0 zx8dZ6UcjPCNF-8)lB;lAa=;e%_?mEv&6-xQwA=3i%4|`Ona6Xt(eac*dp>H<&jLOl ze2CNrs4=K5V53}H)qG1i>RD^xeQS=puRLXS;iT*NDMG7fMuRfnf2?mz=+cXm`z9mO z`qp{Go-7x$-UG2<%Uvty!I0S|YY*ogdN+2TlmKxa-pXWofN&#Z-@t2n@Rkk)NU8{G zbykss{Dkho3(*c|(+kro-s7?)jiSTp1(+XF;^75lb4z+ILoWcAF*%>3kgY3?zJv=(>TlycCTU+%ZqK>~!B2{${y z`ma;w1IL2-#ww^W$Yrwlb-MxZLZ$Wvh+oAh*M94HCUW=r=VnhaD@hRh*JLH9b19)E zLC2N;wfPIbZNNH9S*Z*kXov*6e=tZ^xO_WdjN)$jYlwx~%mR)Zw2$1Nyf`bi!t*F! z+!QPAro@>LTeYJMH>a}g@~b-=((1%zw^4ALYKe<(#Bs0c#h5UPvz|5FonE|B#zvUj z*N1PV;6;hirCHpNuQ=-;TUEcv;gUzQlJqo{dXZwN-ijy= z=?n`0ZYB#w&_(ai3}43f%*ZM%l`NDy3+wgKi&zY&E@>saX;trKEWl3N z(5--_%go%dnG+FtYK!9~UND2L=mI#55p1c_drx^|QGjcVYQ(H}HzR7q9s7#7v^$pO zaaf*J)w1T|N3s?|3Go8ysSLkh$ck9C1y^EZD&+!J)H3lZTKRAly-di5D_X@U6VL(i zg}5|JH+#+#tKuyes$!OnR4EQf$;(H7`8bt88JJy$${Il^`Bp2+j0x5Af;DvE7?sNN zhp0$;sm5v(WDvt1L#{@%Na7`3Yi7-*8lILm_QuZzBb3AiNk(ZYZC?_<-9Z`BNNE=w za7sVlfKx^pB20P85?sn#G^fs_q>4k8oViH(%8^UTzc$sS0%fQ!6)ZzB1u9RKqX-w3 zr$S|_7YHs@S)QQsW2-9vJU__lRV&GoeRfOMI8vi#l}sopUc0Kj65(6iUfrtpN{ZL7 zQb@&{kHyV3Two`wag|w6MmQHY*>o{0dj6IIFYX$PP=28mn2^gQrLbI5oh1^cQFE>_ zWiklKD6{hLlnrb4t{qoqo+pgVmya6r%OJ79g@eL^=L-X?xTwIdP-&=FWtqsX)qz^T zP<6TJtr}XS)w(>Lb*ufE^+n#w2D4xWHTvD=N>kov(2O#)Pe;z97TUILb+)V?G?ac8 zUOc^u{M`c9+9DPmWr~%m*3L3z3VU+aeyZA5rjT3UYPBfftX=XOA1OT_c5ZYz1!NFp z`KZX0hx(Dt*riHVv?*_$Q=lk#HDy`@20>w`g~HtZ4X%Rjy^K_#IOIH-FIW;lnyM%+ z7%DB8mu!`ODP`?FnW}oHXS>0fIcY_ts$1O;T$>oy` 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::{Error, 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, + /// `` (the id value's range). + pub statements: Vec, +} + +/// A Java interface. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MapperJava { + /// Fully qualified name. + pub name: Option, + pub methods: Vec<(Named, Vec)>, +} + +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 { + 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)>, +} + +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(" 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 { + 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 { + 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 { + 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, Error> { + Err(Error::NotFound(format!("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 search(String name, int limit);\n}\n"; + const XML: &str = "\n\n \n \n insert into users values (#{user})\n \n \n\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::>(), ["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", ""); + 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::>(), [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::>(), ["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::>(), ["id"]); + } +} diff --git a/plugins/official/regex-tester/Cargo.lock b/plugins/official/regex-tester/Cargo.lock new file mode 100644 index 00000000..5ccd74eb --- /dev/null +++ b/plugins/official/regex-tester/Cargo.lock @@ -0,0 +1,373 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[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-regex-tester" +version = "0.1.0" +dependencies = [ + "openidea-plugin", + "regex", + "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 = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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/regex-tester/Cargo.toml b/plugins/official/regex-tester/Cargo.toml new file mode 100644 index 00000000..ae13c79c --- /dev/null +++ b/plugins/official/regex-tester/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "openidea-plugin-openidea-regex-tester" +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" } +regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa", "perf-onepass", "perf-backtrack", "perf-inline"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..9eed421b1bd1cfc2d44d3ebc0563c16dd5dd95c6 GIT binary patch literal 930798 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 z�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, +} + +impl Run { + pub fn parse(v: &Value) -> Option { + let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned); + Some(Run { + seq: v.get("seq").and_then(Value::as_u64).unwrap_or(0), + pattern: s("pattern")?, + flags: s("flags").unwrap_or_default(), + text: s("text").unwrap_or_default(), + replace: s("replace"), + }) + } +} + +/// UTF-16 offset of every byte offset that starts a char (and the end). +fn utf16_offsets(text: &str) -> Vec { + let mut map = vec![0u32; text.len() + 1]; + let mut at = 0u32; + for (i, c) in text.char_indices() { + map[i] = at; + at += c.len_utf16() as u32; + } + map[text.len()] = at; + map +} + +/// Matches `run` and returns the page's result message. +pub fn evaluate(run: &Run) -> Value { + let fail = |e: String| json!({ "op": "result", "seq": run.seq, "error": e, "matches": [], "count": 0 }); + if run.text.len() > MAX_TEXT { + return fail(format!("the text is longer than {} KiB", MAX_TEXT >> 10)); + } + let flag = |c: char| run.flags.contains(c); + let re = match RegexBuilder::new(&run.pattern) + .case_insensitive(flag('i')) + .multi_line(flag('m')) + .dot_matches_new_line(flag('s')) + .ignore_whitespace(flag('x')) + .size_limit(8 << 20) + .build() + { + Ok(re) => re, + // The parser's message names the position; keep it, minus the repeated pattern. + Err(e) => return fail(e.to_string()), + }; + if run.pattern.is_empty() { + return json!({ "op": "result", "seq": run.seq, "error": null, "matches": [], "count": 0, "groups": [] }); + } + let at = utf16_offsets(&run.text); + let names: Vec = re.capture_names().skip(1).map(|n| n.map_or(Value::Null, |n| json!(n))).collect(); + let mut matches = Vec::new(); + let mut count = 0usize; + for caps in re.captures_iter(&run.text) { + count += 1; + if matches.len() < MAX_MATCHES { + let groups: Vec = (1..caps.len()) + .map(|i| caps.get(i).map_or(Value::Null, |g| json!([at[g.start()], at[g.end()]]))) + .collect(); + let m = caps.get(0).expect("group 0"); + matches.push(json!({ "s": at[m.start()], "e": at[m.end()], "groups": groups })); + } + } + let replaced = run.replace.as_ref().map(|r| { + let mut out = re.replace_all(&run.text, r.as_str()).into_owned(); + if out.len() > MAX_REPLACED { + let mut cut = MAX_REPLACED; + while !out.is_char_boundary(cut) { + cut -= 1; + } + out.truncate(cut); + out.push('…'); + } + out + }); + json!({ + "op": "result", + "seq": run.seq, + "error": null, + "matches": matches, + "count": count, + "groups": names, + "replaced": replaced, + }) +} + +/// The flags the page starts with, from the settings. +fn default_flags() -> String { + let mut f = String::new(); + if host::setting_bool("ignoreCase").unwrap_or(false) { + f.push('i'); + } + if host::setting_bool("multiLine").unwrap_or(true) { + f.push('m'); + } + f +} + +/// The focused editor's first non-empty selection, else its whole text. +fn selection_text(ctx: &Invocation) -> Result { + let editor = ctx.editor().ok_or_else(|| Error::Invalid("focus an editor first".into()))?; + let doc = editor.document()?; + let selected = editor.selections()?.into_iter().filter_map(|r| doc.text_in(r)).find(|t| !t.is_empty()); + let text = selected.map(str::to_owned).unwrap_or(doc.text); + Ok(if text.len() > MAX_TEXT { + let mut cut = MAX_TEXT; + while !text.is_char_boundary(cut) { + cut -= 1; + } + text[..cut].to_owned() + } else { + text + }) +} + +fn post(message: &Value) -> Result<(), Error> { + host::post_view_message(VIEW, &message.to_string()) +} + +impl Plugin for RegexTester { + fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + match id { + // To the open tool window (its page ignores it until it has loaded). + "test-selection" => post(&json!({ "op": "text", "text": selection_text(ctx)? })).map(|()| None), + _ => Err(Error::NotFound(format!("command {id}"))), + } + } +} + +impl Views for RegexTester { + fn render(&mut self, view: &str, _viewport: Option, _ctx: &Invocation) -> Result { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + // The page keeps its own state: the plugin only answers its messages, + // and never re-renders it (that would reload the frame). + let start = json!({ "flags": default_flags() }).to_string().replace('<', "\\u003c"); + let page = PAGE.replacen(r#""__START__""#, &start, 1); + Ok(View::new(Body::Html(page)).button(Button::new("selection", "Use Selection").icon("text"))) + } + + fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> { + if view != VIEW { + return Err(Error::NotFound(view.into())); + } + match event { + ViewEvent::Button(b) if b == "selection" => post(&json!({ "op": "text", "text": selection_text(ctx)? })), + ViewEvent::Message(text) => { + let Ok(v) = serde_json::from_str::(&text) else { return Ok(()) }; + match v.get("op").and_then(Value::as_str) { + Some("run") => match Run::parse(&v) { + Some(run) => post(&evaluate(&run)), + None => Ok(()), + }, + Some("selection") => post(&json!({ "op": "text", "text": selection_text(ctx)? })), + _ => Ok(()), + } + } + _ => Ok(()), + } + } +} + +register_plugin!(RegexTester, views); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + + fn run(pattern: &str, flags: &str, text: &str, replace: Option<&str>) -> Value { + evaluate(&Run { + seq: 7, + pattern: pattern.into(), + flags: flags.into(), + text: text.into(), + replace: replace.map(str::to_owned), + }) + } + + #[test] + fn matches_with_groups_in_utf16_offsets() { + // "é" is one UTF-16 unit but two bytes; "😀" two units, four bytes. + let r = run(r"(?P\w+)@(\d+)", "", "é😀 ab@12 cd@3", None); + assert_eq!(r["error"], Value::Null); + assert_eq!(r["count"], 2); + assert_eq!(r["seq"], 7); + assert_eq!(r["groups"], json!(["word", null])); + assert_eq!(r["matches"][0], json!({ "s": 4, "e": 9, "groups": [[4, 6], [7, 9]] })); + assert_eq!(r["matches"][1]["s"], 10); + } + + #[test] + fn flags_change_the_match() { + let text = "One\none"; + assert_eq!(run("^one$", "", text, None)["count"], 0); + assert_eq!(run("^one$", "m", text, None)["count"], 1); + assert_eq!(run("^one$", "im", text, None)["count"], 2); + assert_eq!(run("e.o", "", text, None)["count"], 0); + assert_eq!(run("e.o", "s", text, None)["count"], 1); + assert_eq!(run("o n e", "x", text, None)["count"], 1); + } + + #[test] + fn replacement_preview_and_errors() { + let r = run(r"(\w+)@(\d+)", "", "ab@12, cd@3", Some("$2:$1")); + assert_eq!(r["replaced"], "12:ab, 3:cd"); + let bad = run("(unclosed", "", "x", None); + assert!(bad["error"].as_str().unwrap().contains("unclosed"), "{bad}"); + assert_eq!(bad["count"], 0); + assert_eq!(run("", "", "x", None)["count"], 0); + } + + #[test] + fn counts_every_match_but_sends_a_bounded_number() { + let text = "a".repeat(5000); + let r = run("a", "", &text, None); + assert_eq!(r["count"], 5000); + assert_eq!(r["matches"].as_array().unwrap().len(), MAX_MATCHES); + } + + #[test] + fn answers_the_page_and_reads_the_selection() { + let host = MockHost::install().with_capabilities(&["editor.read"]); + host.set_setting("ignoreCase", "true"); + let mut p = RegexTester; + let View { body: Body::Html(page), toolbar, .. } = p.render(VIEW, None, &host.invocation()).unwrap() else { + panic!("an html body") + }; + assert!(page.contains(r#"{"flags":"im"}"#), "the settings' flags start the page"); + assert_eq!(toolbar[0].id, "selection"); + + let msg = json!({ "op": "run", "seq": 1, "pattern": "b+", "flags": "", "text": "abbc" }).to_string(); + p.handle(VIEW, ViewEvent::Message(msg), &host.invocation()).unwrap(); + let (view, reply) = host.view_messages().pop().unwrap(); + assert_eq!(view, VIEW); + let reply: Value = serde_json::from_str(&reply).unwrap(); + assert_eq!((reply["count"].clone(), reply["matches"][0]["s"].clone()), (json!(1), json!(1))); + + host.open_editor("/p/a.txt", "plaintext", "first line\nsecond", &[(1, 0, 1, 6)]); + p.handle(VIEW, ViewEvent::Button("selection".into()), &host.invocation()).unwrap(); + let reply: Value = serde_json::from_str(&host.view_messages().pop().unwrap().1).unwrap(); + assert_eq!(reply, json!({ "op": "text", "text": "second" })); + + // Garbage from the page is ignored. + p.handle(VIEW, ViewEvent::Message("not json".into()), &host.invocation()).unwrap(); + } +} diff --git a/plugins/official/regex-tester/src/page.html b/plugins/official/regex-tester/src/page.html new file mode 100644 index 00000000..4dafa5a8 --- /dev/null +++ b/plugins/official/regex-tester/src/page.html @@ -0,0 +1,377 @@ + +