Compare commits

...
Author SHA1 Message Date
l0ng-ai 434aacc64a chore(plugins): regenerate bindings on top of main 2026-11-04 02:41:46 +08:00
l0ng-ai 58f8929509 test(plugins): the MyBatis test asks again while the first call builds the index 2026-11-04 02:41:46 +08:00
l0ng-ai 14ddd948f8 test(plugins): capability paths are the platform's in the parse test 2026-11-04 02:41:46 +08:00
l0ng-ai 8df7731fa6 chore(plugins): ide-extension at the workspace version in Cargo.lock 2026-11-04 02:41:46 +08:00
l0ng-ai 20281e9b66 fix(plugins): Windows: local marketplace paths, localhost connections, tests
- A marketplace index given as a local Windows path (`C:\market\index.json`)
  resolves its packages next to it; only URLs are joined with `/`.
- HTTP requests to `localhost` try IPv4 loopback first, then IPv6: local
  servers such as Ollama usually listen on IPv4, and on Windows a refused
  `::1` cost about two seconds per request.
- Tests that assumed Unix: an absolute program path is the platform's, a
  manifest rewritten by a test may have CRLF line ends, the precompiled-cache
  test replaces the cache file only after the host that maps it is gone, and
  the MyBatis test lets its first call build the index before asserting.
- The icon theme test's time limits guard against work growing faster than
  the icon count rather than measure the runner.
2026-11-04 02:41:46 +08:00
l0ng-ai 20960c1d96 fix(plugins): soft deadlines hold under load, unique cache writes, CI on every platform
- A provider result produced after its soft deadline is dropped even when it
  is already waiting (the caller was scheduled late or waited on another
  plugin first), so the deadline holds on a busy machine. The result is
  timestamped when the plugin's thread produces it, so a result that came in
  time is still used when it is read late.
- Each precompiled-component write uses its own temporary file: two threads
  storing the same component could rename a half-written file into place.
- Tests: the cache-corruption case replaces the file instead of overwriting
  the one a running component maps; the yield test no longer counts on a
  tick rate; a test that runs a shell script is Unix-only like its
  neighbours.
- Plugin web pages declare `@types/node` for their Vite configs instead of
  finding the repository's.
2026-11-04 02:41:46 +08:00
l0ng-ai 9cc83c77b8 feat(plugins): plugin contributions in the IDE's own settings and menus; official plugins published with releases
- Themes: a plugin theme is an `appearance.theme` value
  (`plugin:<id>/<theme>`), offered by Settings › Appearance (and the
  preferred themes of Sync with OS), Quick Switch, the toolbar gear menu and
  Welcome › Customize. The project window selects it in the plugin host;
  picking a built-in theme clears it. While its plugin is off, the built-in
  theme of its kind shows and the choice is kept.
- File icon themes: chosen in Settings › Appearance › File icon theme and
  kept in `appearance.fileIconTheme`, so every window follows.
- Keymap: plugins' default shortcuts show in Settings › Keymap as the
  defaults of their actions: removable, changeable, resettable and part of
  conflict checks.
- Problems: a plugin's problems are withdrawn when it stops, fails, is
  disabled or uninstalled. Settings › Inspections lists them under Plugins by
  plugin name; stored overrides of `plugin:` producers parse correctly.
- Status bar items of plugins are named in the status bar menu and can be
  hidden.
- Languages added or removed by plugins refresh Override File Type, the
  Code Style pages and Inspections without a restart.
- Uninstalling a plugin deletes the secrets it declared.
- The Settings window follows plugin changes made elsewhere.
- The plugin system is marked Preview (Settings › Plugins, README); Harbor
  Night is versioned 0.1.0 like the other official plugins.
- Release: the official plugins are packed, signed (secret
  PLUGIN_SIGNING_KEY) and indexed into the release as
  `plugins-index.json`; released builds use it as the default marketplace.
  Without the key the step is skipped.
2026-11-04 02:41:46 +08:00
l0ng-ai ac71b7008e feat(plugins): Graphviz, Regex Tester, License Header and Harbor Night official plugins
- Graphviz: `.dot` / `.gv` files open beside a live preview (a web view
  rendering with Graphviz compiled to WebAssembly) in any layout engine,
  with zoom, pan, Fit and SVG / PNG export through the save dialog. What
  Graphviz reports goes from the page to the plugin's code, which publishes
  it as problems on the named line. A DOT language, file icons and a
  New Graphviz Graph template.
- Regex Tester: a tool window whose html view sends pattern, flags, text and
  replacement as you type; the plugin matches with Rust's regex (linear time)
  and answers with matches, groups and the replacement in UTF-16 offsets.
  Use Selection and Test Selection take the editor's selection.
- License Header: files without a copyright / SPDX header are reported when
  they open and on save; a quick fix and a command add one in the file's
  comment syntax (after a shebang or encoding line); a project scan in a
  background task reports the rest. Holder and license are settings.
- Harbor Night becomes an official plugin and the one source of the theme
  files: `openidea-plugin new --template theme`, the theme docs and tests
  start from it.
- Tool windows' renders and events carry the window's active editor, as
  commands do, so a view's buttons can act on what the user is editing.
- knip leaves plugins' web pages (projects of their own) alone.
2026-11-04 02:41:46 +08:00
l0ng-ai 3b78d15d6d refactor(plugins): drop the Kubernetes plugin, samples become test fixtures
- Remove the Kubernetes official plugin.
- `hello` and `language-toy` move from `plugins/` to
  `crates/ide-plugin/tests/fixtures/`: they stay what the host's tests run
  against, but are no longer listed as plugins of the repository.
- A `jobs` fixture takes over what only Kubernetes exercised: a tree view
  filled by a pinned program, a terminal for the activated entry in the
  invoking window, string and number settings, locally and in remote
  projects.
- `plugins/` now holds only the official plugins; `build.sh`, CI and the
  debug plugin catalog follow.
2026-11-04 02:41:46 +08:00
l0ng-ai 3f52439703 chore(plugins): regenerate bindings and reconcile with main
- Regenerated bindings for the merged settings, edits and plugin commands.
- Mouse shortcuts run their action through `executeAction` again.
- Dropped Chinese entries whose templates main already translates.
2026-11-04 02:41:46 +08:00
l0ng-aiandflorentvdry a7b7616da9 feat(plugins): install packages from a .zip or a folder's plugin.toml
Install Plugin from Package File now takes an `.oiplugin`, a `.zip` or a
package folder's `plugin.toml`, and goes through the same install page
(capabilities, signature, consent) as every other install.

- A zip may hold the package at its root or in its only folder, as zipping
  the folder makes; archiver metadata (`__MACOSX`, `.DS_Store`, `._*`) is
  left out. A zip without a `plugin.toml` there, such as an IntelliJ plugin,
  is refused with a message saying so. Unsafe paths, links and size limits
  are checked as for `.oiplugin` files.
- Picking a `plugin.toml` installs its folder.

Co-authored-by: florentvdry <[email protected]>
2026-11-04 02:41:46 +08:00
l0ng-aiandDeng Liu 900f2e6819 feat(plugins): web views, document bridge and the Excalidraw official plugin
Plugins can now ship a web page as the UI of a view or custom editor
(`web = "web/index.html"`), so editors built on web libraries are plugins
rather than part of the core.

- Web views: every file under the page's directory is part of the package
  (checked, copied on install, covered by the digest and signature) and
  served through the `openidea-plugin` URL scheme, under a per-revision
  path with a CSP of its own, into an `<iframe sandbox="allow-scripts">`.
  An update or reinstall reloads open pages.
- Frame bridge (`window.openidea`), shared by html bodies and web pages:
  theme, messages to the plugin's code, `document.get/edit/replace/onChange`
  (versioned edits that join the text editor's undo history, Save and dirty
  state), `saveFile`/`openFile` through the IDE's dialogs, and IDE shortcuts
  the page leaves alone.
- Custom editor layouts: tab, split (Editor and X) or preview over the text
  editor, switchable from the tab's actions.
- New points `fileIcons` and `fileTemplates`; new optional capability
  `clipboard` for web views.
- Excalidraw official plugin: `.excalidraw` / `.excalidraw.json` open as an
  interactive canvas kept in step with the file's text, with self-hosted
  fonts, PNG/SVG export, images, file icons, a New Drawing template and
  JSON highlighting. Closes #732.
- Fixes: a lock-order deadlock between listing plugins and refreshing
  project pins; the dev server ignored edits under `src/features/plugins`.
- `plugins/build.sh` builds and checks web pages; CI typechecks and tests
  them.

Co-authored-by: Deng Liu <[email protected]>
2026-11-04 02:41:46 +08:00
l0ng-ai 78e0c7a23e feat(plugins): finalize plugin API before release, concurrent providers
Plugin API (breaking; the plugin system is unreleased):
- Remove the runtime `contributions` interface (register-command,
  register-language, register-theme). Commands, languages and themes are
  declared in plugin.toml only, so everything a plugin contributes goes
  through the install preview and consent. The registry drops runtime
  scope, `contribute` and `dispose_runtime`; inline `definition` in
  language and theme declarations is gone.
- `completion-item.kind` is a string (the 25 LSP kinds; unknown kinds show
  no icon), like the other open-ended kinds.
- `lifecycle.activate` and `commands.run` return `types.error` instead of a
  string. The host maps it to the matching error kind, and only `failed`
  counts against the plugin: cancelling, a missing grant or an unknown
  command does not. The SDK's `Plugin` returns `Result<_, Error>`; plain
  messages convert to `Error::Failed`.

Runtime:
- Editor providers of several plugins are called at the same time
  (`Actor::submit` + `Pending::wait`): the wait is the slowest plugin's,
  not the sum, so each gets the full edit-path budget.

Versioning and tooling:
- Components are checked at install and by `openidea-plugin check` for the
  plugin API they were built for; another major or a newer minor is
  refused with what to do.
- Tests: the SDK's WIT matches the host's, a simulated next-minor host
  links every shipped component, every host function has an SDK wrapper,
  released APIs only grow (no snapshot until the first release).
- plugins/build.sh builds every checked-in component, records a source
  fingerprint next to it, and `--check` fails on stale components; CI runs
  it plus fmt, clippy (host and wasm) and tests for the SDK, the official
  plugins and the fixtures.
- The API evolution rules and the checklist for a new host function are in
  plugins/README.md; the design notes under docs/ are removed.
2026-11-04 02:41:46 +08:00
l0ng-ai fa003016ef feat(plugins): theme variables for html view frames, kept current without reload
- Inject `data-theme` and stable `--openidea-*` colour and font variables
  (concrete values; the frame has no IDE stylesheet) into html view bodies.
- Post theme changes to the loaded frame instead of rebuilding srcdoc, so a
  theme switch keeps the frame's state; re-send on load to close the race.
- Expose `openidea.theme` / `openidea.onThemeChange` to frame scripts.
- Move `subscribeTheme` / `themeKey` from the Markdown mermaid module to
  `src/platform/themeWatch.ts`, shared by both.
- Document the variables in plugins/README.md.
2026-11-04 02:41:46 +08:00
l0ng-ai fd1f465aeb feat(plugins): extension points, capability consent, async runtime and plugin API 1.0
Rework the plugin system per docs/plugin_plan.md (P0–P8):

- Extension-point registry (ide-extension) so new contribution types register
  without touching the kernel; languages, run detectors, providers, views and
  UI contributions go through it.
- Per-plugin capability consent with a risk-graded ledger; process
  capabilities are pinned to resolved binary paths and hashes.
- Async, actor-per-plugin Wasm runtime with lazy activation, cancellation,
  events, long-running tasks and idle reclamation.
- Plugin host moved into ide-host, shared by local and remote projects.
- Distribution: install/package tooling (openidea-plugin bin) and a static
  market index.
- WIT frozen as openidea:[email protected]; Rust SDK with mock host.
- Ten official validation plugins under plugins/official.
2026-11-04 02:41:46 +08:00
l0ng-ai 63801ba249 fix(github): find pull requests in every git repository of the project (#1084)
A project folder that holds several git repositories without being one itself showed "No GitHub repository" in Pull Requests: every gh command ran in the project folder.

The GitHub remotes now come from each of the project's git repositories, and each remote remembers its repository. gh commands take the repository to run in (the primary one when none is given), and checkout, diff, worktree, merge, push, suggestions and new pull requests use the connected repository.

Fixes #812
2026-11-04 02:36:56 +08:00
Florent Vandflorentvdry 5e7339e4c2 feat(vcs): group commit changes by staging state (#1080)
Commit view › Group By gains "Staging State". When it is on, Changes splits into Staged Changes and Unstaged Changes; a partially staged file appears in both, and its diff compares HEAD with the Index under Staged and the Index with the working copy under Unstaged. The choice is remembered across sessions.

Closes #1067

Co-authored-by: florentvdry <[email protected]>
2026-11-04 02:22:56 +08:00
l0ng-ai 2f75628c87 fix(tasks): show a progress title's executable path as the program name (#1061)
rust-analyzer titles its check with the full command line, starting with the absolute path of cargo, so the status bar and Background Tasks popup showed /Users/…/Application Support/…/bin/cargo check … truncated before the useful part. LSP $/progress and DAP progressStart titles now go through ide_core::task::display_title, which turns a leading absolute executable path (with spaces, Windows drive/UNC, .exe/.cmd/.bat) into the program name.

Fixes #1060
2026-11-04 02:09:13 +08:00
l0ng-ai 8efa7f8ece feat(vcs): Manage Remotes from the Remote node of the Git Log branches pane (#1081)
Manage Remotes… was only in the Git menu. The Remote node of the Git Log branches pane and each remote under it now offer it in their context menu.

Refs #800
2026-11-04 02:07:42 +08:00
l0ng-ai 8bb6dfddc2 fix(maven): unlink modules of a reactor so jdtls stops importing them (#1059)
Only root Maven projects could be unlinked, and jdtls imports an aggregator with all of its modules whatever java.import.exclusions say. A module of a reactor can now be unlinked from the Maven tool window; when one is, jdtls gets initializationOptions.projectConfigurations listing the linked POMs except the aggregators above the unlinked module (plus the Gradle build file if there is one), and a Maven sync only names the POMs jdtls imported. Unlinking whole roots keeps using exclusions.

Fixes #767
2026-11-04 01:53:45 +08:00
l0ng-ai 6eb5584beb feat(deployment): browse for the folders of a mapping (#1078)
Mapping paths had to be typed. The local path and deployment path cells now have a folder button: the local one picks a folder inside the project, the deployment one opens the server's folder chooser at its root path, and both fill in the path relative to their root (the deployment button needs an absolute root path).

Fixes #799
2026-11-04 01:39:00 +08:00
ntbang0901 555226a9bf fix(ui): start pointer drags only while the primary button is held (#1071)
On macOS, with an editable element focused and an input method active, WebKit can deliver a tap as pointerup then pointerdown, so drag trackers saw every later pointermove as a held button: one tap plus a pointer move started a phantom drag (file drag label, tab detach, tool window move) and the next tap dropped it. A drag now starts only from a move with the primary button down, a move without it cancels the press, and late button-less moves after a drag started are ignored so the drop is kept. Resizers, dialog drag/resize, pan and the review gutter end instead; the Project view also listens for pointercancel.

Refs #760

Co-authored-by: ntbang0901 <[email protected]>
2026-11-04 01:31:10 +08:00
l0ng-ai a35e434376 fix(project-view): classify large Java and Kotlin files cut off in their body (#1075)
File icons read the first 64 KiB of a JVM file; a larger file cut off inside its body no longer parsed as a declaration and showed the plain Java file icon. When that happens, the declaration header (up to the first type's opening brace, skipping comments, literals and parenthesised arguments) is classified instead.

Fixes #754
2026-11-04 01:26:44 +08:00
ntbang0901 bd424a3805 feat(gradle): import modules from Gradle itself (includeFlat, included builds, computed includes) (#715)
The Gradle model came from a static parse of settings.gradle, which cannot follow includeFlat, projectDir assignments, computed include lists or included builds, so those modules were missing from the Gradle tool window, the Project view, indexing and run. Sync now runs an init script that prints every evaluated project (identity path, directory, build directory, source sets with test classification, Java version) without resolving dependencies; the static parser stays as the fallback before the first sync. Modules outside the root become content roots, the Project view lists the build's module group above the project folder, an unsynced Gradle build is imported once when opened, and staleness ignores comments and whitespace in build scripts.

Application / Spring Boot configurations of a Gradle module run through a JavaExec task on the source set's runtime classpath by default (Settings › Build Tools › Gradle › Build and run using), with the debugger attached to the forked JVM. The build model reaches run and the Project view through a platform store rather than a cross-feature import.

Fixes #737
Fixes #861

Co-authored-by: ntbang0901 <[email protected]>
2026-11-04 01:09:07 +08:00
xtr-hubandxtr-hub 0c1a64bc2c fix(vim): keep IME compositions out of the document in Normal mode (#1055)
codemirror-vim only discards single-character IME commits in Normal mode, so multi-character commits (Chinese pinyin, Japanese conversions) were written into the document and could replace the selection. In Normal/Visual mode the composition now renders as usual and its whole span is restored when it ends; an ASCII single-character commit (dead keys) is replayed as a Vim key. Composition changes are marked transientChange: they stay out of undo history, the document model, the backend and other split panes. Insert/Replace mode is unchanged.

Closes #1056

Co-authored-by: xtr-hub <[email protected]>
2026-11-04 00:49:09 +08:00
fuqiuluoandfuqiuluo 2b20c0f38e fix(ui): reorder dragged tabs live with a placeholder; drop terminal tabs on a group to join or split (#1054)
Dragging a tab showed no floating copy: the ghost cloned the tab's classes, and .tab { position: relative } loaded after .ghost { position: fixed }. The ghost is now positioned inline, and a tab dragged within its strip stays as an empty slot the other tabs move around; the insertion line for tabs from another strip is drawn inside the edge tabs so the scroller doesn't clip it. A terminal group is now a drop target like an editor pane: the middle joins the group, an edge splits off a new group (dropZoneAt moves to ui and is shared).

Closes #1052

Co-authored-by: fuqiuluo <[email protected]>
2026-11-04 00:41:57 +08:00
l0ng-ai e20eb1acf8 fix(agents): stop idle agents, run tasks locally by default, keep worktrees next to the project (#1050)
A task's agent process lived until the task was archived or the project closed, and stopping it killed only the direct child, so wrappers (npx, codex.cmd, claude shims) left the real agent running. Agents that support session/load are now stopped after 5 minutes idle and restarted with the session reloaded on the next message; agent connections, language servers and the native Claude CLI start in their own process group and are stopped with their whole tree.

New tasks run in the local checkout unless the user picks a worktree, and worktrees go next to the project (<parent>/<project>.worktrees/<task>), on the project's drive and outside its tree.

Fixes #1011
Fixes #1003
2026-11-04 00:38:33 +08:00
matthiasdemauroyandmatthiasdemauroy 39fe96bd01 feat(agents): show Claude usage limits and session cost in the agent task header (#1041)
Claude Code reports the account's 5-hour and weekly usage limits (rate_limit_event) and the session cost (result.total_cost_usd); only the limit status was used. The limits now show as meters next to the context meter in the task header, with the reset time in the tooltip, and Settings › Tools › Agents › Chat turns each meter (context, work time, limits, cost) on or off. Limits are per provider and kept in memory; cost is off by default.

Closes #1057

Co-authored-by: matthiasdemauroy <[email protected]>
2026-11-04 00:28:23 +08:00
l0ng-ai 4eda613c59 fix(maven): inherit the compiler level from parent POMs and pluginManagement (#1064)
A module whose maven-compiler-plugin release/source is configured in a parent POM or in <pluginManagement> had no Java level in the build model, so its JDK fell back to the newest installed one. The level is now read from build/plugins and build/pluginManagement/plugins and inherited down the parent chain. Launching a Java application without a project JDK also takes the module level from the build model.

Refs #753
2026-11-04 00:18:45 +08:00
Andrea Basileandandreabasile 5c642819e2 feat(run): import run configurations from .vscode/launch.json (#1030)
Run/Debug Configurations offers Import from .vscode/launch.json when the project has one (also a Find Action command). debugpy/python launches become Python, Django test, module or pytest configurations with their interpreter; debugpy attach becomes Python Remote Debug; node/pwa-node launches become node or npm configurations, attaches become Attach to Node.js, and node-terminal commands map to npm, a program or a shell line. cwd, env, envFile and the per-OS blocks are applied. Imported configurations join the dialog like new ones and are saved on OK/Apply; names already present are skipped, and a notification lists skipped entries with the reason and keys with no equivalent.

Refs #991

Co-authored-by: andreabasile <[email protected]>
2026-11-04 00:09:38 +08:00
arookieofcanda-rookie-of-C-language f00ce451ac fix(remote): serve the database commands from the remote server (#1029)
A remote window sends a command to openidea-server only when the server registers it; none of the db_* commands were registered, so they ran locally, where a remote window has no project ("no project is open in this window"). The command bodies move from src-tauri into ide_host::ops::db, the Tauri commands become thin wrappers, and the remote dispatcher registers the same ops, with cancellation for execute / table page / explain. db_export_file stays local because exports are written on the user's machine.

Fixes #1019

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-04 00:03:38 +08:00
Bl0ckandl0ng-ai 8e4c9f6f61 fix(titlebar): prevent expanded main menu from covering toolbar widgets (#725) (#729)
* fix(menu): keep dropdowns clear of the status bar

* fix(titlebar): hide right toolbar while hamburger menu is expanded

* test(titlebar): close expanded menu through the focused element

* style(titlebar): follow existing formatter layout for mocked menu

* test(titlebar): expect expanded menu titles instead of hamburger button

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-03 23:56:06 +08:00
kevinhandl0ng-ai 6a3a712488 fix(tools): skip an unreachable revocation check when curl downloads on Windows (#833)
Schannel curl fails every download with CRYPT_E_REVOCATION_OFFLINE when the CRL/OCSP servers cannot be reached (corporate proxies, firewalls, some regions), although the certificate is valid, so jdtls and other tools could not be installed. On Windows, pass --ssl-revoke-best-effort when `curl --version` reports 7.70+ with Schannel as the active TLS backend: the check is skipped only when the revocation server is offline, and actually revoked certificates are still rejected. Older curl, which rejects the option, is left as is. Fixes #801.

Co-authored-by: l0ng-ai <[email protected]>
2026-11-03 23:55:58 +08:00
l0ng-ai deab41cfc1 fix(run): find gradlew.bat / mvnw.cmd in the run directory on Windows (#777)
* fix(run): resolve Windows wrapper commands in the run directory

* fix(run): take extensionless names from the run directory only through PATHEXT
2026-11-03 23:55:53 +08:00
d0whc3r c5727141a0 feat(format): make oxfmt a first-class formatter next to Prettier (#1010)
oxfmt ran only below an oxfmt configuration and reused the Prettier settings. It now has its own settings group and page (path, enabled languages, formatting provider, run on save) next to Prettier. For a language with an external formatter, oxfmt formats it when enabled for that language and either an oxfmt configuration is found above the file or oxfmt is the selected provider; otherwise Prettier, when enabled. Turning Prettier off for a language still leaves it to the language server. New Reformat with Prettier / Reformat with oxfmt commands run that tool regardless of the settings and report languages it cannot format.

Co-authored-by: d0whc3r <[email protected]>
2026-11-03 23:54:55 +08:00
l0ng-aiandDPhngNam e8d1c45970 fix(run): UTF-8 default charset for application JVMs forked by Maven and Gradle on Windows (#751)
* fix(run): give the spring-boot:run fork a UTF-8 default charset on Windows

On Windows, mvn spring-boot:run passes the stdout/stderr encoding properties to the
forked application JVM through JAVA_TOOL_OPTIONS, but not file.encoding (it would
change how Maven reads sources). On JDK 8-17, logback / log4j2 console appenders
encode with the default charset (GBK on Chinese Windows) and ignore stdout.encoding,
so Chinese log lines showed as U+FFFD in the UTF-8 Run console.

For a Maven launch with a spring-boot:run goal and a UTF-8 console, add
-Dfile.encoding=UTF-8 to -Dspring-boot.run.jvmArguments, merged into the user's
VM options. It reaches only the forked JVM; Maven's own JVM is unchanged. A
file.encoding the user set (in jvmArguments or JAVA_TOOL_OPTIONS) wins.

Fixes #697

* fix(run): give Gradle JavaExec forks a UTF-8 default charset on Windows

Applications started by Gradle (bootRun, run, custom JavaExec tasks) kept the
ANSI default charset (GBK on Chinese Windows, JDK 8-17) while the Run console
decodes UTF-8. Code that converts text with the default charset then breaks:
Nacos config with Chinese text fails to parse (MalformedInputException) and
logback/log4j2 console output is garbled.

For a UTF-8 console on Windows, a Gradle launch now gets an init script that
adds -Dfile.encoding=UTF-8 to each JavaExec task when it runs, unless the task
or JAVA_TOOL_OPTIONS already sets file.encoding. Gradle's own JVM and compiler
source encoding are unchanged. Maven and Gradle are told apart so the
spring-boot:run rewrite only applies to Maven.

* fix(run): set Gradle charset before build task configuration

---------

Co-authored-by: DPhngNam <[email protected]>
2026-11-03 23:54:01 +08:00
arookieofcandl0ng-ai 8d25b1bcd0 fix(sdk): follow scoop shims to the jdtls release script (#804)
* fix(sdk): follow scoop shims to the jdtls release script

scoop puts a jdtls.exe shim (driven by a jdtls.shim descriptor) on PATH.
launch_directly only recognized the release's own script names, so a scoop
install kept the shim's command line and the configured JDK never applied.
On Windows, follow the shim's path entry to the release script; a shim
without a descriptor is still treated as someone's own wrapper.

Fixes #794

* test(sdk): make the scoop-shim fixture a full JDK

#815 made launch_directly require javac and the jdk.compiler/jdk.jdi/
jdk.unsupported modules, so the shim fixture's bare release file stopped
the launch before the shim path was exercised.

* fix(sdk): follow real Scoop Python shims for jdtls

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-03 23:53:56 +08:00
l0ng-ai 0b68cb3e76 fix(windows): enlarge taskbar icon artwork and lettering (#703) 2026-11-03 23:50:53 +08:00
arookieofc 6f7216fb7f fix(terminal): git bash 补全——Windows 上 POSIX shell 也以登录 shell 启动 (#1036)
* fix(terminal): start POSIX shells as login shells on Windows too

Git Bash started argument-less on Windows (resolve_shell only passed
-l on Unix), so it ran as a non-login shell: /etc/profile never ran,
git-completion.bash was not loaded, and git <Tab> had no subcommand
suggestions. Start POSIX shells with -l everywhere, like the Git Bash
shortcut, Windows Terminal and IDEA do.

A bare login bash also runs with Git for Windows' MSYS default profile,
whose PATH leaves git and the mingw64 tools out unless <Git>\cmd is
inherited; default MSYSTEM to MINGW64 (the shortcut's value) unless the
user or the launching process already set it.

* fix(terminal): keep MSYS2 login shells in the working directory

MSYS2's /etc/profile runs cd "$HOME" on a login start unless
CHERE_INVOKING is set, so with the new login args an MSYS2 bash/sh
project terminal would open in the home directory instead of the
project. Default CHERE_INVOKING=1 for Windows bash/sh next to the
MSYSTEM default, still deferring to a value the user or the launching
process set. Git for Windows' /etc/profile has no such cd (verified
locally: a login bash stays in the project directory either way), so
Git Bash users see no change.
2026-11-03 23:50:49 +08:00
kkmihaiandkkMihai a3576fca90 fix(vcs): maximize diffs and keep file navigation (#907)
Diff tabs, including the preview tab, get Maximize Editor / Restore Size in their toolbar (reusing Hide All Tool Windows). Show Diff from the Commit view or the Git log now carries the whole change list: F7 / Shift+F7 at the last or first difference shows a hint, and pressing again moves to the first or last difference of the neighbouring file, skipping binary, oversized and unreadable files. The layout is restored after a preview is replaced and keyboard focus stays in the diff across files. Compare branches stays single-file.

Fixes #904

Co-authored-by: kkMihai <[email protected]>
2026-11-03 23:41:16 +08:00
ntbang0901 c2fa6f37ab feat(vcs): make unresolved merge conflicts visible wherever they come from (#905)
Conflicts left by stash pop or an automatic stash now get the same editor banner (File has unresolved merge conflicts, Resolve conflicts…) as merge conflicts. Complete conflict markers are reported as errors in every language and listed in Problems (severity under Settings › Editor › Inspections › Version Control). The toolbar shows Resolve/Continue and Abort next to the branch while conflicts or an interrupted operation exist, and the Commit view and Branches popup offer Resolve Conflicts even when no operation is in progress.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-03 23:39:26 +08:00
ntbang0901 f752f2db08 feat(vcs): make the merge editor work like IntelliJ's merge tool (#902)
The result pane starts from the base and every change, conflicting or not, gets Accept / Ignore next to its side; accepting one side of a conflict still lets the other side be appended, and the modifier key resolves using that side directly. Adds Resolve Simple Conflicts (word-level), Apply Non-Conflicting Changes from the left, right or both (optionally on opening), Revert Conflict Resolution, Accept Yours / Accept Theirs / Cancel / Apply, Compare Contents between any two versions, and undo back to the conflicted state. CRLF and UTF-16 files are written back exactly, deleted sides and binary files are handled, and the commands are registered as merge.* actions.

Refs #826

Co-authored-by: ntbang0901 <[email protected]>
2026-11-03 23:37:38 +08:00
Florent Vandflorentvdry 2d4d6ee6bf fix(deployment): accept absolute local paths in mappings and name menu entries after the server (#885)
A mapping's local path may now be an absolute path inside the project (the form IntelliJ writes), and a deployment path of . means the server root; absolute paths outside the project are kept as they are. The project-tree Deployment submenu names the default server (Upload to <server>, Download from <server>, Sync with Deployed to <server>…) and gains Sync with Deployed to Default Server. Remote Host downloads use the backend-resolved mapping, which fixes downloads under symlinked or case-different paths.

Fixes #877

Co-authored-by: florentvdry <[email protected]>
2026-11-03 23:35:43 +08:00
Shohruh Ismoilovandshohruh-genius 0396f0d552 feat(vcs): recognize moved and edited code in the diff viewer (#868)
Blocks that were moved unchanged, re-indented or moved and then edited are paired across the split diff: each side gets a link to the other (keyboard accessible), moved lines carry a left accent bar, edits inside a moved block get word-level highlights, and the toolbar counts moved blocks and whitespace-only changes. Staging and reverting still work on the original hunks.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-03 23:26:36 +08:00
whyareyouserious 09402e3e95 feat(vcs): redesign the Clone Repository dialog and support shallow clones (#866)
Clone Repository gets IntelliJ's layout: a source list on the left, a version-control selector, URL history, a directory picker, and a shallow-clone option (history truncated to N commits, git clone --depth N). Entries for hosting platforms without backend support were removed.

Co-authored-by: whyareyouserious <[email protected]>
2026-11-03 23:21:53 +08:00
时雨遥 f5e12238bb feat(terminal): split the tool window into tab groups, as IDEA (#864)
The Terminal could only split panes inside one tab. Split Right / Split Down now create tab groups, each with its own tabs, + and ▾; tabs can be moved between groups by dragging or Split and Move Right/Down, Unsplit merges a group back, and closing a group's last tab focuses the neighbouring group. Split actions are registered as actions and appear in the tab context menu.

Fixes #774

Co-authored-by: StarryKira <[email protected]>
2026-11-03 23:20:11 +08:00
Ravuthzandravuthz 9100187fc8 feat(php): PHPUnit and Composer support and a PHP interpreter setting (#959)
Composer projects and PHPUnit suites are detected (Composer scripts, PHPUnit, artisan serve); PHPUnit runs report into the test tree through --teamcity with gutter runs for classes and methods, file-level runs, rerun failed and a PHPUnit run configuration type. Settings › Languages & Frameworks › PHP selects the CLI interpreter (discovered on PATH, Homebrew, phpbrew, phpenv, Herd, XAMPP, Laragon); it is applied to run configurations and PHP language servers through PATH. TeamCity service messages feed only the test tree and are kept out of the run console, sharing the stdout decoder go test -json uses.

Co-authored-by: ravuthz <[email protected]>
2026-11-03 23:17:52 +08:00
Jaw d6efea8dac feat(markdown): set the preview font size apart from the UI font (#1045)
The Markdown preview had a fixed 14px body and notebook Markdown cells inherited the UI font size, so enlarging them meant scaling the whole UI. Languages & Frameworks › Markdown › Preview font size (application-level, 8–72, following the editor font size until set) now drives the preview and notebook Markdown cells; fenced code scales with it, and notebook code cells and outputs follow the editor font.

Fixes #768

Co-authored-by: jaw52 <[email protected]>
2026-11-03 23:16:53 +08:00
Piettro 71dd9d120b fix(python): keep modules in the working directory from shadowing the stdlib (#964)
Python puts the working directory first on sys.path for -c / -m. venv creation, pip (list/install/uninstall), the library-path probe and the IDE-managed Python tool probes and installs inherited the IDE's own working directory, so a project's typing.py / logging.py there broke them ("partially initialized module 'typing'"). venv creation now runs python -I; pip, the library probe and the tool commands run in an empty private directory (keeping pip install --user packages visible), and relative requirements (., ./pkg, .[dev]) resolve against the project root. The window-label part of the original change already landed in #960.

Refs #955

Co-authored-by: pcastro93 <[email protected]>
2026-11-03 22:58:36 +08:00
Jaw b57272b715 fix(markdown): make Structure autoscroll work with Preview only (#1037)
In Preview only the editor is display:none and the preview only followed it in the split layout, so Structure's Autoscroll to Source moved a hidden caret and Autoscroll from Source never saw the preview scroll. Caret moves without an edit now scroll a Preview-only preview to that line, and the preview publishes the source line on screen through editor/api (setShownLine/useShownLine) so Structure selects the heading being read; it is cleared when the editor comes back or the preview closes.

Fixes #977

Co-authored-by: jaw52 <[email protected]>
2026-11-03 22:50:13 +08:00
小小强andguozhiqiang123 685a9487f3 fix(vcs): wrap long refs in Git log commit details (#999)
Branch and tag labels in the commit details pane inherited the graph row's 180px single-line limit and were clipped. Labels in the details metadata now use the pane width and wrap, including names without break points; graph-row labels keep their single-line ellipsis.

Fixes #996

Co-authored-by: guozhiqiang123 <[email protected]>
2026-11-03 22:44:30 +08:00
l0ng-ai 329ad2c644 fix(ui): treat every second click of a burst as a double-click
WebKit fires `dblclick` only for the second click of a burst. A quick
double-click right after another arrives as `click` with `detail` 3 and 4 and
no `dblclick`, so the second double-click on a folder in the Project view (and
on any tree row, tab or list item handled by `onDoubleClick`) did nothing.

A window-level shim now dispatches a `dblclick` on the click's target for every
later even click of a burst (detail 4, 6, ...), after the page's own click
handlers. Existing `onDoubleClick` handlers and their `stopPropagation` apply
unchanged; an engine that fires its own `dblclick` for that click gets it
dropped as a duplicate.

The Project view test now delivers bursts the way WebKit does (`dblclick` only
at detail 2) and checks both expand modes over 2–6 click bursts.

Refs #724
2026-11-03 22:34:29 +08:00
l0ng-ai eefe166cf7 chore: v0.1.35 2026-11-03 22:31:49 +08:00
l0ng-ai 8c9ddb9879 fix(agents): tidy the Built-in agents settings page
- List rows are no longer text-selectable. Settings pages enable text selection
  for their content, and the shared List inherited it, so dragging or ⌘A
  highlighted every row.
- An agent missing from PATH is shown as a muted row with a small "Not found"
  label instead of an orange warning badge; a custom executable gets an accent
  "Custom" label.
- The details header puts the status (with a status dot) and the launch command
  on one line.
- Reset only appears when a custom executable is set.
- The sign-in hint gets its own "Sign in" heading.
- The list/details frame sizes to its content instead of filling the page.
2026-11-03 22:20:58 +08:00
l0ng-ai d54ef98a59 fix(lsp): open library classes behind jar: URIs (#1048)
The official Kotlin LSP answers navigation into a dependency with a
`jar:///…/lib.jar!/pkg/Name.class` URI but does not serve its contents through
`workspace/textDocumentContent`, so opening the target failed with "no
language server provides the contents of jar:…", locally and remotely.

`jar:` URIs are now resolved by the backend, where the jars are:
- An entry that is not a class (a sources jar entry, a resource) is read from
  its archive.
- A class is shown as its source from the artifact's `-sources.jar` in the
  Maven repository or the Gradle cache (`Name.java`/`Name.kt`, an inner
  class's outer file, a `FileKt` facade's `File.kt`, a `@file:JvmName`
  file); Go to Declaration then lands on the declaration of the name it was
  invoked on.
- Otherwise the server's `decompile` command is used when it offers one, and
  the error says that neither a source nor a decompiler is available.
- Library tabs name the archive and package (`spring-boot-3.5.16.jar ›
  org.springframework.boot`).

Fixes #982
2026-11-03 22:04:05 +08:00
l0ng-ai 98517019e7 fix(editor): say when a file is outside the language server's projects (#1046)
jdtls only checks the syntax of a Java file outside every module's source
folders (or outside any imported project) and answers navigation with
nothing. The editor then said only "Cannot find declaration to go to", and
the server's explanation was a warning squiggle on line 1.

- A banner above the editor explains it while the server reports it, for
  jdtls (not on the classpath of a project / non-project file), gopls (no
  package, build constraints — this replaces the Go-only banner) and
  rust-analyzer (unlinked file). For Java it offers the Maven / Gradle reload
  when available.
- Go to Declaration / Implementation / Type Declaration that finds nothing
  in such a file gives the reason in its hint.

Fixes #827
2026-11-03 22:02:50 +08:00
l0ng-ai ae406ca2fb fix(java): resolve Maven projects with the profiles chosen in the Maven tool window (#1044)
The profiles checked in the Maven tool window only reached `mvn` (sync and
goal runs) and the Kotlin bridge. jdtls imported every module without them,
so a dependency whose version, or module, comes from a profile stayed
unresolved and its classes were reported as missing, while Build Project ran
without `-P` as well.

- A Maven sync records the profiles it ran with. jdtls gets a copy of the
  user settings.xml with them in `<activeProfiles>` (m2e has no other input
  for profiles), sent as a setting so a running server re-imports instead of
  restarting; the file name follows its content so m2e reloads it.
- Toggling a profile reloads the Maven projects (once for several toggles in
  a row, again after a sync that started with the previous choice).
- Build Project passes the synced profiles with `-P`.
- When jdtls finishes an import or project update it does not re-check open
  files, so their errors stayed until the next edit; open Java files are now
  re-checked on its ProjectStatus report. This also covers a POM edit or a
  dependency added to ~/.m2.

Fixes #993
2026-11-03 22:02:40 +08:00
arookieofcanda-rookie-of-C-language 9dbae1d189 feat(agents): configurable executable for every built-in agent (#682)
Settings › Tools › Agents is split into Built-in and ACP Registry tabs. The Built-in tab lists every built-in agent with its status (custom executable or not found on PATH) and, for the selected agent, its default launch line, an Executable field (empty = auto-detect on PATH) with Browse and Reset, and its sign-in hint. Edits are staged with the Settings dialog and written on Apply / OK; Cancel discards them.

The override is written to <config>/agents/<id>.toml, keeping hand-written name, driver, args and env; resetting a command-only override removes the file. AgentProvider now exposes the program, args, built-in defaults, login hint and whether the command is editable: built-ins started through a package runner (npx) and registry installs are read-only, with the reason shown.

Fixes #596

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 21:55:51 +08:00
l0ng-ai e4b8b76452 chore: add issue/PR templates, PR check, AGENTS.md and shared agent skills (#1043)
Adds a PR template (linked issue, what and why, how it was verified, review focus), a minimal reproduction field on the bug form and required acceptance criteria on the feature form, and CONTRIBUTING.md with the issue and PR rules. A PR check labels needs-info and leaves one comment when the issue link or the verification section is missing; it only reads the title and description.

AGENTS.md is now tracked, with CLAUDE.md linking to it, and covers layout, rules, commands, the checks CI runs, pitfalls and parallel worktrees. The verify and issue-fix skills are shared under .agents/skills and linked from .claude/skills; the rest of .claude/ stays local.
2026-11-03 21:49:31 +08:00
l0ng-ai e2e699dca9 fix(database): bound large cell previews and use dialog editing (#1040)
Limit grid and export-preview cell text to 1000 UTF-16 code units plus an ellipsis. Edit large values in a wrapped dialog while preserving complete clipboard, export and database values.

Reproduced STATUS_STACK_OVERFLOW (0xC00000FD) in Windows WebView2 against a real PostgreSQL table with nine rows and 8.83 MB of jsonb data. Verified the fix through display, selection, editing, full clipboard copy, JSON file export and PostgreSQL writeback. All PR CI checks passed, including 5625 frontend tests and Rust checks on Windows, macOS and Linux.

Fixes #825
2026-11-03 21:49:26 +08:00
l0ng-ai dcd8ecc5ae fix(java): name reserved Windows device files that abort the Java import (#1032)
On Windows, a project file named after a device (nul, con, aux, prn, com1-9, lpt1-9; nul is typically left by a 2>nul redirect in Git Bash) aborts the Java import: Files.exists reports it missing, Eclipse's refresh builds a child path with an empty name and throws "Path must include project and resource name", so m2e never creates the Java project.

When JDT LS reports that failure on Windows, the server root is scanned for reserved device names (bounded, ignore files not honoured) and a one-time error notification lists them with how to recover. The name check covers COM0-9/LPT0-9 including superscript digits, case-insensitive, with or without extension or trailing spaces. Backend notification texts now go through txTemplate so templated catalog entries translate; zh text added.

Refs #976
2026-11-03 21:09:03 +08:00
l0ng-ai a3b6780823 fix(java): replace module outputs with launch snapshots on Windows (#1038)
Maven launches put a snapshot of each module's class directory on the classpath in place of the original. The originals were built as module.join("target/classes") (`...\app\target/classes` on Windows) while JDT LS reports `...\app\target\classes`; compared as text, the original stayed next to its snapshot, so every resource was on the classpath twice and MyBatis failed with "XML fragments parsed from previous mappers already contains value". Launch class directories are now joined per component, and classpath entries are compared by a key that ignores the verbatim prefix, separators, trailing separators and, on Windows, case.

Fixes #704
2026-11-03 20:59:40 +08:00
arookieofc bd2df0bd3a fix(markdown): keep the preview-only scroll position across remounts (#1009)
Tab switches and mode toggles unmount MarkdownPreview, whose only
scroll state was the DOM scrollTop; Preview Only started over at the
top. Record the preview's own scrollTop in the per-path live markdown
store and restore it on mount (split mode keeps its editor-driven
sync).
2026-11-03 20:55:10 +08:00
Marius Ciepluch 8ca25e1984 fix(markdown): use editor-side tokens in the preview (#992)
* fix(markdown): use editor-side tokens in the preview

The preview paints on var(--bg-editor) but took its text colors from
the UI tokens (--fg, --fg-strong, --fg-secondary), so with a light UI
theme plus a dark editor scheme (e.g. an imported dark .icls) it
rendered black-on-black. The code editor was unaffected because it
uses --fg-editor.

The preview now uses editor-side tokens: text var(--fg-editor),
headings var(--syn-markup-heading), code/pre/th background
var(--bg-editor-inline), blockquote var(--fg-editor) at 80%.

* chore: oxfmt src/features/run/types.ts (fixes format:check broken on main by 002401e8)

* chore: regenerate bindings after rebasing onto main
2026-11-03 20:55:06 +08:00
arookieofc a15712b670 fix(vcs): keep a deleted-upstream branch pushable in the multi-repo Push dialog (#1004)
`@{u}..HEAD` fatally fails when the tracking config remains but the
remote-tracking ref is gone (the upstream branch was deleted on the
remote and the fetch pruned): the repository was dropped from the
multi-repository Push dialog, or, when every repository failed, the
dialog showed the raw fatal and disabled Push. Fall back to recent
history like the single-repository dialog does, so the branch stays
listed and pushable (a plain push recreates the remote branch).
2026-11-03 20:33:41 +08:00
arookieofc 82130d37e5 fix(build): recognize Maven builds whose aggregate POM sits in src/ (#1022) 2026-11-03 20:32:37 +08:00
JawandClaude Opus 5.5 07456b2562 fix(editor): show the scrollbar thumb over overview marks (#1031)
The overview lane lies over the vertical scrollbar, so a mark the height of the lane (a new file added to git is one green VCS mark) hid the native thumb completely. The lane now draws the thumb itself, above its marks and without taking the pointer, at the position of the native one (scrollbarThumb, inset by its 2px border); it switches to the hover color while the pointer is on the thumb. The native thumb is made transparent in editors that have the lane, so the two don't add up, and still takes the drag. Editors without the lane (notebook cells, previews) keep the native thumb.

Fixes #1014

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 20:31:57 +08:00
arookieofc 659afe66f5 fix(code-insight): stop call/type hierarchy from nesting recursive symbols forever (#1016)
A recursive symbol got a fresh id on every level (ids carry the parent
chain), so sibling dedupe never matched and each expansion requested the
same level again, forever. Identify items by their position key instead:
a child whose key repeats an ancestor is kept a leaf, marked recursive,
and never expanded.
2026-11-03 20:31:01 +08:00
JawandClaude Opus 5.5 a57267fa32 fix(project-tree): reveal files of linked workspace projects (#915) (#1035)
In a workspace the tree's root is the workspace directory, and the linked projects are separate top-level roots outside it. Select Opened File (and other reveals) only looked inside that root and contributed folders, so a file of a linked project was never expanded, selected or scrolled to. Reveal now resolves the innermost linked project containing the path first; the Project Files pane also counts linked projects as shown.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 20:30:07 +08:00
814 changed files with 66029 additions and 4901 deletions

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
---
name: issue-fix
description: Triage and fix a GitHub issue (or a user-reported bug) end to end — reproduce, find the root cause, generalise it to every sibling case, fix the class rather than the instance, verify, and open one PR. Use when given an issue URL/number or asked to fix a reported bug and open a PR ("看看这个 issue", "修一下这个 bug", "提 pr").
---
# Fix an issue: from one case to the whole class
The reported case is one sample of a defect class. A fix that only makes the
screenshot go away is not done: the next question is always "does this break for other
languages / platforms / entry points too?". Answer that before anyone asks.
## 1. Read and reproduce
- `gh issue view <n> --comments` (repo `l0ng-ai/openidea`). Read attached logs/screenshots
closely: OS, language, toolchain versions, exact error text.
- Locate the code path and confirm the claim in code, with `file:line` evidence.
Say plainly whether it is a real bug, a missing feature, or user environment.
- Reproduce locally when possible (`fixtures/` projects, `/verify` for runtime). If the
reporter's platform (often Windows/Linux) can't be reproduced here, say so up front.
- Do not ask for a generic environment/log checklist before trying the flow and inspecting the code.
Record the attempt and result; ask only for a fact that actually blocks the next diagnostic step.
Missing fields, limited time, or unavailable automation are not by themselves reasons to ask the reporter.
- Treat a maintainer-confirmed bug as an investigation task even if local reproduction fails. Distinguish
“not reproduced here” from “not a bug”, and do not require the reporter to prove its existence again.
## 2. Name the root cause as a class
Write one sentence of the form "<mechanism> is missing/wrong, so any <X> hits it",
not "<this button> is broken". Typical classes in this codebase:
- Per-language config handled for one language only (`languages/*.toml`, `ide-sdk`).
- Platform gaps: paths, executables, shells, `.cmd`/`.exe`, separators — macOS-only code.
- Toolchain/version discovery: one probe location, no version check, no rescan.
- Two features each implementing half a flow and never joined (feature isolation →
should go through an action/service).
- State not reset on project switch / window reuse; events not scoped per window.
- Failure surfaced as raw stderr/stack trace or retried until "crashed" instead of a
clear status with an action.
- UI: same primitive styled differently in N places.
## 3. Sweep for siblings
Search systematically, not by memory:
- Same pattern elsewhere: `git grep` the function, the config key, the error string,
the hard-coded path/flag. Check every `languages/*.toml` entry and every platform branch.
- Same flow for other languages/runtimes (Java ↔ Kotlin/Gradle, Python ↔ venv/uv,
Node ↔ Deno/Bun, Go, Rust …): would they fail the same way?
- Other callers/entry points of the same flow (menu, context menu, gutter, shortcut, CLI arg).
- Neighbouring failure modes of the same mechanism (not installed / wrong version /
installed but crashes / slow start / installed mid-session).
For broad sweeps spawn one Explore agent with the class description, not the instance.
## 4. Decide the fix at the right altitude
- Fix the mechanism once (shared helper, action, config hook) so siblings are fixed
by construction; don't patch each call site.
- Respect `AGENTS.md` rules (crate boundaries, no cross-feature imports, `IdeResult`,
UTF-16). If the right fix breaks a rule, pick the rule-respecting design and say why.
- Compare with how established IDEs solve it when relevant; prefer the established answer.
- Present: root cause, sibling table, proposed fix, what's deferred. When the user leaves
the choice to you, go ahead without asking again.
## 5. Implement and test the class
- Tests cover the class: table-driven over languages/platforms/states, not just the
reported input. Include the reporter's case verbatim.
- Run the scoped checks (`pnpm vitest run <path>`, `cargo nextest run -p <crate>`), then
`/verify` in the app for UI or runtime behaviour.
## 6. PR
- One branch `fix/<slug>`, one PR unless the user asks otherwise; `Fixes #<n>` in the body.
- Don't stage untracked IDE-state dirs (`.ideal/`, `.openidea/`) or screenshots.
- PR body follows `.github/pull_request_template.md`: `Closes #<n>`, then
**What and why** (user-visible change + root cause at class level, 2–4 sentences),
**How it was verified** (commands run and results, siblings fixed/deferred, what could break and how
it was checked, what was not verified, e.g. "Windows not tested"; for UI changes the before/after
screenshot paths from `/verify`), **Review focus** (optional). Short and plain.
## Report format
Reply in the user's language:
```
Root cause: <one sentence, class level>
Sibling sweep:
| Location / case | Affected? | Action |
Fixed / not covered (why) / not verified (platform)
PR: <url>
```
+57
View File
@@ -0,0 +1,57 @@
---
name: verify
description: Build, launch and drive the OpenIDEA desktop app through the Tauri MCP bridge to verify a change at runtime.
---
# Verify OpenIDEA in the running app
## Launch
- Run the app from the worktree that holds the change: `pnpm tauri dev` in the background, logging
into your task's scratch subdirectory. Use `pnpm dev:app` instead when your checks call
`window.__TAURI__` from `execute_js`. The wrapper gives each worktree its own app identity, data,
settings and Vite port, so other worktrees and the installed app don't interfere. If it reports a
session already running for this worktree, reuse that one.
- Once the app is up, `pnpm dev:status` lists this worktree's MCP bridges. Pass the reported
`appIdentifier` (the port) on every `mcp__tauri__*` call. The MCP server is shared between
sessions: never `driver_session stop` without an `appIdentifier`, and re-check `document.title` /
`location.href` before acting if anything looks off.
- Make sure the binary contains the change: a session already running for this worktree may be an
older build. Restart it after Rust changes; frontend changes reload through Vite.
- After the app restarts, `driver_session start` again for your port (a stale session says "Already connected").
- If the `pnpm tauri dev` process exits while the app window stays open, Vite is gone and any webview
reload shows an empty `#root`. Stop that app and start the session again.
- Stop everything you started when done and nothing else; never use process-name kills.
## Driving
- `manage_window focus` before screenshots / timing-sensitive steps: a background window throttles timers
(debounced LSP / VCS refreshes stall, `setTimeout` promises in `execute_js` time out) and native
screenshots come back black.
- Settings is a separate native window `settings-main`; pass `windowId`, focus it before screenshotting.
- CodeMirror / xterm ignore MCP `type`. Focus the element, then `document.execCommand('insertText', false, text)`.
For xterm Enter, dispatch a `keydown` (`key: 'Enter', keyCode: 13`) on `.xterm-helper-textarea`.
- MCP clicks arrive with `event.detail === 0`; CodeMirror treats them as line-select clicks. Set the caret
through the view instead: `document.querySelector('.cm-content').cmTile.view`.
- MCP `text` strategy can resolve to a hidden node; prefer coordinates (CSS px = image px × 0.9) or CSS selectors.
- CSS-module edits often trigger a full page reload in dev: dialogs close, Run sessions and terminal
scrollback reset, and each reload spawns a fresh shell. Re-open what you were testing.
## Evidence
- For UI or interaction changes, capture `before.png` (on `main` or with the fix reverted) and
`after.png` at the same state and window size, into your task's scratch subdirectory. Multi-step
flows: a numbered series (`after-1.png`, `after-2.png`, ...).
- Report the absolute paths; they go into the PR's **How it was verified** section and the triage
acceptance entry. Never commit screenshots to the feature branch.
## Fixture
`fixtures/` has sample projects (go-app, rust-app, ts-app, kotlin-gradle, polyglot). Copy one into
your scratch directory before editing it, and don't commit the IDE state it creates (`.openidea/`).
## Flows worth driving
⌘⇧O Goto File · ⌘⇧F Find in Files (preview scroll + highlight) · ⌘⇧A Find Action · editor edit/undo/autosave ·
Terminal (Unicode, venv) · Commit view at the default narrow width · Problems · Run → Stop · Project view
New / Delete (nested `a/b.txt` names) · ⌘, Settings search (+ Keymap filter) · Git Log on a repo without commits.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/issue-fix
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/verify
+6
View File
@@ -42,6 +42,12 @@ body:
3. ...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Minimal reproduction
description: A link to a small public repository, or the smallest files/snippet that trigger the problem. Optional, but it's the fastest way to get a fix.
placeholder: https://github.com/you/repro-project
- type: textarea
id: expected
attributes:
@@ -27,6 +27,16 @@ body:
description: How you'd like it to work. Mockups or examples from other tools are welcome.
validations:
required: true
- type: textarea
id: acceptance
attributes:
label: Acceptance criteria
description: How we'll know it's done. One observable behavior per line.
placeholder: |
- [ ] Right-clicking a test class shows "Run with Coverage"
- [ ] Coverage results open in the Coverage tool window
validations:
required: true
- type: textarea
id: alternatives
attributes:
+17
View File
@@ -0,0 +1,17 @@
Closes #
## What and why
<!-- 2–4 sentences: the change a user notices, and the root cause.
If it doesn't fit, the PR is probably too big; split it. -->
## How it was verified
<!-- Required. Commands you actually ran and their results; what else this could
affect and how you checked it still works.
UI changes: before/after screenshots. Multi-step interactions: a recording.
Backend changes: logs or command output. -->
## Review focus
<!-- Optional: anything you want reviewers to look at closely. -->
+51
View File
@@ -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
+69
View File
@@ -0,0 +1,69 @@
name: PR check
on:
# pull_request_target so the check can label PRs from forks. It never checks out
# or runs PR code: it only reads the PR title and description.
pull_request_target:
types: [opened, edited, reopened, ready_for_review]
permissions:
issues: write
pull-requests: write
concurrency:
group: pr-check-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const LABEL = 'needs-info';
const MARKER = '<!-- pr-check -->';
// Docs, CI and pure refactors may skip the linked issue.
const NO_ISSUE_TYPES = /^(docs|ci|build|chore|refactor|style|test)(\(.+\))?!?:/;
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
const problems = [];
const linksIssue =
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s*:?\s+(#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i.test(body);
if (!linksIssue && !NO_ISSUE_TYPES.test(pr.title)) {
problems.push('Link the issue this PR resolves, e.g. `Closes #123`.');
}
const verified = body.match(/^##+\s*How it was verified\s*$([\s\S]*?)(?=^##+\s|(?![\s\S]))/im);
if (!verified || !verified[1].trim()) {
problems.push('Fill in **How it was verified**: the commands you ran and their results. UI changes need before/after screenshots.');
}
const { owner, repo } = context.repo;
const issue_number = pr.number;
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
const labeled = pr.labels.some(l => l.name === LABEL);
if (problems.length) {
const text = `${MARKER}\nThanks for the PR. Before review, please update the description (see [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md)):\n\n${problems.map(p => `- ${p}`).join('\n')}\n\nThis check reruns when you edit the description.`;
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: text });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body: text });
}
if (!labeled) {
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [LABEL] });
}
return;
}
// Only undo what this check did: the label is cleared only when our comment exists.
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
if (labeled) {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: LABEL }).catch(() => {});
}
}
+64 -1
View File
@@ -180,6 +180,69 @@ jobs:
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The official plugins, signed, and the marketplace index released apps read
# (releases/latest/download/plugins-index.json). They are not in the
# installers: users install them from Settings › Plugins. Without the
# signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen`
# writes) nothing is published and the release goes on.
plugins:
needs: create-release
name: Official plugins
runs-on: ubuntu-22.04
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
steps:
- id: key
run: |
if [ -z "$SIGNING_KEY" ]; then
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
if: steps.key.outputs.skip != 'true'
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
if: steps.key.outputs.skip != 'true'
with: { targets: wasm32-wasip2 }
- uses: Swatinem/rust-cache@v2
if: steps.key.outputs.skip != 'true'
with: { key: release-plugins }
- uses: pnpm/action-setup@v4
if: steps.key.outputs.skip != 'true'
with: { version: 11 }
- uses: actions/setup-node@v4
if: steps.key.outputs.skip != 'true'
with: { node-version: 22 }
# Builds the web pages; fails when a checked-in component isn't built from these sources.
- name: Build
if: steps.key.outputs.skip != 'true'
run: |
plugins/build.sh --check
cargo build --release --locked -p ide-host --bin openidea-plugin
- name: Pack, index and upload
if: steps.key.outputs.skip != 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tool="$PWD/target/release/openidea-plugin"
key="$RUNNER_TEMP/openidea.key"
out="$RUNNER_TEMP/market"
(umask 077 && printf '%s' "$SIGNING_KEY" > "$key")
trap 'rm -f "$key"' EXIT
mkdir -p "$out"
for dir in plugins/official/*/; do
id="$(sed -n 's/^id *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
"$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin"
done
# The published index's revocations and earlier keys carry over.
curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \
-o "$out/plugins-index.json" || rm -f "$out/plugins-index.json"
"$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json"
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
@@ -243,7 +306,7 @@ jobs:
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
needs: [create-release, build, server, updater-manifest]
needs: [create-release, build, server, plugins, updater-manifest]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
+10 -3
View File
@@ -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
@@ -30,9 +33,13 @@ target/
test-results/
playwright-report/
# Local AI agent files
CLAUDE.md
.claude/
# Local AI agent files. Shared skills live in .agents/skills; .claude/skills links to them.
CLAUDE.local.md
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/verify
!.claude/skills/issue-fix
# Local reference screenshots
pycharm-screenshots/
+2
View File
@@ -15,6 +15,8 @@
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
"plugins/*/web",
"plugins/official/*/web",
"crates/**/tests/fixtures"
],
"overrides": [
+118
View File
@@ -0,0 +1,118 @@
# OpenIDEA — contributor guide
Rust + Tauri 2 desktop IDE with full-featured, multi-language code intelligence.
Visual style: dark, low-contrast greys, one cool accent
(`src/styles/tokens.css` is the source of truth for colours and metrics).
## Layout
- `crates/ide-*` — backend crates; one domain each. Depend on `ide-core` for shared
types; never reach into another crate's internals.
- `src-tauri/` — glue only: `state.rs` (AppState / Project), `commands/<domain>.rs`
(thin typed IPC adapters), `events.rs`. No domain logic here.
- `src/bindings.ts` — generated by tauri-specta (`pnpm gen:bindings`). Never edit.
- `src/platform/` — ipc (`call`, `onIdeEvent`, `nextRequestId`), actions registry,
keymap + dispatcher, context keys / `when` clauses, project store.
- `src/workbench/contributions.ts` — extension points (tool windows, status items,
overlays, editor kinds) and cross-feature services (editor, tool windows, notifications).
- `src/ui/` — design-system primitives. `src/features/<name>/` — one feature each,
exporting `activate()` registered in `src/features/index.ts`.
- `languages/<id>.toml` — language definitions (detection, comments, LSP, DAP).
- `crates/ide-host` — the `openidea-server` binary for remote development.
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins;
`templates/` — plugin starter templates.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
## Rules
- IPC coordinates: 0-based lines, **UTF-16** columns/offsets everywhere (`ide-core::text`).
- Every command returns `IdeResult<T>`; convert internal errors at the crate edge.
- Streaming → `tauri::ipc::Channel<T>`; cancellable → UI-allocated `RequestId` +
`cancel_request`. Backend → UI notifications → `IdeEvent` via `EventSink`.
- Long work never blocks the UI thread or a Tauri command thread: use
`spawn_blocking`/rayon, honour `CancellationToken`.
- Frontend features never import each other; communicate via actions, contributions
and services. All user-invocable behaviour is an action with a stable id; default
shortcuts live in `src/platform/keymaps/`.
- Styling: CSS Modules + tokens only (no hex literals in components). Inline SVG
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
- User-visible text goes through the text catalog: add every new string to both
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
- Keep comments sparse and purposeful; match surrounding style.
- For GUI verification, prefer Tauri MCP; use Computer Use only when Tauri MCP is
unavailable or cannot cover the required checks.
## Issues & PRs
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one issue per PR with
`Closes #<n>`, reproduce first, keep the diff focused, short plain description, and a
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
Unclear product decisions → `needs-decision` and stop.
## Skills
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
`issue-fix` takes an issue from reproduction to one PR.
## Commands
- `pnpm tauri dev` — run the app with worktree isolation (Node 22.18+).
`pnpm dev:app` also enables the global Tauri API. `pnpm dev:status` reports
this worktree's running MCP bridges and actual ports.
- `pnpm build` — typecheck + bundle frontend.
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
benches vs budgets). Budgets: `perf/budgets.json`.
## Before opening a PR
Run what CI runs for the parts you touched:
- Rust: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
`cargo nextest run --workspace`. After changing IPC types, run `pnpm gen:bindings` and
commit `src/bindings.ts`; CI fails if it differs.
- Frontend: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm test`,
`pnpm build`, and `pnpm e2e` for UI flows.
- UI or runtime behaviour: verify in the running app (`.agents/skills/verify`).
## Pitfalls
- Run a subset of frontend tests with `pnpm vitest run <path>`. `pnpm test -- <path>` ignores
the filter and runs the whole suite, which takes several minutes.
- Don't run clippy while iterating: `pnpm tauri dev` compiles anyway. Run
`cargo fmt --all` + `cargo clippy -p <crate> --all-targets -- -D warnings` once, right
before committing. A cold build cache can take around 5 minutes. Run long checks one at
a time, not chained in one shell call that may hit the 10-minute tool timeout.
- `pnpm tauri dev` does a full debug build. Clippy only checks the code, so a passing clippy
doesn't make the first launch fast.
## Parallel worktrees
Several worktrees can be developed and run side by side.
- Create one per task: `git worktree add ../<name> -b <branch> origin/main`, then
`pnpm install` in it.
- Launch with `pnpm tauri dev` / `pnpm dev:app`, not the raw `pnpm exec tauri dev` CLI. The
wrapper starts Vite on a worktree-derived port (advancing if occupied), passes its actual
URL to Tauri, and reports an existing launcher instead of starting another for the same
worktree. HMR uses the Vite port.
- Debug app identity, data, settings, logs and single-instance scope follow the canonical
worktree path captured at build time, independent of launch cwd. Launching the same debug
binary again focuses its existing instance and forwards its path argument. Different
worktrees and the installed release can coexist. `--release` uses normal release identity
and bypasses the development wrapper.
- MCP bridge ports are dynamic (9223–9322). Use `pnpm dev:status`, connect to the reported
port, verify identifier/cwd, and pass `appIdentifier: <port>` explicitly. Do not assume the
default MCP session belongs to this worktree.
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
`target` symlinks at a shared directory.
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
15–30 GB. Avoid running several heavy builds on one machine at the same time.
- Stop only processes started for the current worktree/session. Never use broad
process-name kills such as `pkill openidea`.
- When done: stop that worktree's app and dev session, delete its build output, then
`git worktree remove ../<name>`.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+35
View File
@@ -0,0 +1,35 @@
# Contributing to OpenIDEA
These rules apply to everyone, and to any coding agent working on their behalf.
Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
## Issues
- One problem or request per issue. Use the issue forms.
- Bugs: steps that reproduce the problem from a fresh start. A minimal sample
project is the fastest way to get a fix.
- Features: state the problem and the acceptance criteria. A feature is ready to
build once a maintainer labels it `planned`.
- Labels track status: `needs-info` waits on the reporter, `needs-decision` waits
on a maintainer, `planned` is accepted, `needs-acceptance` has a fix waiting to be
checked.
## Pull requests
- One issue per PR, linked with `Closes #123`. Feature PRs need a `planned` issue.
Docs, CI and pure refactors may skip the issue.
- Reproduce the problem first, or write a test that fails before the fix.
- Fix the cause, and check whether the same cause breaks anything nearby.
- Keep the diff focused. Unrelated refactors go in their own PR.
- Title: [Conventional Commits](https://www.conventionalcommits.org/), e.g.
`fix(editor): keep the caret visible after folding`.
- Fill in the template. Keep the description short and in plain words. If it
doesn't fit in a few sentences, the PR is probably too big.
- **How it was verified** is required: the commands you ran and their results,
and what else the change could affect. UI changes need before/after screenshots;
multi-step interactions need a recording.
- If a change needs a product decision you can't make, ask in the issue and stop
rather than guessing.
A check on each PR adds `needs-info` when the issue link or the verification
section is missing, and removes it once they're filled in.
Generated
+75 -38
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,7 +4075,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4090,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,7 +4099,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4116,7 +4116,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-run",
@@ -4130,7 +4130,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4148,7 +4148,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4170,7 +4170,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"serde",
@@ -4181,9 +4181,29 @@ 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.34"
version = "0.1.35"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4195,7 +4215,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4208,11 +4228,15 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.34"
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,15 +4275,17 @@ dependencies = [
"specta-typescript",
"tempfile",
"tokio",
"toml 0.9.12+spec-1.1.0",
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
"zip",
]
[[package]]
name = "ide-http"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"parking_lot",
@@ -4273,7 +4303,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"blake3",
"criterion",
@@ -4295,7 +4325,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"encoding_rs",
"futures",
@@ -4311,11 +4341,12 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"blake3",
"criterion",
"ide-core",
"ide-extension",
"ide-text",
"parking_lot",
"regex-lite",
@@ -4366,7 +4397,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"dunce",
"futures",
@@ -4387,11 +4418,12 @@ dependencies = [
"tokio",
"toml 0.9.12+spec-1.1.0",
"tracing",
"zip",
]
[[package]]
name = "ide-notebook"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"futures",
"ide-core",
@@ -4414,26 +4446,27 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.34"
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]]
name = "ide-profiler"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-run",
@@ -4446,7 +4479,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"globset",
"ide-core",
@@ -4463,7 +4496,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4486,7 +4519,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"dunce",
"ide-core",
@@ -4505,7 +4538,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"criterion",
"globset",
@@ -4525,7 +4558,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"parking_lot",
@@ -4538,7 +4571,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4557,7 +4590,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-language",
@@ -4572,7 +4605,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"libc",
@@ -4585,7 +4618,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"criterion",
"ide-core",
@@ -4597,7 +4630,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"flate2",
"hex",
@@ -4618,7 +4651,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4637,7 +4670,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"chardetng",
"dunce",
@@ -4659,7 +4692,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"ide-core",
"open",
@@ -4675,7 +4708,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"blake3",
"dunce",
@@ -6147,8 +6180,9 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"blake3",
"block2 0.6.2",
"dirs 6.0.0",
@@ -6163,6 +6197,7 @@ dependencies = [
"ide-dap",
"ide-db",
"ide-docker",
"ide-extension",
"ide-fonts",
"ide-github",
"ide-host",
@@ -6194,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",
+6 -1
View File
@@ -1,9 +1,11 @@
[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.34"
version = "0.1.35"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -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"
+5
View File
@@ -65,6 +65,11 @@ from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
`src/features/vcs/mergeDiff3.ts`, `chunkOptimizer.ts` and `mergeModel.ts` also adapt
the IntelliJ Platform merge implementation (`MergeRangeUtil`, `MergeResolveUtil`,
`ChunkOptimizer.LineChunkOptimizer` and `MergeConflictModel`). Modified from the
originals. Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
languages whose JetBrains icons are not open source (Go, Ruby, Dart, Lua, Zig,
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
+24 -2
View File
@@ -1,7 +1,9 @@
//! Session settings (model, mode, thinking effort…), slash commands and context use, as
//! the agent reports them over ACP.
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
use crate::model::{
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
};
use serde_json::{Value, json};
/// Cursor exposes execution modes over ACP, but leaves approval policy to its client.
@@ -150,7 +152,27 @@ pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
/// `usage_update`: `{ used, size }`.
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32);
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)? })
let cost = update.get("cost").and_then(|c| {
Some(Box::new(AgentCost {
amount: c.get("amount")?.as_f64().filter(|a| a.is_finite() && *a >= 0.0)?,
currency: c.get("currency")?.as_str().filter(|c| !c.is_empty())?.to_owned(),
}))
});
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)?, cost })
}
/// The usage-limit windows a [`crate::LIMITS_UPDATE`] carries; malformed entries are skipped.
pub(crate) fn limits_of(update: &Value) -> Vec<AgentLimit> {
let limits = update.get("limits").and_then(Value::as_array).into_iter().flatten();
limits
.filter_map(|l| {
Some(AgentLimit {
window: l.get("window")?.as_str().filter(|w| !w.is_empty())?.to_owned(),
used_percent: l.get("usedPercent")?.as_f64().filter(|p| p.is_finite())?.clamp(0.0, 100.0),
resets_ms: l.get("resetsMs").and_then(Value::as_f64),
})
})
.collect()
}
fn legacy(
+73 -14
View File
@@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Weak};
use std::time::Duration;
@@ -44,11 +44,16 @@ const REPLAY_GRACE: Duration = Duration::from_millis(300);
/// can trail its `session/prompt` response by this much.
const TURN_GRACE: Duration = Duration::from_millis(100);
const STDERR_LINES: usize = 20;
/// An agent idle this long after its turn is stopped when it can reload its session
/// (`session/load`): the next message starts it again where it left off.
const IDLE_RELEASE: Duration = Duration::from_secs(5 * 60);
/// Updates of a session's state rather than its conversation.
const SESSION_STATE: [&str; 5] =
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]).
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
/// A native agent's own update: the usage limits of its account (see [`AgentLimit`]).
pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
const MAX_EMBED: u64 = 256 * 1024;
/// How long a probe waits for the agent to answer `initialize`.
@@ -90,6 +95,8 @@ struct Shared {
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
}
/// What an agent offered in its last session, for the New Task page.
@@ -205,6 +212,8 @@ struct Live {
capabilities: AgentCapabilities,
/// `promptCapabilities.embeddedContext`: referenced files are sent with their text.
embedded: bool,
/// `session/load`: the process can be stopped between turns and the session reloaded.
resumable: bool,
}
struct Task {
@@ -226,6 +235,8 @@ struct Task {
cancel_pending: AtomicBool,
/// The agent reported something for the current turn.
turn_started: AtomicBool,
/// Turns sent so far: an idle release scheduled before the latest one is void.
turns: AtomicU64,
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
config_sent: Mutex<HashSet<(String, String)>>,
dirty: Mutex<BTreeSet<(Session, usize)>>,
@@ -236,6 +247,11 @@ struct Task {
}
impl AgentManager {
#[cfg(test)]
fn set_idle_release(&self, delay: Duration) {
self.shared.idle_release_ms.store(delay.as_millis() as u64, Ordering::Relaxed);
}
/// Loads the project's saved tasks. Spawned work runs on `runtime`.
pub fn new(paths: AgentPaths, runtime: tokio::runtime::Handle) -> Self {
let connector: Connector = Arc::new(|spec, cwd, client, on_stderr| match spec.driver {
@@ -260,6 +276,7 @@ impl AgentManager {
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
});
let mut tasks = HashMap::new();
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
@@ -301,17 +318,11 @@ impl AgentManager {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
let list = specs
.iter()
.map(|p| AgentProvider {
id: p.id.clone(),
name: p.name.clone(),
command: std::iter::once(p.command.as_str())
.chain(p.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" "),
installed: provider::installed(&p.command),
config: self.shared.known_config(&p.id),
commands: self.shared.known_commands(&p.id),
source: p.source.clone(),
.map(|p| {
let mut info = provider::describe(p);
info.config = self.shared.known_config(&p.id);
info.commands = self.shared.known_commands(&p.id);
info
})
.collect();
let mut prev = self.shared.providers.write();
@@ -325,6 +336,15 @@ impl AgentManager {
list
}
/// Overrides built-in `id`'s command with `command` (the driver and arguments stay
/// the built-in's) through its agent file, or returns it to auto-detection with `None`.
pub fn set_builtin_command(&self, id: &str, command: Option<String>) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::set_builtin_command(&dir, id, command.as_deref())
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
@@ -883,6 +903,7 @@ impl Task {
send_now: AtomicBool::new(false),
cancel_pending: AtomicBool::new(false),
turn_started: AtomicBool::new(false),
turns: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
flush_scheduled: AtomicBool::new(false),
@@ -1025,12 +1046,42 @@ impl Task {
}
match (next, result) {
(Some(queued), _) => prompt = queued.prompt,
(None, Ok(_)) => return self.set_status(AgentStatus::Ready, None),
(None, Ok(_)) => {
self.set_status(AgentStatus::Ready, None);
return self.release_when_idle();
}
(None, Err(failure)) => return self.fail(failure),
}
}
}
/// Stops the agent's process once the task has stayed idle for [`IDLE_RELEASE`], so
/// finished tasks don't keep an agent (and what it started) running. Only an agent that
/// reloads its session: the next message starts it again with the conversation intact.
fn release_when_idle(self: &Arc<Self>) {
let turn = self.turns.load(Ordering::Acquire);
let delay = Duration::from_millis(self.shared.idle_release_ms.load(Ordering::Relaxed));
let task = Arc::downgrade(self);
self.shared.runtime.spawn(async move {
tokio::time::sleep(delay).await;
let Some(task) = task.upgrade() else { return };
if task.turns.load(Ordering::Acquire) != turn
|| task.state.lock().busy
|| !task.permissions.lock().is_empty()
{
return;
}
let live = {
let mut live = task.live.lock();
if live.as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) { live.take() } else { None }
};
if let Some(live) = live {
tracing::debug!("agent task {}: idle, stopping the agent until the next message", task.id);
live.peer.kill();
}
});
}
/// Ends a turn that failed: nothing it started stays open.
fn fail(self: &Arc<Self>, failure: Failure) {
self.cancel_permissions();
@@ -1091,6 +1142,7 @@ impl Task {
/// Sends one prompt, starting the agent first if needed; returns the stop reason.
async fn turn(self: &Arc<Self>, prompt: &AgentPrompt) -> Result<String, Failure> {
self.turns.fetch_add(1, Ordering::AcqRel);
self.turn_started.store(false, Ordering::Release);
self.state.lock().task.resets_ms = None;
let attachments = prompt
@@ -1287,7 +1339,7 @@ impl Task {
}
self.persist();
self.emit_task();
let live = Live { peer, session: session_id, capabilities, embedded };
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -1369,6 +1421,13 @@ impl Task {
}
return;
}
if kind == LIMITS_UPDATE {
if !self.replaying.load(Ordering::Acquire) {
let provider = self.state.lock().task.provider.clone();
self.shared.emit(AgentEvent::Limits { provider, limits: config::limits_of(update) });
}
return;
}
if kind == "session_info_update" {
// The agent's own title for the session replaces the first line of the prompt.
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
+47
View File
@@ -154,6 +154,30 @@ pub struct AgentUsage {
pub used: u32,
/// Size of the context window.
pub size: u32,
/// What the session has cost so far (ACP `usage_update.cost`), when the agent reports it.
// Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows.
#[serde(default)]
pub cost: Option<Box<AgentCost>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentCost {
pub amount: f64,
/// ISO 4217 code, e.g. `USD`.
pub currency: String,
}
/// One usage-limit window of an agent's account.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentLimit {
/// The window as the agent names it: `five_hour`, `seven_day`, `seven_day_opus`…
pub window: String,
/// Share of the window used, 0–100.
pub used_percent: f64,
/// When the window resets (ms since the epoch), if the agent said.
pub resets_ms: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -322,6 +346,15 @@ pub struct AgentProvider {
pub name: String,
/// The command line that starts its ACP server.
pub command: String,
/// Executable and arguments actually launched, without flattening paths containing spaces.
pub program: String,
pub args: Vec<String>,
pub default_program: Option<String>,
pub default_args: Vec<String>,
pub login_hint: Option<String>,
pub command_overridden: bool,
pub command_editable: bool,
pub command_read_only_reason: Option<AgentCommandReadOnlyReason>,
/// Whether the command was found (on `PATH`).
pub installed: bool,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
@@ -331,6 +364,15 @@ pub struct AgentProvider {
pub source: AgentSource,
}
/// Why the executable cannot be changed in built-in agent settings.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentCommandReadOnlyReason {
PackageRunner,
Registry,
CustomAgent,
}
/// Where an agent comes from.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
@@ -413,6 +455,11 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// An agent reported the usage limits of its account (shared by every task of `provider`).
Limits {
provider: String,
limits: Vec<AgentLimit>,
},
/// The installed or configured agent providers changed.
Providers,
}
@@ -98,6 +98,8 @@ impl Cli for Process {
use std::process::Stdio;
let mut cmd = tokio::process::Command::new(&l.program);
ide_core::process::prepare(cmd.as_std_mut());
// `claude` may be a wrapper (`claude.cmd` → node): stopping it stops what it started.
ide_core::process::own_group(cmd.as_std_mut());
cmd.args(&l.args)
.current_dir(&l.cwd)
.envs(ide_core::process::shell_env())
@@ -120,6 +122,9 @@ impl Cli for Process {
tokio::select! {
_ = child.wait() => {}
_ = token.cancelled() => {
if let Some(pid) = child.id() {
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
}
let _ = child.start_kill();
let _ = child.wait().await;
}
@@ -904,6 +904,7 @@ impl Session {
if info.get("status").and_then(Value::as_str) == Some("rejected") {
st.limit_resets_ms = info.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0);
}
updates.extend(st.tr.on_rate_limit(&info));
}
"command_lifecycle" => {
let uuid = msg.get("command_uuid").and_then(Value::as_str);
@@ -1013,6 +1013,39 @@ fn streamed_text_is_not_sent_twice() {
assert_eq!(usage[0].update, json!({"sessionUpdate": "usage_update", "used": 15, "size": 1000}));
}
#[test]
fn rate_limits_and_cost() {
let mut t = Translator::new("s", PathBuf::from("/w"), false);
let info = json!({"status": "allowed", "rateLimitType": "five_hour", "unifiedWindows": {
"five_hour": {"utilization": 0.19, "resetsAt": 100, "status": "allowed"},
"seven_day": {"utilization": 0.09, "resetsAt": 200, "status": "allowed"},
}});
let limits = t.on_rate_limit(&info).unwrap();
assert_eq!(limits.session, "s");
assert_eq!(limits.update["sessionUpdate"], crate::LIMITS_UPDATE);
let windows: Vec<(&str, i64, f64)> = limits.update["limits"]
.as_array()
.unwrap()
.iter()
.map(|l| {
(
l["window"].as_str().unwrap(),
l["usedPercent"].as_f64().unwrap().round() as i64,
l["resetsMs"].as_f64().unwrap(),
)
})
.collect();
assert_eq!(windows, [("five_hour", 19, 100_000.0), ("seven_day", 9, 200_000.0)]);
// Older versions report the window that applies, alone.
let single = t.on_rate_limit(&json!({"rateLimitType": "seven_day", "utilization": 0.5})).unwrap();
assert_eq!(single.update["limits"], json!([{"window": "seven_day", "usedPercent": 50.0, "resetsMs": null}]));
assert_eq!(t.on_rate_limit(&json!({"status": "allowed", "resetsAt": 100})), None, "no utilization, no update");
t.used = Some(10);
let usage = t.on_result(&json!({"total_cost_usd": 0.5, "modelUsage": {"x": {"contextWindow": 1000}}}));
assert_eq!(usage[0].update["cost"], json!({"amount": 0.5, "currency": "USD"}));
}
#[test]
fn tool_calls_plans_and_subagents() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
@@ -5,7 +5,7 @@
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
use super::tools;
use crate::RETRY_UPDATE;
use crate::{LIMITS_UPDATE, RETRY_UPDATE};
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
@@ -58,6 +58,8 @@ pub(super) struct Translator {
pub used: Option<u64>,
pub model: Option<String>,
windows: HashMap<String, u64>,
/// The session's cost so far in USD, as the last `result` reported it.
cost_usd: Option<f64>,
}
impl Translator {
@@ -75,6 +77,7 @@ impl Translator {
used: None,
model: None,
windows: HashMap::new(),
cost_usd: None,
}
}
@@ -543,6 +546,9 @@ impl Translator {
self.windows.insert(model.clone(), w);
}
}
if let Some(cost) = result.get("total_cost_usd").and_then(Value::as_f64).filter(|c| c.is_finite()) {
self.cost_usd = Some(cost);
}
self.usage_update().into_iter().collect()
}
@@ -557,9 +563,33 @@ impl Translator {
.or_else(|| self.windows.iter().find(|(k, _)| k.starts_with(m.as_str())).map(|(_, v)| v))
})
.or_else(|| (self.windows.len() == 1).then(|| self.windows.values().next()).flatten())?;
Some(Update {
let mut update = json!({ "sessionUpdate": "usage_update", "used": used, "size": size });
if let Some(amount) = self.cost_usd {
update["cost"] = json!({ "amount": amount, "currency": "USD" });
}
Some(Update { session: self.root.clone(), update })
}
/// The usage-limit windows a `rate_limit_event` reports, as a [`LIMITS_UPDATE`]: every
/// window of `unifiedWindows`, or the single `rateLimitType` window older versions send.
pub fn on_rate_limit(&self, info: &Value) -> Option<Update> {
let window = |name: &str, w: &Value| {
let utilization = w.get("utilization")?.as_f64()?;
Some(json!({
"window": name,
"usedPercent": utilization * 100.0,
"resetsMs": w.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0),
}))
};
let limits: Vec<Value> = match info.get("unifiedWindows").and_then(Value::as_object) {
Some(windows) => windows.iter().filter_map(|(name, w)| window(name, w)).collect(),
None => {
info.get("rateLimitType").and_then(Value::as_str).and_then(|t| window(t, info)).into_iter().collect()
}
};
(!limits.is_empty()).then(|| Update {
session: self.root.clone(),
update: json!({ "sessionUpdate": "usage_update", "used": used, "size": size }),
update: json!({ "sessionUpdate": LIMITS_UPDATE, "limits": limits }),
})
}
}
+213 -1
View File
@@ -2,7 +2,8 @@
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
//! Registry, see `registry`).
use crate::AgentSource;
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -92,6 +93,82 @@ pub(crate) fn builtin_name(id: &str) -> Option<&'static str> {
BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name)
}
/// The built-in spec of `id`.
pub(crate) fn builtin_spec(id: &str) -> Option<ProviderSpec> {
builtins().into_iter().find(|s| s.id == id)
}
/// Public launch metadata, keeping the built-in defaults separate from agent-file overrides.
pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
let builtin = builtin_spec(&spec.id);
let reason = if matches!(spec.source, AgentSource::Registry { .. }) {
Some(AgentCommandReadOnlyReason::Registry)
} else if let Some(default) = &builtin {
matches!(default.command.as_str(), "npx" | "bunx" | "uvx").then_some(AgentCommandReadOnlyReason::PackageRunner)
} else {
Some(AgentCommandReadOnlyReason::CustomAgent)
};
AgentProvider {
id: spec.id.clone(),
name: spec.name.clone(),
command: std::iter::once(spec.command.as_str())
.chain(spec.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" "),
program: spec.command.clone(),
args: spec.args.clone(),
default_program: builtin.as_ref().map(|b| b.command.clone()),
default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(),
login_hint: Some(spec.login_hint.clone()),
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
command_editable: reason.is_none(),
command_read_only_reason: reason,
installed: installed(&spec.command),
config: vec![],
commands: vec![],
source: spec.source.clone(),
}
}
/// Overrides built-in `id`'s command through its agent file, preserving other
/// customizations. `None` restores the built-in command and removes a redundant file.
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
let Some(builtin) = builtin_spec(id) else {
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
};
let file = custom_dir.join(format!("{id}.toml"));
let mut agent = if file.try_exists()? {
CustomAgent::read(&file).map_err(IdeError::invalid)?
} else {
CustomAgent {
name: Some(builtin.name.clone()),
driver: builtin.driver,
command: builtin.command.clone(),
args: builtin.args.clone(),
env: BTreeMap::new(),
registry: None,
}
};
if agent.registry.is_some() {
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry")));
}
if command.is_none()
&& agent.name.as_deref() == Some(builtin.name.as_str())
&& agent.driver == builtin.driver
&& agent.args == builtin.args
&& agent.env.is_empty()
{
match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
}
return Ok(());
}
agent.command = command.unwrap_or(&builtin.command).to_owned();
agent.write(&file)?;
Ok(())
}
/// An agent file, `<config>/agents/<id>.toml`.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -182,6 +259,9 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
match specs.iter_mut().find(|s| s.id == spec.id) {
Some(existing) => {
spec.login_hint = existing.login_hint.clone();
if spec.source == AgentSource::Custom {
spec.source = AgentSource::Builtin;
}
*existing = spec;
}
None => specs.push(spec),
@@ -221,6 +301,54 @@ fn is_executable(p: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
let dir = tempfile::tempdir().unwrap();
let list = || load(Some(dir.path())).iter().map(describe).collect::<Vec<_>>();
let defaults = list();
let codex = defaults.iter().find(|p| p.id == "codex").unwrap();
assert_eq!(codex.program, "codex");
assert_eq!(codex.default_program.as_deref(), Some("codex"));
assert_eq!(codex.args, ["app-server"]);
assert_eq!(codex.default_args, codex.args);
assert_eq!(codex.command, "codex app-server");
assert_eq!(codex.login_hint.as_deref(), Some("Run `codex login` in a terminal."));
assert!(!codex.command_overridden);
assert!(codex.command_editable);
for p in &defaults {
assert_eq!(p.source, AgentSource::Builtin);
assert!(p.default_program.is_some());
assert_eq!(p.command_editable, !p.id.ends_with("-acp"));
assert_eq!(
p.command_read_only_reason,
p.id.ends_with("-acp").then_some(AgentCommandReadOnlyReason::PackageRunner)
);
}
set_builtin_command(dir.path(), "codex", Some("/path with spaces/codex")).unwrap();
let overridden = list().into_iter().find(|p| p.id == "codex").unwrap();
assert_eq!(overridden.program, "/path with spaces/codex");
assert_eq!(overridden.args, codex.args);
assert_eq!(overridden.default_program, codex.default_program);
assert!(overridden.command_overridden && overridden.command_editable);
assert_eq!(overridden.source, AgentSource::Builtin);
set_builtin_command(dir.path(), "codex", Some("codex")).unwrap();
assert!(!list().iter().find(|p| p.id == "codex").unwrap().command_overridden);
std::fs::write(
dir.path().join("gemini.toml"),
"command = \"/registry/gemini\"\n[registry]\nid = \"gemini\"\nversion = \"1.0\"\n",
)
.unwrap();
let registry = list().into_iter().find(|p| p.id == "gemini").unwrap();
assert_eq!(registry.default_program.as_deref(), Some("gemini"));
assert!(!registry.command_editable);
assert_eq!(registry.command_read_only_reason, Some(AgentCommandReadOnlyReason::Registry));
assert!(matches!(registry.source, AgentSource::Registry { .. }));
std::fs::write(dir.path().join("mine.toml"), "command = \"mine\"\n").unwrap();
let custom = list().into_iter().find(|p| p.id == "mine").unwrap();
assert_eq!(custom.default_program, None);
assert_eq!(custom.command_read_only_reason, Some(AgentCommandReadOnlyReason::CustomAgent));
}
#[test]
fn custom_agents_extend_and_override_the_builtins() {
let dir = tempfile::tempdir().unwrap();
@@ -240,6 +368,90 @@ mod tests {
assert!(installed("sh"));
}
#[test]
fn set_builtin_command_overrides_and_clears_a_builtin() {
let dir = tempfile::tempdir().unwrap();
set_builtin_command(dir.path(), "not-a-builtin", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
assert_eq!((claude.command.as_str(), claude.driver), ("/opt/claude", Driver::Claude));
assert!(claude.args.is_empty());
// A registry install is neither overwritten nor removed by a command override.
let path = dir.path().join("codex.toml");
CustomAgent {
name: None,
driver: Driver::Acp,
command: "amp".into(),
args: vec![],
env: BTreeMap::new(),
registry: Some(RegistryRef { id: "amp-acp".into(), version: "1.0.0".into() }),
}
.write(&path)
.unwrap();
set_builtin_command(dir.path(), "codex", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), "codex", None).unwrap_err();
assert!(path.exists());
// Clearing (twice) returns the built-in to auto-detection.
set_builtin_command(dir.path(), "claude", None).unwrap();
set_builtin_command(dir.path(), "claude", None).unwrap();
assert!(!dir.path().join("claude.toml").exists());
assert_eq!(load(Some(dir.path())).iter().find(|s| s.id == "claude").unwrap().command, "claude");
}
#[test]
fn set_builtin_command_preserves_other_customizations_on_save_and_reset() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("claude.toml");
for field in ["name", "driver", "args", "env", "unnamed"] {
let mut agent = CustomAgent {
name: Some("Claude Code".into()),
driver: Driver::Claude,
command: "/old/claude".into(),
args: vec![],
env: BTreeMap::new(),
registry: None,
};
match field {
"name" => agent.name = Some("My Claude".into()),
"driver" => agent.driver = Driver::Acp,
"args" => agent.args.push("--verbose".into()),
"env" => {
agent.env.insert("K".into(), "v".into());
}
"unnamed" => agent.name = None,
_ => unreachable!(),
}
agent.write(&path).unwrap();
set_builtin_command(dir.path(), "claude", Some("/new/claude")).unwrap();
agent.command = "/new/claude".into();
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
set_builtin_command(dir.path(), "claude", None).unwrap();
agent.command = "claude".into();
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
}
}
#[test]
fn resetting_a_command_only_override_removes_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("codex.toml");
std::fs::write(
&path,
"name = \"Codex\"\ndriver = \"codex\"\ncommand = \"/custom/codex\"\nargs = [\"app-server\"]\n",
)
.unwrap();
set_builtin_command(dir.path(), "codex", None).unwrap();
assert!(!path.exists());
assert_eq!(
load(Some(dir.path())).into_iter().find(|s| s.id == "codex").unwrap(),
builtin_spec("codex").unwrap()
);
}
#[test]
fn agent_files_round_trip_with_their_registry_table() {
let dir = tempfile::tempdir().unwrap();
+2 -18
View File
@@ -826,15 +826,7 @@ mod tests {
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir))
.into_iter()
.filter(|s| s.id == "amp-acp")
.map(|s| AgentProvider {
id: s.id,
name: s.name,
command: s.command,
installed: false,
config: vec![],
commands: vec![],
source: s.source,
})
.map(|s| provider::describe(&s))
.collect();
mark_updates(&p, &mut providers);
assert_eq!(
@@ -1001,15 +993,7 @@ mod tests {
assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in.");
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None });
let mut providers = vec![AgentProvider {
id: spec.id,
name: spec.name,
command: spec.command,
installed: true,
config: vec![],
commands: vec![],
source: spec.source,
}];
let mut providers = vec![provider::describe(&spec)];
mark_updates(&p, &mut providers);
assert_eq!(
providers[0].source,
+94 -2
View File
@@ -9,6 +9,8 @@ struct FakeAgent {
/// Every connection's agent side, kept open.
servers: Mutex<Vec<Peer>>,
calls: Mutex<Vec<String>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
}
impl FakeAgent {
@@ -89,7 +91,7 @@ impl Handler for FakeAgent {
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
self.calls.lock().push("subagents".into());
}
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true,
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true}}}}))
}
"session/new" => Ok(json!({"sessionId": "s1",
@@ -145,7 +147,16 @@ impl Handler for FakeAgent {
json!({"sessionUpdate": "available_commands_update",
"availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}),
);
self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000}));
self.update(
&session,
json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000,
"cost": {"amount": 0.25, "currency": "USD"}}),
);
self.update(
&session,
json!({"sessionUpdate": LIMITS_UPDATE,
"limits": [{"window": "five_hour", "usedPercent": 19.0, "resetsMs": 5000.0}]}),
);
self.update(&session, json!({"sessionUpdate": "session_info_update", "title": "Lexer fix"}));
self.say(&session, "Hi");
self.update(
@@ -358,6 +369,10 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask")));
assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["review"]);
assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000)));
assert_eq!(
t.usage.as_ref().and_then(|u| u.cost.clone()),
Some(Box::new(AgentCost { amount: 0.25, currency: "USD".into() }))
);
assert_eq!(t.title, "Lexer fix", "the agent's title replaces the prompt's first line");
}
mgr.set_config(&task.id, "mode", "code".into()).await.unwrap();
@@ -370,6 +385,18 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
assert!(
events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput))
);
let limits = events.iter().find_map(|e| match e {
AgentEvent::Limits { provider, limits } => Some((provider.as_str(), limits.clone())),
_ => None,
});
assert_eq!(
limits,
Some((
task.provider.as_str(),
vec![AgentLimit { window: "five_hour".into(), used_percent: 19.0, resets_ms: Some(5000.0) }]
)),
"an agent's usage limits are its provider's"
);
let last_entry = events.iter().rev().find_map(|e| match e {
AgentEvent::Entry { index, entry, .. } if !matches!(entry, AgentEntry::User { .. }) => {
Some((*index, entry.clone()))
@@ -772,6 +799,18 @@ fn retry_updates_become_a_countdown() {
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
}
#[test]
fn usage_limits_and_cost_skip_what_is_malformed() {
let limits = config::limits_of(&json!({"sessionUpdate": LIMITS_UPDATE, "limits": [
{"window": "seven_day", "usedPercent": 140.0},
{"window": "", "usedPercent": 10.0},
{"window": "five_hour"},
]}));
assert_eq!(limits, [AgentLimit { window: "seven_day".into(), used_percent: 100.0, resets_ms: None }]);
let usage = config::usage_of(&json!({"used": 1, "size": 2, "cost": {"amount": -1.0, "currency": "USD"}}));
assert_eq!(usage.map(|u| u.cost), Some(None), "a negative cost is dropped, the context use kept");
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
let tmp = tempfile::tempdir().unwrap();
@@ -1017,3 +1056,56 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
let probe = mgr.probe("copilot", false).await.unwrap();
assert!(probe.error.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
let new = || NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
};
let live =
|mgr: &AgentManager, id: &str| mgr.task(id).unwrap().live.lock().as_ref().is_some_and(|l| !l.peer.is_closed());
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(live(&mgr, &task.id));
// The process is stopped once the task stays idle…
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id));
assert!(agent.servers.lock()[0].is_closed(), "its connection is closed");
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "not an error");
// …and the next message starts it again on the same session.
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().contains(&"session/load".to_owned()));
assert_eq!(
mgr.entries(&task.id, None).unwrap().iter().filter(|e| matches!(e, AgentEntry::Agent { .. })).count(),
2
);
// A new turn before the delay ran out keeps the agent.
mgr.prompt(&task.id, text("again"), false).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(live(&mgr, &task.id));
mgr.shutdown();
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(live(&mgr, &task.id));
mgr.shutdown();
}
+31 -4
View File
@@ -158,8 +158,11 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
}
/// `root/rel`, without a trailing separator for the root itself.
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
/// as other tools (JDT LS) spell it.
fn subdir(root: &Path, rel: &str) -> PathBuf {
if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
}
fn rel_str(root: &Path, dir: &Path) -> String {
@@ -737,11 +740,11 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
.iter()
.flat_map(|s| {
["classes/kotlin", "classes/groovy", "classes/scala", "classes/java", "resources"]
.map(|d| module.join("build").join(d).join(s))
.map(|d| subdir(module, &format!("build/{d}/{s}")))
})
.collect(),
BuildSystem::Maven if test => vec![module.join("target/test-classes"), module.join("target/classes")],
BuildSystem::Maven => vec![module.join("target/classes")],
BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")],
BuildSystem::Maven => vec![subdir(module, "target/classes")],
_ => Vec::new(),
};
dirs.into_iter().filter(|d| d.is_dir()).collect()
@@ -751,6 +754,30 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
mod tests {
use super::*;
/// Launch outputs use the platform separator throughout, so they compare equal to
/// the same directories as JDT LS reports them.
#[test]
fn launch_dirs_use_one_separator() {
let root = tempfile::tempdir().unwrap();
let module = subdir(root.path(), "parent/app");
for dir in ["target/classes", "target/test-classes", "build/classes/java/main", "build/resources/test"] {
std::fs::create_dir_all(subdir(&module, dir)).unwrap();
}
let foreign = if std::path::MAIN_SEPARATOR == '\\' { '/' } else { '\\' };
let dirs = [
launch_class_dirs(BuildSystem::Maven, &module, true),
launch_class_dirs(BuildSystem::Gradle, &module, true),
]
.concat();
assert_eq!(dirs.len(), 4, "{dirs:?}");
for dir in &dirs {
let suffix = dir.strip_prefix(root.path()).unwrap().to_string_lossy().into_owned();
assert!(!suffix.contains(foreign), "{suffix}");
}
assert_eq!(subdir(root.path(), ""), root.path());
assert_eq!(subdir(root.path(), "a//b/"), root.path().join("a").join("b"));
}
fn write(root: &Path, rel: &str, text: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+40
View File
@@ -0,0 +1,40 @@
// OpenIDEA Gradle sync: prints each project as Gradle evaluated it, one JSON line per project.
// Read by `ide_build::gradle::parse_reported_projects`. Dependencies are not resolved: the
// Java language server reads the classpath itself, and resolving is the slow part of an import.
import groovy.json.JsonOutput
def canon = { File f -> f.canonicalPath }
// Init scripts also run in included builds and buildSrc, whose projects reuse `:`, `:core`…:
// the identity path (`:lib:core`, `:buildSrc`) tells them apart and addresses their tasks
// from the root build. Internal API, hence the fallback.
def identity = { Project p -> try { p.identityPath.toString() } catch (ignored) { p.path } }
def describe = { Project p ->
def m = [path: identity(p), dir: canon(p.projectDir), buildDir: canon(p.layout.buildDirectory.get().asFile)]
if (p.gradle.parent != null) m.build = identity(p.rootProject)
def sets = p.extensions.findByName('sourceSets')
if (sets != null) {
// Test source sets as IDEA classifies them: `test`, test fixtures, JVM test suites,
// and sets whose folders the `idea` plugin lists as test sources.
def suites = [] as Set
try { p.extensions.findByName('testing')?.suites?.each { suites << it.sources.name } } catch (ignored) {}
def ideaTests = [] as Set
try { p.extensions.findByName('idea')?.module?.testSources?.files?.each { ideaTests << canon(it) } } catch (ignored) {}
m.sourceSets = sets.collect { s ->
def res = s.resources.srcDirs.collect(canon)
def src = s.allSource.srcDirs.collect(canon) - res
def test = s.name == 'test' || s.name == 'testFixtures' || suites.contains(s.name) ||
s.name.endsWith('Test') || s.name.endsWith('Tests') ||
(!src.isEmpty() && src.every { ideaTests.contains(it) })
[name: s.name, test: test, src: src, res: res]
}
}
try {
def java = p.extensions.findByName('java')
def lv = java?.toolchain?.languageVersion?.getOrNull()
m.java = lv != null ? lv.asInt() : (java?.targetCompatibility?.majorVersion as Integer)
} catch (ignored) {}
println '@openidea-model\t' + JsonOutput.toJson(m)
}
gradle.projectsEvaluated { g -> g.rootProject.allprojects { describe(it) } }
+340 -37
View File
@@ -32,7 +32,8 @@ pub struct GradleModel {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GradleProject {
/// Gradle path: `:` for the root project, `:app`, `:libs:core`.
/// Gradle path: `:` for the root project, `:app`, `:libs:core`; for a project of a
/// nested build its identity path (`:lib:core`), which addresses its tasks from the root.
pub path: String,
pub name: String,
/// Project-relative directory.
@@ -40,6 +41,10 @@ pub struct GradleProject {
/// Project-relative build script, if any.
pub build_file: Option<String>,
pub tasks: Vec<GradleTask>,
/// Identity path of the nested build holding the project (`:lib` for `includeBuild`,
/// `:buildSrc`); null for the opened build.
#[serde(default)]
pub build: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -129,35 +134,88 @@ fn include_args(after: &str) -> Vec<String> {
out
}
/// `(root project name, included project paths)` from a settings script.
pub fn parse_settings(text: &str) -> (Option<String>, Vec<String>) {
/// What a settings script declares, read statically.
#[derive(Debug, Default, PartialEq)]
pub struct Settings {
pub name: Option<String>,
/// Included project paths (`:app`), in declaration order.
pub includes: Vec<String>,
/// Project directories that differ from the default `<path>` under the build
/// dir, relative to it: `includeFlat` siblings and literal `projectDir = file(..)`.
pub dirs: BTreeMap<String, String>,
}
/// The included projects, root project name and directory overrides of a settings script.
/// Interpolated (`"$m"`) names and directories cannot be read without Gradle and are skipped.
pub fn parse_settings(text: &str) -> Settings {
let text = strip_comments(text);
let mut name = None;
let mut includes: Vec<String> = Vec::new();
let mut s = Settings::default();
for (i, _) in text.match_indices("rootProject.name") {
let line = text[i + "rootProject.name".len()..].lines().next().unwrap_or_default();
if let Some(n) = literals(line).into_iter().next() {
name = Some(n);
s.name = Some(n);
}
}
let mut from = 0;
while let Some(i) = text[from..].find("include") {
let at = from + i;
from = at + "include".len();
let flat = text[from..].starts_with("Flat");
if flat {
from += "Flat".len();
}
let before_ok = at == 0 || !text[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.');
let after = &text[from..];
let after_ok = after.starts_with(|c: char| c == '(' || c.is_whitespace());
if !before_ok || !after_ok {
continue;
}
for p in include_args(after) {
for p in include_args(after).into_iter().filter(|p| !p.contains('$')) {
let path = if p.starts_with(':') { p } else { format!(":{p}") };
if path.len() > 1 && !includes.contains(&path) {
includes.push(path);
if path.len() <= 1 {
continue;
}
// `includeFlat 'a'` is the sibling directory `../a` of the root project.
if flat {
s.dirs.insert(path.clone(), format!("../{}", path.trim_start_matches(':')));
}
if !s.includes.contains(&path) {
s.includes.push(path);
}
}
}
(name, includes)
// `project(':a').projectDir = file('../a')`; a later assignment wins, as in Gradle.
for line in text.lines() {
let Some((left, right)) = line.split_once(".projectDir") else { continue };
let Some(right) = right.trim_start().strip_prefix('=') else { continue };
let Some(arg) = right.trim_start().strip_prefix("file") else { continue };
let (Some(path), Some(dir)) = (
left.rsplit_once("project").map(|(_, p)| literals(p)).and_then(|l| l.into_iter().next()),
literals(arg).into_iter().next(),
) else {
continue;
};
if !path.contains('$') && !dir.contains('$') {
let path = if path.starts_with(':') { path } else { format!(":{path}") };
s.dirs.insert(path, dir);
}
}
s
}
/// `a/b/../c` → `a/c`, without touching the filesystem.
fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir if out.file_name().is_some() => {
out.pop();
}
c => out.push(c),
}
}
out
}
/// The directory of the Gradle build under `root`: the root itself, or a direct
@@ -235,31 +293,159 @@ pub fn default_tasks(is_root: bool, script: &str) -> Vec<GradleTask> {
}
/// Reads the Gradle build under `root` (no Gradle run), or `None` when there is none.
fn read_first(d: &Path, names: &[&str]) -> Option<(String, String)> {
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
}
/// The project `path` in `pdir`, with the default tasks of its build script.
/// Outside `root` the directory stays absolute (`rel` falls back).
fn project_at(root: &Path, path: String, name: String, pdir: &Path) -> GradleProject {
let script = read_first(pdir, BUILD_FILES);
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
GradleProject {
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
dir: rel(root, pdir),
path,
name,
tasks,
build: None,
}
}
/// A project as Gradle evaluated it (the sync's init script, `gradle-model.gradle`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportedProject {
pub path: String,
/// Absolute, canonical.
pub dir: String,
/// Absolute output folder (`layout.buildDirectory`).
#[serde(default)]
pub build_dir: Option<String>,
#[serde(default)]
pub source_sets: Vec<ReportedSourceSet>,
/// Toolchain language version, else target compatibility.
#[serde(default)]
pub java: Option<u32>,
/// Identity path of the nested build (included build, buildSrc) reporting the project.
#[serde(default)]
pub build: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ReportedSourceSet {
pub name: String,
pub test: bool,
/// Absolute source folders (Java, Kotlin, Groovy, …), which need not exist.
#[serde(default)]
pub src: Vec<String>,
/// Absolute resource folders.
#[serde(default)]
pub res: Vec<String>,
}
const MODEL_LINE: &str = "@openidea-model\t";
/// The projects printed by `gradle-model.gradle`; malformed lines are skipped.
pub fn parse_reported_projects(output: &str) -> Vec<ReportedProject> {
output.lines().filter_map(|l| serde_json::from_str(l.trim_end().strip_prefix(MODEL_LINE)?).ok()).collect()
}
/// Source roots of the reported source sets: `(absolute folder, is test, is resources)`.
pub fn reported_source_roots(reported: &[ReportedProject]) -> Vec<(PathBuf, ReportedRoot)> {
let mut out: Vec<(PathBuf, ReportedRoot)> = Vec::new();
for p in reported {
for set in &p.source_sets {
let (src, res) = if set.test {
(ReportedRoot::Test, ReportedRoot::TestResources)
} else {
(ReportedRoot::Source, ReportedRoot::Resources)
};
for (dir, kind) in set.src.iter().map(|d| (d, src)).chain(set.res.iter().map(|d| (d, res))) {
let dir = PathBuf::from(dir);
// A folder shared by `main` and another set is `main`'s, as in IDEA.
if !out.iter().any(|(d, _)| *d == dir) {
out.push((dir, kind));
}
}
}
// IDEA excludes the build folder of every module.
if let Some(b) = &p.build_dir {
out.push((PathBuf::from(b), ReportedRoot::Excluded));
}
}
out
}
/// Role of a folder a Gradle sync reported.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReportedRoot {
Source,
Test,
Resources,
TestResources,
Excluded,
}
/// Replaces the statically read projects with the ones Gradle reported (root first):
/// settings scripts may compute their projects, which no static reading sees.
pub fn merge_reported_projects(root: &Path, model: &mut GradleModel, reported: &[ReportedProject]) {
if reported.is_empty() {
return;
}
let root_name = model.projects.iter().find(|p| p.path == ":").map(|p| p.name.clone());
model.projects = reported
.iter()
.map(|p| {
let name = match p.path.as_str() {
":" => root_name.clone().unwrap_or_default(),
path => path.rsplit(':').next().unwrap_or_default().to_owned(),
};
let mut project = project_at(root, p.path.clone(), name, Path::new(&p.dir));
// buildSrc is built before the build, never addressable as `:buildSrc:<task>`.
if p.build.as_deref().is_some_and(|b| b.ends_with(":buildSrc")) {
project.tasks.clear();
}
project.build = p.build.clone();
project
})
.collect();
// Each build reports its projects when it is evaluated (nested builds first).
model.projects.sort_by(|a, b| a.path.cmp(&b.path));
// One JDK runs the whole build: the newest any module needs.
if let Some(v) = reported.iter().filter_map(|p| p.java).max() {
model.java_version = Some(v);
}
}
/// Project directories outside `root`, outermost only (`../svc`, not also `../svc/sources/app`):
/// the folders IDEA shows as extra top-level module roots.
pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = dirs.into_iter().map(|d| root.join(d)).filter(|d| !d.starts_with(root)).collect();
dirs.sort();
dirs.dedup();
let mut out: Vec<PathBuf> = Vec::new();
for d in dirs {
if !out.iter().any(|o| d.starts_with(o)) {
out.push(d);
}
}
out
}
pub fn load(root: &Path) -> Option<GradleModel> {
let dir = build_dir(root)?;
let read = |d: &Path, names: &[&str]| {
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
};
let (name, includes) = read(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
let root_name =
name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
let mut projects = Vec::new();
let mut add = |path: String, name: String, pdir: PathBuf| {
let script = read(&pdir, BUILD_FILES);
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
projects.push(GradleProject {
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
dir: rel(root, &pdir),
path,
name,
tasks,
});
};
add(":".into(), root_name, dir.clone());
for path in includes {
let pdir = dir.join(path.trim_start_matches(':').replace(':', "/"));
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
let mut projects = vec![project_at(root, ":".into(), root_name, &dir)];
for path in settings.includes {
let pdir = match settings.dirs.get(&path) {
Some(d) => normalize(&dir.join(d)),
None => dir.join(path.trim_start_matches(':').replace(':', "/")),
};
let name = path.rsplit(':').next().unwrap_or_default().to_owned();
add(path, name, pdir);
projects.push(project_at(root, path, name, &pdir));
}
let java_version = projects
.iter()
@@ -358,9 +544,32 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
out.extend(SETTINGS_FILES.iter().map(|f| nested.join(f)).filter(|f| f.is_file()));
}
// What IDEA's auto-import also watches: properties, the wrapper and version catalogs.
let extra = ["gradle.properties", "gradle/wrapper/gradle-wrapper.properties"].map(|f| dir.join(f));
out.extend(extra.into_iter().filter(|p| p.is_file()));
if let Ok(entries) = std::fs::read_dir(dir.join("gradle")) {
let mut catalogs: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.to_string_lossy().ends_with(".versions.toml") && p.is_file())
.collect();
catalogs.sort();
out.extend(catalogs);
}
out
}
/// A build script without comments and with whitespace runs collapsed, so editing a
/// comment or reformatting does not make a sync stale (IDEA hashes lexer tokens alike).
pub(crate) fn script_fingerprint(text: &str) -> String {
strip_comments(text).split_whitespace().collect::<Vec<_>>().join(" ")
}
/// `(project path, task)` from `gradle tasks --all` console output; tasks listed as
/// `app:run` belong to `:app`, unqualified ones to the root project `:`.
pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
@@ -451,14 +660,60 @@ dependencies {
/* include("block") */
includeBuild("plugins")
"#;
let (name, includes) = parse_settings(kts);
assert_eq!(name.as_deref(), Some("gradle-demo"));
assert_eq!(includes, [":app", ":lib", ":libs:core"]);
let s = parse_settings(kts);
assert_eq!(s.name.as_deref(), Some("gradle-demo"));
assert_eq!(s.includes, [":app", ":lib", ":libs:core"]);
assert!(s.dirs.is_empty());
let groovy = "rootProject.name = 'demo'\ninclude 'a',\n 'b'\ninclude ':c'\nprintln 'x'\n";
let (name, includes) = parse_settings(groovy);
assert_eq!(name.as_deref(), Some("demo"));
assert_eq!(includes, [":a", ":b", ":c"]);
let s = parse_settings(groovy);
assert_eq!(s.name.as_deref(), Some("demo"));
assert_eq!(s.includes, [":a", ":b", ":c"]);
}
#[test]
fn settings_include_flat_and_project_dirs() {
let groovy = r#"
includeFlat 'svc-a'
includeFlat 'svc-b', 'svc-c' // trailing comment
['x', 'y'].each { m -> include ":svc-a:$m" }
include ':wo'
project(':wo').projectDir = file('../svc-a/.worktrees/wo')
project(":dyn").projectDir = file("${rootDir}/dyn")
includeFlated 'not-a-call'
"#;
let s = parse_settings(groovy);
assert_eq!(s.includes, [":svc-a", ":svc-b", ":svc-c", ":wo"]);
assert_eq!(
s.dirs.into_iter().collect::<Vec<_>>(),
[
(":svc-a".into(), "../svc-a".into()),
(":svc-b".into(), "../svc-b".into()),
(":svc-c".into(), "../svc-c".into()),
(":wo".into(), "../svc-a/.worktrees/wo".into()),
]
);
let kts = "includeFlat(\"k\")\nproject(\":k\").projectDir = file(\"../other\")";
let s = parse_settings(kts);
assert_eq!(s.includes, [":k"]);
assert_eq!(s.dirs.get(":k").map(String::as_str), Some("../other"));
}
#[test]
fn loads_include_flat_siblings() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path();
std::fs::create_dir_all(ws.join("root")).unwrap();
std::fs::create_dir_all(ws.join("svc")).unwrap();
std::fs::write(ws.join("root/settings.gradle"), "rootProject.name = 'Root'\nincludeFlat 'svc'").unwrap();
std::fs::write(ws.join("svc/build.gradle"), "plugins { id 'java' }").unwrap();
let root = ws.join("root");
let m = load(&root).unwrap();
let svc = m.projects.iter().find(|p| p.path == ":svc").unwrap();
assert_eq!(Path::new(&svc.dir), ws.join("svc"));
assert_eq!(svc.build_file.as_deref().map(Path::new), Some(ws.join("svc/build.gradle").as_path()));
assert!(root.join(svc.build_file.as_ref().unwrap()).is_file());
}
#[test]
@@ -539,4 +794,52 @@ Pattern: clean<TaskName>: Cleans the output files of a task.
let none = tempfile::tempdir().unwrap();
assert!(load(none.path()).is_none());
}
#[test]
fn merges_included_builds_and_build_src_by_identity_path() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path();
for d in ["shop/app", "shop/buildSrc", "lib/core"] {
std::fs::create_dir_all(ws.join(d)).unwrap();
}
std::fs::write(ws.join("shop/settings.gradle"), "rootProject.name = 'shop'\ninclude 'app'").unwrap();
std::fs::write(ws.join("lib/settings.gradle"), "include 'core'").unwrap();
let root = ws.join("shop");
// In evaluation order, as the init script prints it: nested builds first.
let line = |path: &str, d: &str, build: Option<&str>| {
let project = ReportedProject {
path: path.into(),
dir: ws.join(d).to_string_lossy().into_owned(),
build: build.map(Into::into),
..ReportedProject::default()
};
format!("{MODEL_LINE}{}\n", serde_json::to_string(&project).unwrap())
};
let out = [
line(":lib", "lib", Some(":lib")),
line(":lib:core", "lib/core", Some(":lib")),
line(":buildSrc", "shop/buildSrc", Some(":buildSrc")),
line(":", "shop", None),
line(":app", "shop/app", None),
]
.concat();
let mut model = load(&root).unwrap();
merge_reported_projects(&root, &mut model, &parse_reported_projects(&out));
let projects: Vec<_> =
model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str(), p.build.as_deref())).collect();
assert_eq!(
projects,
[
(":", "shop", None),
(":app", "app", None),
(":buildSrc", "buildSrc", Some(":buildSrc")),
(":lib", "lib", Some(":lib")),
(":lib:core", "core", Some(":lib")),
]
);
let build_src = &model.projects[2];
assert!(build_src.tasks.is_empty(), "buildSrc tasks cannot run from the root build");
assert!(!model.projects[4].tasks.is_empty());
assert!(build_files(&root, &model).contains(&ws.join("lib/settings.gradle")));
}
}
+13 -1
View File
@@ -18,7 +18,7 @@ mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven};
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -65,6 +65,18 @@ pub fn load_linked(
BuildModel { maven, gradle }
}
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
/// extra content roots, as IDEA lists module roots beside the project folder.
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec<PathBuf> {
gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
}
/// A library the project depends on, as `groupId:artifactId:version`.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DependencyCoordinate {
+184 -13
View File
@@ -245,7 +245,8 @@ struct RawPom {
plugins: Vec<RawPlugin>,
managed_plugins: Vec<RawPlugin>,
profiles: Vec<RawProfile>,
/// `<build><plugins>` `maven-compiler-plugin` `<release>` / `<source>`.
/// `maven-compiler-plugin` `<release>` / `<source>` of `<build><plugins>`, else of
/// `<pluginManagement>`.
compiler_release: Option<String>,
}
@@ -300,6 +301,16 @@ fn read_build(e: &Element, plugins: &mut Vec<RawPlugin>, managed: &mut Vec<RawPl
managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin));
}
/// `maven-compiler-plugin` `<release>` / `<source>` among the plugins at `path`.
fn compiler_level(project: &Element, path: &str) -> Option<String> {
project
.list(path, "plugin")
.into_iter()
.find(|p| p.text_of("artifactId").as_deref() == Some("maven-compiler-plugin"))
.and_then(|p| p.child("configuration"))
.and_then(|c| c.text_of("release").or_else(|| c.text_of("source")))
}
fn read_raw(project: &Element) -> RawPom {
let parent = project.child("parent").and_then(|p| {
Some(ParentRef {
@@ -328,13 +339,8 @@ fn read_raw(project: &Element) -> RawPom {
..RawPom::default()
};
read_build(project, &mut pom.plugins, &mut pom.managed_plugins);
if let Some(c) = pom.plugins.iter().position(|p| p.artifact_id == "maven-compiler-plugin") {
let plugin =
project.list("build/plugins", "plugin").into_iter().filter(|p| p.text_of("artifactId").is_some()).nth(c);
if let Some(cfg) = plugin.and_then(|p| p.child("configuration")) {
pom.compiler_release = cfg.text_of("release").or_else(|| cfg.text_of("source"));
}
}
pom.compiler_release =
compiler_level(project, "build/plugins").or_else(|| compiler_level(project, "build/pluginManagement/plugins"));
for p in project.list("profiles", "profile") {
let mut profile = RawProfile {
id: p.text_of("id").unwrap_or_else(|| "default".into()),
@@ -411,6 +417,9 @@ struct Inherited {
managed: Vec<RawDep>,
plugins: Vec<RawPlugin>,
managed_plugins: Vec<RawPlugin>,
/// The nearest `maven-compiler-plugin` `<release>` / `<source>`: a module usually
/// inherits its compiler level from the parent's (managed) plugin configuration.
compiler_release: Option<String>,
/// `<id>`, `activeByDefault`: profiles of the POM and its ancestors —
/// Maven lets a child enable a parent's profiles with `-P`.
profiles: Vec<(String, bool)>,
@@ -538,6 +547,9 @@ impl Resolver {
merge_by(&mut ctx.deps, &pom.deps, RawDep::key);
merge_plugins(&mut ctx.plugins, &pom.plugins);
merge_plugins(&mut ctx.managed_plugins, &pom.managed_plugins);
if pom.compiler_release.is_some() {
ctx.compiler_release = pom.compiler_release.clone();
}
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
merge_by(&mut ctx.properties, &p.properties, |(k, _)| k.clone());
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
@@ -966,13 +978,19 @@ fn rel_path(root: &Path, p: &Path) -> String {
}
/// The POMs that are not modules of another one: the root `pom.xml`, else the
/// shallowest `pom.xml` of each branch (a folder holding several builds).
/// shallowest `pom.xml` of each branch (a folder holding several builds). An
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
/// with its modules beside it, `src/app/pom.xml` and so on).
fn top_level_poms(root: &Path) -> Vec<PathBuf> {
let top = root.join("pom.xml");
if top.is_file() {
return vec![top];
}
let mut out = Vec::new();
let src_pom = root.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = queue.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
@@ -986,9 +1004,16 @@ fn top_level_poms(root: &Path) -> Vec<PathBuf> {
}
dirs.sort();
for d in dirs.into_iter().rev() {
if d.join("pom.xml").is_file() {
out.push(d.join("pom.xml"));
} else if depth + 1 < POM_SEARCH_DEPTH {
let pom = d.join("pom.xml");
if pom.is_file() {
out.push(pom);
continue;
}
let src_pom = d.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
if depth + 1 < POM_SEARCH_DEPTH {
queue.push((d, depth + 1));
}
}
@@ -1110,7 +1135,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
let jv = ["maven.compiler.release", "java.version", "maven.compiler.source", "maven.compiler.target"]
.iter()
.find_map(|k| props.get(*k).map(|v| i(v)))
.or_else(|| pom.compiler_release.as_deref().map(i))
.or_else(|| ctx.compiler_release.as_deref().map(i))
.and_then(|v| java_version(&v));
// The project can activate the profiles of its own POM and its ancestors'.
@@ -1183,6 +1208,40 @@ impl UnlinkedProjects {
self.poms.iter().map(String::as_str)
}
/// The POMs the Java language server should import when an unlinked POM is a module of a
/// linked reactor, else `None` (excluding the unlinked directories is enough then).
///
/// jdtls imports an aggregator together with all of its modules, whatever its import
/// exclusions say, so a reactor keeping an unlinked module is left out; its other linked
/// modules are listed on their own. Such an aggregator (`pom` packaging) has no sources.
pub fn import_poms(&self, model: &MavenModel) -> Option<Vec<String>> {
let linked: Vec<&MavenProject> = model.projects.iter().filter(|p| !self.contains(&p.pom)).collect();
if !linked.iter().any(|p| p.modules.iter().any(|m| self.contains(m))) {
return None;
}
let by_pom: std::collections::HashMap<String, &MavenProject> =
model.projects.iter().map(|p| (normalized_pom(&p.pom), p)).collect();
// Whether `pom`'s module tree reaches an unlinked POM.
let reaches_unlinked = |pom: &str| {
let mut seen = std::collections::HashSet::new();
let mut pending = vec![normalized_pom(pom)];
while let Some(pom) = pending.pop() {
if !seen.insert(pom.clone()) {
continue;
}
let Some(project) = by_pom.get(&pom) else { continue };
for module in &project.modules {
if self.contains(module) {
return true;
}
pending.push(normalized_pom(module));
}
}
false
};
Some(linked.into_iter().filter(|p| !reaches_unlinked(&p.pom)).map(|p| normalized_pom(&p.pom)).collect())
}
pub fn filter(&self, model: &mut MavenModel) {
model.projects.retain(|p| !self.contains(&p.pom));
for project in &mut model.projects {
@@ -1511,6 +1570,52 @@ mod tests {
assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated));
}
#[test]
fn modules_inherit_the_compiler_level_of_the_parent_plugin_configuration() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let parent = |build: &str| {
format!(
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
<modules><module>core</module><module>app</module></modules>
<properties><jdk>17</jdk></properties>{build}</project>"#
)
};
let child = |name: &str, build: &str| {
format!(
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent>
<artifactId>{name}</artifactId>{build}</project>"#
)
};
let compiler = |level: &str| {
format!(
"<plugin><artifactId>maven-compiler-plugin</artifactId><configuration><source>{level}</source></configuration></plugin>"
)
};
let versions = |model: &MavenModel| -> Vec<(String, Option<u32>)> {
model.projects.iter().map(|p| (p.pom.clone(), p.java_version)).collect()
};
let app_build = format!("<build><plugins>{}</plugins></build>", compiler("21"));
write(root, "core/pom.xml", &child("core", ""));
write(root, "app/pom.xml", &child("app", &app_build));
for build in [
format!("<build><pluginManagement><plugins>{}</plugins></pluginManagement></build>", compiler("${jdk}")),
format!("<build><plugins>{}</plugins></build>", compiler("1.8")),
] {
write(root, "pom.xml", &parent(&build));
let expected = if build.contains("1.8") { 8 } else { 17 };
assert_eq!(
versions(&load_with_repo(root, None)),
[
("pom.xml".to_owned(), Some(expected)),
("core/pom.xml".to_owned(), Some(expected)),
("app/pom.xml".to_owned(), Some(21)),
],
"{build}"
);
}
}
#[test]
fn multi_module_parent_properties_and_managed_versions() {
let tmp = tempfile::tempdir().unwrap();
@@ -1706,6 +1811,25 @@ mod tests {
assert!(!is_maven_project(&root.join("c")));
}
#[test]
fn aggregate_pom_in_src_is_a_build() {
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
// with its modules loaded — while `src` stays unsearched otherwise.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"src/pom.xml",
"<project><groupId>g</groupId><artifactId>parent</artifactId><packaging>pom</packaging>\
<modules><module>app</module></modules></project>",
);
write(root, "src/app/pom.xml", "<project><groupId>g</groupId><artifactId>app</artifactId></project>");
let model = load_with_repo(root, None);
let names: Vec<&str> = model.projects.iter().map(|p| p.artifact_id.as_str()).collect();
assert_eq!(names, ["parent", "app"]);
assert!(is_maven_project(root));
}
#[test]
fn property_interpolation() {
let props: HashMap<String, String> =
@@ -1903,4 +2027,51 @@ mod linkage_tests {
UnlinkedProjects::from_model(&original, &MavenSettings::default()).filter(&mut restored);
assert_eq!(restored, original);
}
#[test]
fn language_server_imports_reactor_modules_individually_once_one_is_unlinked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// r ─┬─ x
// ├─ y ─┬─ y/y1
// │ └─ y/y2
// └─ z other/ is a separate root.
for (dir, modules) in [
("r", "<modules><module>x</module><module>y</module><module>z</module></modules>"),
("r/x", ""),
("r/y", "<modules><module>y1</module><module>y2</module></modules>"),
("r/y/y1", ""),
("r/y/y2", ""),
("r/z", ""),
("other", ""),
] {
std::fs::create_dir_all(root.join(dir)).unwrap();
std::fs::write(
root.join(dir).join("pom.xml"),
format!("<project><artifactId>{dir}</artifactId>{modules}</project>"),
)
.unwrap();
}
let model = load_with_repo(root, None);
let cases: [(&[&str], Option<&[&str]>); 5] = [
// Nothing unlinked, or whole roots: the import exclusions do it.
(&[], None),
(&["other/pom.xml"], None),
(&["r/pom.xml"], None),
// A module of a linked reactor: every other linked POM, minus the aggregators above it.
(&["r/y/y1/pom.xml"], Some(&["other/pom.xml", "r/x/pom.xml", "r/y/y2/pom.xml", "r/z/pom.xml"])),
(&["./r/y/pom.xml"], Some(&["other/pom.xml", "r/x/pom.xml", "r/z/pom.xml"])),
];
for (unlinked, expected) in cases {
let settings = MavenSettings {
unlinked_projects: unlinked.iter().map(|s| s.to_string()).collect(),
..Default::default()
};
let mut poms = UnlinkedProjects::from_model(&model, &settings).import_poms(&model);
if let Some(poms) = &mut poms {
poms.sort();
}
assert_eq!(poms, expected.map(|e| e.iter().map(|s| s.to_string()).collect::<Vec<_>>()), "{unlinked:?}");
}
}
}
+198 -8
View File
@@ -4,14 +4,16 @@
//! per project (`maven.json`, `gradle.json` in the cache directory) and merged
//! into the static model by [`crate::load`]; a changed build file marks them stale.
use crate::gradle::{self, GradleModel, GradleTask};
use crate::gradle::{self, GradleModel, GradleTask, ReportedProject, parse_reported_projects};
use crate::maven::{self, MavenDependency, MavenModel, MavenPlugin};
use crate::process::{self, Output};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use specta::Type;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::SystemTime;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -68,8 +70,14 @@ struct GradleCache {
at_ms: f64,
error: Option<String>,
tasks: Vec<(String, GradleTask)>,
/// The projects as Gradle evaluated them; empty in caches from before they were read.
#[serde(default)]
projects: Vec<ReportedProject>,
}
/// Init script printing every project, its source sets and Java version once configured.
const MODEL_INIT_SCRIPT: &str = include_str!("gradle-model.gradle");
const MAVEN_CACHE: &str = "maven.json";
const MAVEN_CACHE_VERSION: u32 = 1;
@@ -88,6 +96,24 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
]
}
const GRADLE_CACHE: &str = "gradle.json";
const MAVEN_PROFILES: &str = "maven-profiles.json";
/// The Maven profiles (`-P` entries, `!id` deactivates) the project was last synced
/// with: what the IDE resolves it with besides `mvn` (the Java language server, Build Project).
pub fn maven_profiles(cache_dir: &Path) -> Vec<String> {
read_cache(cache_dir, MAVEN_PROFILES).unwrap_or_default()
}
/// Records the profiles of a sync for [`maven_profiles`]; `true` when they changed.
pub fn save_maven_profiles(cache_dir: &Path, profiles: &[String]) -> IdeResult<bool> {
if maven_profiles(cache_dir) == profiles {
return Ok(false);
}
std::fs::create_dir_all(cache_dir)?;
let json = serde_json::to_vec(profiles).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(cache_dir.join(MAVEN_PROFILES), json)?;
Ok(true)
}
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
serde_json::from_slice(&std::fs::read(dir.join(name)).ok()?).ok()
@@ -104,6 +130,20 @@ fn write_cache<T: Serialize>(dir: &Path, name: &str, value: &T) -> IdeResult<()>
/// FNV-1a over the paths and contents of `files` (missing files count as empty).
fn hash_files(files: &[PathBuf]) -> String {
hash_contents(files, |f| std::fs::read(f).unwrap_or_default())
}
/// Like IDEA's auto-import CRC: editing a comment or reformatting a build script
/// does not make the last sync stale.
fn hash_gradle_files(files: &[PathBuf]) -> String {
hash_contents(files, |f| {
let text = std::fs::read_to_string(f).unwrap_or_default();
let script = f.extension().is_some_and(|e| e == "gradle" || e == "kts");
if script { gradle::script_fingerprint(&text) } else { text }.into_bytes()
})
}
fn hash_contents(files: &[PathBuf], read: impl Fn(&Path) -> Vec<u8>) -> String {
let mut h: u64 = 0xcbf29ce484222325;
let mut feed = |bytes: &[u8]| {
for b in bytes {
@@ -114,7 +154,7 @@ fn hash_files(files: &[PathBuf]) -> String {
for f in files {
feed(f.to_string_lossy().as_bytes());
feed(&[0]);
feed(&std::fs::read(f).unwrap_or_default());
feed(&read(f));
feed(&[0]);
}
format!("{h:016x}")
@@ -150,9 +190,33 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
}
}
/// The projects of the last successful Gradle sync.
/// Parsed once per change of the cache file (modification time and size): content and source
/// roots are read on hot paths (every references search, Find in Files, repository scans).
pub fn reported_gradle_projects(cache_dir: &Path) -> Arc<Vec<ReportedProject>> {
type Memo = HashMap<PathBuf, (Option<SystemTime>, u64, Arc<Vec<ReportedProject>>)>;
static MEMO: LazyLock<Mutex<Memo>> = LazyLock::new(Default::default);
let path = cache_dir.join(GRADLE_CACHE);
let Ok(meta) = std::fs::metadata(&path) else {
MEMO.lock().unwrap_or_else(|e| e.into_inner()).remove(&path);
return Arc::default();
};
let stamp = (meta.modified().ok(), meta.len());
let mut memo = MEMO.lock().unwrap_or_else(|e| e.into_inner());
if let Some((modified, len, projects)) = memo.get(&path)
&& (*modified, *len) == stamp
{
return projects.clone();
}
let projects = Arc::new(read_cache::<GradleCache>(cache_dir, GRADLE_CACHE).map(|c| c.projects).unwrap_or_default());
memo.insert(path, (stamp.0, stamp.1, projects.clone()));
projects
}
/// Merges the cached task list into `model`.
pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel) {
let Some(cache) = read_cache::<GradleCache>(cache_dir, GRADLE_CACHE) else { return };
gradle::merge_reported_projects(root, model, &cache.projects);
if !cache.tasks.is_empty() {
for p in &mut model.projects {
let tasks: Vec<GradleTask> =
@@ -162,9 +226,11 @@ pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel
}
}
}
// A successful sync from before projects were cached has not seen computed includes.
let without_projects = cache.error.is_none() && cache.projects.is_empty();
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
model.stale = cache.hash != hash_files(&gradle::build_files(root, model));
model.stale = without_projects || cache.hash != hash_gradle_files(&gradle::build_files(root, model));
}
// ---------------------------------------------------------------- processes
@@ -304,8 +370,18 @@ pub fn sync_gradle(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
let mut args: Vec<String> = vec!["--console=plain".into(), "-q".into(), "tasks".into(), "--all".into()];
let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
std::fs::create_dir_all(cache_dir)?;
let init_script = cache_dir.join("openidea-model.gradle");
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
let mut args: Vec<String> = vec![
"--console=plain".into(),
"-q".into(),
"--init-script".into(),
init_script.to_string_lossy().into_owned(),
"tasks".into(),
"--all".into(),
];
if options.offline {
args.push("--offline".into());
}
@@ -318,11 +394,15 @@ pub fn sync_gradle(
cancel,
|_| {},
)?;
let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() };
// Hash the build files of the reported projects, or the model is stale right after the sync.
gradle::merge_reported_projects(root, &mut model, &projects);
let cache = GradleCache {
hash: hash_files(&gradle::build_files(root, &model)),
hash: hash_gradle_files(&gradle::build_files(root, &model)),
at_ms: now_ms(),
error: (!out.success).then(|| error_summary(&out.text)),
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
projects,
};
write_cache(cache_dir, GRADLE_CACHE, &cache)
}
@@ -438,6 +518,93 @@ mod tests {
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
}
#[test]
fn gradle_reported_projects_replace_static_ones() {
let tmp = tempfile::tempdir().unwrap();
let ws = dunce::canonicalize(tmp.path()).unwrap();
let root = ws.join("app");
let billing = ws.join("billing");
std::fs::create_dir_all(root.join("shared")).unwrap();
std::fs::create_dir_all(billing.join("modules/core")).unwrap();
// Computed includes: nothing a static reading can see.
std::fs::write(root.join("settings.gradle"), "rootProject.name = 'Shop'\n['billing'].each { includeFlat it }")
.unwrap();
std::fs::write(billing.join("modules/core/build.gradle"), "apply plugin: 'java'").unwrap();
// Built with serde: a Windows path holds backslashes, which raw JSON text would leave unescaped.
let line = |p: ReportedProject| format!("@openidea-model\t{}\n", serde_json::to_string(&p).unwrap());
let (r, b) = (root.display().to_string(), billing.display().to_string());
let core_src = |p: &str| format!("{b}/modules/core/src/{p}");
let set = |name: &str, test: bool, src: &[&str], res: &[&str]| gradle::ReportedSourceSet {
name: name.into(),
test,
src: src.iter().map(|p| core_src(p)).collect(),
res: res.iter().map(|p| core_src(p)).collect(),
};
// A settings script's own println comes first; task listing lines follow.
let out = format!(
"configuring billing\n{}{}{}@openidea-model\t{{broken\n\nOther tasks\n-----------\nbilling:core:jar\n",
line(ReportedProject { path: ":".into(), dir: r.clone(), ..ReportedProject::default() }),
line(ReportedProject {
path: ":billing".into(),
dir: b.clone(),
java: Some(17),
..ReportedProject::default()
}),
line(ReportedProject {
path: ":billing:core".into(),
dir: format!("{b}/modules/core"),
java: Some(21),
source_sets: vec![
set("main", false, &["main/java"], &["main/resources"]),
set("test", true, &["main/java", "test/java"], &[]),
],
..ReportedProject::default()
}),
);
let projects = parse_reported_projects(&out);
assert_eq!(projects.len(), 3, "the malformed line is skipped");
let src = |p: &str| PathBuf::from(core_src(p));
assert_eq!(
gradle::reported_source_roots(&projects),
[
(src("main/java"), gradle::ReportedRoot::Source),
(src("main/resources"), gradle::ReportedRoot::Resources),
(src("test/java"), gradle::ReportedRoot::Test),
]
);
let mut model = gradle::load(&root).unwrap();
assert_eq!(model.projects.len(), 1);
gradle::merge_reported_projects(&root, &mut model, &projects);
let paths: Vec<_> = model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str())).collect();
assert_eq!(paths, [(":", "Shop"), (":billing", "billing"), (":billing:core", "core")]);
assert_eq!(model.projects[0].dir, "");
assert!(root.join(model.projects[2].build_file.as_ref().unwrap()).is_file());
assert_eq!(
gradle::external_roots(&root, model.projects.iter().map(|p| p.dir.as_str())),
std::slice::from_ref(&billing)
);
assert_eq!(model.java_version, Some(21), "the newest version any module needs");
assert_eq!(
gradle::parse_tasks(&out),
[(":billing:core".into(), GradleTask { name: "jar".into(), group: "other".into(), description: None })]
);
// The cached projects are parsed once per change of the cache file.
let cache_dir = ws.join("cache");
let write = |projects: &[ReportedProject]| {
let cache = GradleCache { projects: projects.to_vec(), ..GradleCache::default() };
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
};
write(&projects);
let first = reported_gradle_projects(&cache_dir);
assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again");
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]);
write(&projects[..1]);
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
assert!(reported_gradle_projects(&cache_dir).is_empty());
}
#[test]
fn gradle_cache_replaces_tasks_per_project() {
let tmp = tempfile::tempdir().unwrap();
@@ -448,16 +615,39 @@ mod tests {
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
let mut model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_files(&gradle::build_files(&root, &model)),
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
at_ms: 1.0,
error: None,
tasks: vec![(":app".into(), GradleTask { name: "hello".into(), group: "demo".into(), description: None })],
projects: [":", ":app"]
.iter()
.zip([root.clone(), root.join("app")])
.map(|(path, dir)| ReportedProject {
path: (*path).into(),
dir: dir.to_string_lossy().into_owned(),
..Default::default()
})
.collect(),
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
assert_eq!(model.projects[1].tasks.len(), 1);
assert!(model.projects[0].tasks.iter().any(|t| t.name == "wrapper"), "root keeps its defaults");
assert!(!model.stale);
let stale_after = |script: &str| {
std::fs::write(root.join("app/build.gradle"), script).unwrap();
let mut m = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut m);
m.stale
};
assert!(!stale_after("// a note\napply plugin: 'java'\n"), "comments and spacing don't count");
assert!(stale_after("apply plugin: 'groovy'"));
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
std::fs::write(root.join("gradle.properties"), "org.gradle.jvmargs=-Xmx2g").unwrap();
let mut m = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut m);
assert!(m.stale, "gradle.properties is watched");
}
#[test]
+119 -4
View File
@@ -13,7 +13,10 @@
//! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers).
//! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool
//! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change
//! how a build reads sources that declare no encoding.
//! how a build reads sources that declare no encoding. The application JVMs a build
//! forks get `file.encoding=UTF-8` for a UTF-8 console instead, through the build's own
//! options: `-Dspring-boot.run.jvmArguments` for Maven `spring-boot:run`, an init script
//! ([`GRADLE_FILE_ENCODING_SCRIPT`]) for Gradle `JavaExec` tasks (`bootRun`, `run`).
//! - Python: `PYTHONIOENCODING`.
use std::collections::BTreeMap;
@@ -75,16 +78,34 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
const SPRING_BOOT_JVM_ARGS: &str = "-Dspring-boot.run.jvmArguments=";
/// Build tools and launcher scripts that run on (and may fork) JVMs.
const JVM_TOOLS: &[&str] =
&["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
const JVM_TOOLS: &[&str] = &["sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
const MAVEN: &[&str] = &["mvn", "mvnw", "mvnd"];
const GRADLE: &[&str] = &["gradle", "gradlew"];
/// Gradle init script giving the `JavaExec` tasks (`bootRun`, `run`, custom ones) a UTF-8
/// default charset before the build configures them, so the build can override it.
/// Gradle's own JVM and the compiler's source encoding are untouched.
pub const GRADLE_FILE_ENCODING_SCRIPT: &str = "\
// Written by OpenIDEA: a UTF-8 default charset for applications run by Gradle, as the Run console decodes UTF-8.
allprojects {
tasks.withType(JavaExec).configureEach {
defaultCharacterEncoding = 'UTF-8'
}
}
";
/// What a launch needs: `-D` options to put first on a `java` command line,
/// environment variables to set, and the notice line its JVMs print because of them.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct EncodingFix {
pub java_args: Vec<String>,
/// The build tool's arguments, rewritten to pass options to the JVM it forks.
pub args: Option<Vec<String>>,
/// Gradle: pass [`GRADLE_FILE_ENCODING_SCRIPT`] with `--init-script`.
pub gradle_init_script: bool,
pub env: Vec<(String, String)>,
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
pub notice: Option<String>,
@@ -116,6 +137,8 @@ fn options(properties: &[&str], encoding: &str) -> Vec<String> {
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Java,
Maven,
Gradle,
JvmTool,
Python,
Other,
@@ -125,6 +148,10 @@ fn kind(program: &str) -> Kind {
let name = program_name(program);
if name == "java" || name == "javaw" {
Kind::Java
} else if MAVEN.contains(&name.as_str()) {
Kind::Maven
} else if GRADLE.contains(&name.as_str()) {
Kind::Gradle
} else if JVM_TOOLS.contains(&name.as_str()) {
Kind::JvmTool
} else if is_python(&name) {
@@ -193,7 +220,17 @@ fn fix_kind(
out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8"));
}
}
(Kind::Java | Kind::JvmTool, _) => {
(Kind::Java | Kind::Maven | Kind::Gradle | Kind::JvmTool, _) => {
if let Some(args) = args
&& is_utf8(encoding)
&& !sets(&tool_options, &[FILE_ENCODING])
{
match kind {
Kind::Maven => out.args = spring_boot_file_encoding(args),
Kind::Gradle => out.gradle_init_script = true,
_ => {}
}
}
if !sets(&tool_options, STD_PROPERTIES) {
let added = options(STD_PROPERTIES, encoding).join(" ");
let value = match tool_options.trim() {
@@ -214,6 +251,26 @@ fn fix_kind(
out
}
fn spring_boot_file_encoding(args: &[String]) -> Option<Vec<String>> {
if !args.iter().any(|a| a == "spring-boot:run" || (a.contains(":spring-boot-maven-plugin:") && a.ends_with(":run")))
{
return None;
}
let option = format!("-D{FILE_ENCODING}=UTF-8");
let mut out = args.to_vec();
match out.iter_mut().rfind(|a| a.starts_with(SPRING_BOOT_JVM_ARGS)) {
Some(own) if sets(own, &[FILE_ENCODING]) => return None,
Some(own) => {
if own.len() > SPRING_BOOT_JVM_ARGS.len() {
own.push(' ');
}
own.push_str(&option);
}
None => out.push(format!("{SPRING_BOOT_JVM_ARGS}{option}")),
}
Some(out)
}
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
@@ -435,6 +492,64 @@ mod tests {
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
}
#[test]
fn spring_boot_run_forks_get_a_utf8_default_charset() {
let boot = |args: &[&str], env: &[(&str, &str)]| run("mvn.cmd", args, env, "UTF-8", true).args;
let utf8 = "-Dspring-boot.run.jvmArguments=-Dfile.encoding=UTF-8";
assert_eq!(boot(&["spring-boot:run"], &[]).unwrap(), ["spring-boot:run", utf8]);
let plugin = "org.springframework.boot:spring-boot-maven-plugin:3.2.0:run";
assert_eq!(boot(&["-pl", "app", plugin], &[]).unwrap(), ["-pl", "app", plugin, utf8]);
// The user's VM options are kept; their own `file.encoding` wins.
assert_eq!(
boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\""], &[]).unwrap(),
["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\" -Dfile.encoding=UTF-8"]
);
assert_eq!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments="], &[]).unwrap()[1], utf8);
assert!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Dfile.encoding=GBK"], &[]).is_none());
assert!(boot(&["spring-boot:run"], &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")]).is_none());
// Other goals and tools, other consoles, and other platforms are left alone.
assert!(boot(&["package"], &[]).is_none());
assert!(run("mvn", &["spring-boot:run"], &[], "GBK", true).args.is_none());
assert!(run("mvn", &["spring-boot:run"], &[], "UTF-8", false).args.is_none());
}
#[test]
fn forked_application_jvms_get_a_utf8_default_charset() {
// Reported: a Spring Boot app run by the build on Chinese Windows (JDK 8–17, GBK default).
for (tool, args) in [
("C:\\p\\gradlew.bat", &["bootRun"][..]),
("gradle", &[":app:run"]),
("mvnw.cmd", &["spring-boot:run"]),
("mvnd", &["org.springframework.boot:spring-boot-maven-plugin:2.7.18:run"]),
] {
let f = run(tool, args, &[], "UTF-8", true);
let gradle = tool.contains("gradle");
assert_eq!(f.gradle_init_script, gradle, "{tool}");
assert_eq!(f.args.is_some(), !gradle, "{tool}");
// The build tool's own JVM keeps its default charset.
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "{tool}");
// The user's choice, a GBK console and other platforms are left alone.
let own = run(tool, args, &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")], "UTF-8", true);
assert!(!own.gradle_init_script && own.args.is_none(), "{tool}");
let gbk = run(tool, args, &[], "GBK", true);
assert!(!gbk.gradle_init_script && gbk.args.is_none(), "{tool}");
assert_eq!(run(tool, args, &[], "UTF-8", false), EncodingFix::default(), "{tool}");
}
// Shell command lines can't take arguments; other JVM tools fork no application.
assert!(!fix_command_line("gradlew bootRun", &BTreeMap::new(), |_| None, "UTF-8", true).gradle_init_script);
let sbt = run("sbt", &["run"], &[], "UTF-8", true);
assert!(!sbt.gradle_init_script && sbt.args.is_none());
}
#[test]
fn gradle_script_respects_the_builds_own_file_encoding() {
let s = GRADLE_FILE_ENCODING_SCRIPT;
assert!(s.contains("tasks.withType(JavaExec).configureEach"), "{s}");
assert!(s.contains("defaultCharacterEncoding = 'UTF-8'"), "{s}");
assert!(!s.contains("doFirst"), "the build must be able to override the default: {s}");
assert!(!s.contains("withType(Test)") && !s.contains("JavaCompile"), "{s}");
}
#[test]
fn shell_command_lines_get_variables_from_their_first_word() {
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
+47
View File
@@ -53,6 +53,31 @@ pub fn kill_tree(child: &mut Child) {
let _ = child.wait();
}
/// [`kill_tree`] for a child known by its process id (a `tokio` child): `SIGKILL` to its
/// process group (spawn it with [`own_group`]) or `taskkill /T /F`. Call it before the child
/// is reaped, so the id still names it. The child itself is left to its owner to kill and reap.
pub fn kill_tree_of(pid: u32) {
#[cfg(unix)]
if let Ok(pid) = libc::pid_t::try_from(pid) {
// SAFETY: plain syscall; see `signal_group`.
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = taskkill.status();
}
#[cfg(not(any(unix, windows)))]
let _ = pid;
}
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
/// git command fails. Windows has no such signal to send from outside the child's console,
@@ -395,6 +420,28 @@ fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn kill_tree_of_reaches_what_the_child_started() {
use std::io::BufRead;
let mut cmd = Command::new("sh");
own_group(&mut cmd).args(["-c", "sleep 30 & echo $!; wait"]).stdout(std::process::Stdio::piped());
let mut child = cmd.spawn().unwrap();
let mut line = String::new();
std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap();
let grandchild: libc::pid_t = line.trim().parse().unwrap();
let alive = |pid: libc::pid_t| unsafe { libc::kill(pid, 0) } == 0;
assert!(alive(grandchild));
kill_tree_of(child.id());
let _ = child.wait();
// Reparented to init once its parent is gone, which reaps it.
let gone = (0..100).any(|_| {
std::thread::sleep(Duration::from_millis(20));
!alive(grandchild)
});
assert!(gone, "the grandchild {grandchild} survived");
}
#[cfg(windows)]
#[test]
fn user_path_reads_the_current_users_path() {
+1 -1
View File
@@ -132,7 +132,7 @@ pub struct DeploymentServer {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentMapping {
/// Project-relative folder; empty = the project root.
/// Project-relative folder, or absolute inside the project; empty = the project root.
pub local: String,
/// Remote folder, relative to the server root (a leading `/` is the root too).
pub deployment: String,
+110 -5
View File
@@ -59,6 +59,31 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
}
/// What the meters in an agent task's header show.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSettings {
/// Context window use.
pub show_context_usage: bool,
/// Time the agent spent on the task.
pub show_work_time: bool,
/// The account's usage-limit windows (Claude's 5-hour and weekly limits).
pub show_usage_limits: bool,
/// What the session has cost so far, as the agent reports it.
pub show_cost: bool,
}
impl Default for AgentSettings {
fn default() -> Self {
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
}
}
/// Absent profiles retain automatic discovery for existing projects.
@@ -192,6 +217,8 @@ impl Default for Settings {
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
plugins: BTreeMap::new(),
}
}
}
@@ -300,7 +327,8 @@ impl Default for NotificationGroupSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AppearanceSettings {
/// `dark` | `light` | `system` | `dark-hc` (high-contrast dark)
/// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
/// "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
pub theme: String,
pub accent: String,
pub ui_font_size: f32,
@@ -309,6 +337,8 @@ pub struct AppearanceSettings {
pub sync_with_os: bool,
pub preferred_light_theme: String,
pub preferred_dark_theme: String,
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
pub file_icon_theme: String,
/// IDE zoom in percent (50–300).
pub zoom: u32,
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
@@ -363,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(),
@@ -543,26 +574,65 @@ impl Default for FileSettings {
pub struct LanguagesSettings {
pub python: PythonSettings,
pub node: NodeSettings,
pub php: PhpSettings,
pub rust: RustSettings,
pub go: GoSettings,
pub typescript: TypeScriptSettings,
pub prettier: PrettierSettings,
pub prettier: ExternalFormatterSettings,
pub oxfmt: ExternalFormatterSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MarkdownSettings {
/// Preview (and notebook Markdown cell) font size in px; unset follows the editor font size.
pub preview_font_size: Option<u32>,
}
impl LanguagesSettings {
pub fn formatter(&self, tool: ExternalFormatter) -> &ExternalFormatterSettings {
match tool {
ExternalFormatter::Prettier => &self.prettier,
ExternalFormatter::Oxfmt => &self.oxfmt,
}
}
}
/// A formatter that runs outside the language servers, with its own settings group.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum ExternalFormatter {
Prettier,
Oxfmt,
}
impl ExternalFormatter {
/// The name of the tool in the tool registry.
pub fn name(self) -> &'static str {
match self {
Self::Prettier => "prettier",
Self::Oxfmt => "oxfmt",
}
}
}
/// An external formatter (Prettier, oxfmt) and the languages it formats.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PrettierSettings {
pub struct ExternalFormatterSettings {
/// Empty path: project node_modules, managed installation, then PATH.
pub path: String,
pub languages: Vec<String>,
/// Prefer Prettier even when a language server offers formatting.
/// Prefer this formatter even when a language server offers formatting.
pub prefer: bool,
pub run_on_save: bool,
}
impl Default for PrettierSettings {
impl Default for ExternalFormatterSettings {
fn default() -> Self {
Self {
path: String::new(),
@@ -606,6 +676,13 @@ impl Default for PythonSettings {
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PhpSettings {
/// `php` CLI executable; empty = the one on `PATH`.
pub interpreter: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RubySettings {
@@ -717,6 +794,19 @@ pub struct GradleSettings {
pub home: String,
/// Empty = Gradle's default user home.
pub user_home: String,
/// IDEA's "Build and run using": who builds and launches an Application main.
pub run_using: GradleRunUsing,
}
/// IDEA's "Build and run using" for Gradle builds; IDEA defaults to Gradle.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum GradleRunUsing {
/// A `JavaExec` task on the source set's runtime classpath (`gradleRun.gradle`).
#[default]
Gradle,
/// The Java language server's classpath, launched by OpenIDEA.
Ide,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
@@ -858,6 +948,20 @@ mod tests {
}
}
#[test]
fn agent_meters_default_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
assert!(old.agents.show_cost);
assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits);
assert!(!Settings::default().agents.show_cost);
let mut settings = Settings::default();
settings.agents.show_usage_limits = false;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["agents"]["showUsageLimits"], false);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.agents, settings.agents);
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
@@ -943,6 +1047,7 @@ mod gradle_settings_tests {
distribution,
home: "/gradle".into(),
user_home: "/cache/gradle".into(),
run_using: GradleRunUsing::Ide,
};
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
+57
View File
@@ -44,6 +44,39 @@ pub fn cancel(id: TaskId) -> bool {
}
}
/// A progress title a tool reported, made readable: a leading absolute executable path
/// (rust-analyzer reports `/…/bin/cargo check`) shows as its program name (`cargo check`).
pub fn display_title(title: &str) -> String {
let is_sep = |c: char| c == '/' || c == '\\';
let b = title.as_bytes();
let absolute = title.starts_with(is_sep)
|| (b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && is_sep(b[2] as char));
if !absolute {
return title.to_owned();
}
// The path runs on through spaces (`Application Support/…`) while the next word still
// continues it; the first word without a separator, an option or a new path ends it.
let mut end = title.find(' ').unwrap_or(title.len());
while end < title.len() {
let rest = &title[end + 1..];
let word = rest.split(' ').next().unwrap_or_default();
if word.is_empty() || !word.contains(is_sep) || word.starts_with(is_sep) || word.starts_with('-') {
break;
}
end += 1 + word.len();
}
let (path, rest) = title.split_at(end);
let name = path.rsplit(is_sep).next().unwrap_or(path);
let name = [".exe", ".cmd", ".bat"]
.iter()
.find_map(|ext| {
let cut = name.len().checked_sub(ext.len())?;
(name.is_char_boundary(cut) && name[cut..].eq_ignore_ascii_case(ext)).then(|| &name[..cut])
})
.unwrap_or(name);
if name.is_empty() { title.to_owned() } else { format!("{name}{rest}") }
}
#[cfg(test)]
mod tests {
use super::*;
@@ -71,4 +104,28 @@ mod tests {
assert!(!cancel(other), "an ended task is not cancelled");
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[test]
fn display_title_shows_a_leading_executable_path_as_its_name() {
for (title, shown) in [
("/Users/thomas/Library/Application Support/mbx/bin/cargo check", "cargo check"),
("/home/me/.cargo/bin/cargo clippy (#2)", "cargo clippy (#2)"),
(
"/opt/my tools/bin/cargo check --manifest-path /x/Cargo.toml",
"cargo check --manifest-path /x/Cargo.toml",
),
(r"C:\Users\me\.cargo\bin\cargo.exe check", "cargo check"),
(r"C:\Program Files\Rust\bin\cargo.EXE check", "cargo check"),
(r"\\server\share\tools\x.cmd build", "x build"),
("/usr/bin/make", "make"),
("cargo check", "cargo check"),
("Indexing", "Indexing"),
("Building compile-time-deps", "Building compile-time-deps"),
("Loading /src/app (2/3)", "Loading /src/app (2/3)"),
("/", "/"),
("", ""),
] {
assert_eq!(display_title(title), shown, "{title:?}");
}
}
}
+19 -7
View File
@@ -8,7 +8,7 @@
use ide_core::process::executable_names;
use ide_core::{IdeError, IdeResult};
use ide_language::{DebuggerConfig, LanguageRegistry};
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -44,6 +44,8 @@ pub struct AdapterSpec {
/// `host:port` of an adapter inside another program (the Godot editor): connected to
/// when it accepts, else `command` is started.
pub connect: Option<String>,
/// A plugin language's adapter: start exactly this approved program.
pub pinned: Option<ProgramPin>,
}
/// Built-in adapters used when no language definition names `adapter`.
@@ -55,6 +57,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
transport: transport.to_owned(),
connect: None,
install_hint: Some(hint.to_owned()),
pinned: None,
};
Some(match adapter {
"codelldb" => cfg(
@@ -133,6 +136,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
env: Vec::new(),
server_host: None,
connect: config.connect.clone(),
pinned: config.pinned.clone(),
}
}
@@ -426,12 +430,20 @@ impl AdapterLauncher for ProcessLauncher {
if is_plugin_bundle(&spec.command) {
return Err(not_standalone(&spec));
}
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
.await
.ok()
.flatten()
.ok_or_else(|| missing(&spec))?;
let binary = match spec.pinned.clone() {
Some(pin) => tokio::task::spawn_blocking(move || pin.verify().map(Path::to_path_buf))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?,
None => {
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
.await
.ok()
.flatten()
.ok_or_else(|| missing(&spec))?
}
};
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
+1 -1
View File
@@ -405,7 +405,7 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
let mut progress = s.progress.lock();
let id =
if done { progress.remove(&pid) } else { Some(*progress.entry(pid).or_insert_with(TaskId::next)) };
(id, str_("title").unwrap_or_else(|| s.name.clone()))
(id, str_("title").map(|t| ide_core::task::display_title(&t)).unwrap_or_else(|| s.name.clone()))
};
let Some(id) = id else { return };
s.owner.sink.emit(IdeEvent::Task {
+56
View File
@@ -915,6 +915,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
transport: "tcp".into(),
connect: Some(format!("127.0.0.1:{port}")),
install_hint: Some("Open the project in the Godot editor".into()),
pinned: None,
}),
};
Arc::new(LanguageRegistry::from_configs(vec![lang]))
@@ -1147,3 +1148,58 @@ async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
h.manager.stop(id).await.unwrap();
}
}
/// A built-in language definition (GDScript) shipped by a plugin
/// instead: its debugger starts the program approved for the plugin's
/// `process:godot` grant, and refuses once that file changed.
#[cfg(unix)]
#[tokio::test]
async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
use ide_language::ProgramPin;
use ide_language::plugin_point::{PluginLanguages, parse_definition};
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("openidea-plugin-dap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Stands in for `godot --headless --editor …`.
let program = dir.join("godot");
std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
let debugger = gdscript.debugger.as_mut().unwrap();
debugger.args.clear();
debugger.connect = Some(format!("127.0.0.1:{port}"));
// No built-in GDScript: it comes from the plugin.
let registry = Arc::new(LanguageRegistry::from_configs(Vec::new()));
let store = PluginLanguages::new();
registry.attach_plugins(store.clone());
let pin = ProgramPin::approve(&program).unwrap();
store.set_process_policy(Arc::new(move |plugin, cap| {
(plugin == "acme.godot" && cap.to_string() == "process:godot").then(|| pin.clone())
}));
let _language = store.add("acme.godot".into(), gdscript);
let log: Log = Arc::default();
let server_log = log.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
let (stream, _) = listener.accept().await.unwrap();
fake_adapter(stream, server_log, Script::default()).await;
});
let (owner, _events) = recording_owner("main");
let manager = DebugManager::new(registry.clone());
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
assert!(log.lock().iter().any(|m| m["command"] == "launch"));
manager.stop(id).await.unwrap();
// Replaced after approval: refused before anything starts.
std::fs::write(&program, "#!/bin/sh\nexec sleep 31\n").unwrap();
let (owner, _events) = recording_owner("main");
let err = manager.start(godot_launch(&dir), owner).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
let _ = std::fs::remove_dir_all(&dir);
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "ide-extension"
description = "Plugin manifest, extension points, contribution registry and capability model (no Wasm runtime)"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ide-core.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
specta-typescript.workspace = true
toml.workspace = true
tracing.workspace = true
parking_lot.workspace = true
semver = "1"
blake3.workspace = true
zip.workspace = true
base64.workspace = true
ed25519-dalek = "3"
[dev-dependencies]
tempfile.workspace = true
+277
View File
@@ -0,0 +1,277 @@
//! What a plugin may do beyond pure computation, and how risky each grant is.
//!
//! Capabilities are written as strings in the manifest (`project.read`,
//! `net:crates.io`, `process:cargo`) and stored the same way in the grant
//! ledger. The 0.1 permission names (`project-read`, …) parse as their 0.2
//! equivalents.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use specta::Type;
use std::fmt;
use std::str::FromStr;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Capability {
/// Read files inside the project root; the project root itself.
ProjectRead,
/// The focused editor's file, selection and text.
EditorRead,
/// Edit the focused editor (versioned edits).
EditorWrite,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
Storage,
/// The plugin's own secrets (kept in the client's OS keychain).
Secrets,
/// Workbench events (file opened, saved, focus…), not keystrokes.
Events,
/// Keystrokes and editor input events.
InputEvents,
/// The system clipboard, from the plugin's web views.
Clipboard,
/// HTTP(S) to one host (`*.example.com` matches subdomains).
Net(String),
/// Run one external program. Full code execution: never fine-grained.
Process(String),
/// Read files outside the project under one path.
FsRead(String),
}
/// How a grant is presented (plan 4.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum Risk {
/// Listed on the install page, one confirmation.
Low,
/// Listed separately with its purpose.
Medium,
/// Separate dialog: "equivalent to running arbitrary code on this machine".
High,
}
impl Capability {
pub fn risk(&self) -> Risk {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
Risk::Medium
}
Self::Process(_) | Self::FsRead(_) => Risk::High,
}
}
/// Low-risk capabilities that the one-time migration of the 0.1 "declared
/// means granted" behaviour may grant automatically (plan 4.6).
pub fn auto_migrates(&self) -> bool {
self.risk() == Risk::Low
}
/// Short user-facing description for install and grant pages.
pub fn describe(&self) -> String {
match self {
Self::ProjectRead => "Read files in the open project".into(),
Self::EditorRead => "Read the focused editor's file and selection".into(),
Self::EditorWrite => "Change text in the editor".into(),
Self::VcsRead => "Read version control state".into(),
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
Self::Storage => "Store its own data".into(),
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Clipboard => "Read and write the clipboard from its web views".into(),
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
}
}
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
pub fn covers(&self, wanted: &Capability) -> bool {
match (self, wanted) {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::FsRead(base), Self::FsRead(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
_ => self == wanted,
}
}
}
/// `example.com` matches exactly; `*.example.com` matches any subdomain (not the apex).
pub fn host_matches(pattern: &str, host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
let pattern = pattern.to_ascii_lowercase();
match pattern.strip_prefix("*.") {
Some(suffix) => host.len() > suffix.len() + 1 && host.ends_with(&format!(".{suffix}")),
None => host == pattern,
}
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ProjectRead => f.write_str("project.read"),
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
Self::Secrets => f.write_str("secrets"),
Self::Events => f.write_str("events"),
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
}
}
}
impl FromStr for Capability {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
let s = s.trim();
if let Some((kind, arg)) = s.split_once(':') {
let arg = arg.trim();
if arg.is_empty() {
return Err(format!("capability {s:?} needs a value after ':'"));
}
return match kind {
"net" => {
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
&& host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
}
"process" => {
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
return Err(format!("{s:?}: use a program name or an absolute path"));
}
Ok(Self::Process(arg.to_owned()))
}
"fs.read" => {
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
}
Ok(Self::FsRead(arg.to_owned()))
}
_ => Err(format!("unknown capability {s:?}")),
};
}
Ok(match s {
"project.read" | "project-read" => Self::ProjectRead,
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
"secrets" => Self::Secrets,
"events" => Self::Events,
"events.input" => Self::InputEvents,
"clipboard" => Self::Clipboard,
_ => return Err(format!("unknown capability {s:?}")),
})
}
}
impl Serialize for Capability {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Capability {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
String::deserialize(d)?.parse().map_err(serde::de::Error::custom)
}
}
/// A capability as the UI shows it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CapabilityInfo {
/// `project.read`, `net:crates.io`, …
pub id: String,
pub risk: Risk,
pub description: String,
/// Declared under `optional`: the plugin runs without it.
pub optional: bool,
/// Currently granted.
pub granted: bool,
}
impl CapabilityInfo {
pub fn new(cap: &Capability, optional: bool, granted: bool) -> Self {
Self { id: cap.to_string(), risk: cap.risk(), description: cap.describe(), optional, granted }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_prints() {
for s in [
"project.read",
"editor.write",
"vcs.write",
"storage",
"secrets",
"events",
"events.input",
"net:crates.io",
"net:*.github.com",
"process:cargo",
] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
// Absolute paths are the platform's.
let (git, etc) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows")
} else {
("process:/usr/bin/git", "fs.read:/etc")
};
for s in [git, etc] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
assert!("net:".parse::<Capability>().is_err());
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
}
#[test]
fn risk_levels() {
assert_eq!(Capability::ProjectRead.risk(), Risk::Low);
assert_eq!(Capability::Net("x".into()).risk(), Risk::Medium);
assert_eq!(Capability::InputEvents.risk(), Risk::Medium);
assert_eq!("clipboard".parse::<Capability>().unwrap(), Capability::Clipboard);
assert_eq!(Capability::Clipboard.risk(), Risk::Medium);
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
}
#[test]
fn host_patterns() {
assert!(host_matches("crates.io", "crates.io"));
assert!(host_matches("crates.io", "CRATES.IO."));
assert!(!host_matches("crates.io", "static.crates.io"));
assert!(host_matches("*.github.com", "api.github.com"));
assert!(!host_matches("*.github.com", "github.com"));
assert!(!host_matches("*.github.com", "evilgithub.com"));
let any_gh: Capability = "net:*.github.com".parse().unwrap();
assert!(any_gh.covers(&"net:api.github.com".parse().unwrap()));
assert!(Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etc/hosts".into())));
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
}
}
+104
View File
@@ -0,0 +1,104 @@
//! The contribution index: validated UI
//! declarations of installed packages, kept on disk so start-up does not
//! validate them again (sanitising thousands of icon SVGs, say).
//!
//! Only packages with a [`Package::digest`] are indexed: installed copies,
//! which the host never modifies after install. An entry is keyed by that
//! digest, the extension point, the declaration and a salt naming the
//! validation logic (the IDE version), so a new package version, a changed
//! declaration or an IDE upgrade simply misses. A miss or an unreadable entry
//! only costs the validation.
use crate::point::Package;
use crate::ui::UiDecl;
use std::collections::BTreeSet;
use std::path::PathBuf;
/// Bumped when [`UiDecl`]'s serialized shape changes.
const FORMAT: u32 = 1;
pub struct ContributionIndex {
dir: PathBuf,
salt: String,
}
impl ContributionIndex {
/// `salt` names the validators' version (the IDE version).
pub fn new(dir: PathBuf, salt: &str) -> Self {
Self { dir, salt: format!("{FORMAT}:{salt}") }
}
/// Entries start with a prefix of the package digest, for pruning.
fn file(&self, digest: &str, point: &str, decl: &toml::Value) -> PathBuf {
let mut h = blake3::Hasher::new();
for part in [self.salt.as_str(), digest, point, &decl.to_string()] {
h.update(part.as_bytes());
h.update(&[0]);
}
let key = h.finalize().to_hex();
self.dir.join(format!("{}-{}.json", prefix(digest), &key[..32]))
}
pub fn get(&self, pkg: &Package, point: &str, decl: &toml::Value) -> Option<UiDecl> {
let digest = pkg.digest.as_deref()?;
let bytes = std::fs::read(self.file(digest, point, decl)).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn put(&self, pkg: &Package, point: &str, decl: &toml::Value, ui: &UiDecl) {
let Some(digest) = pkg.digest.as_deref() else { return };
let Ok(bytes) = serde_json::to_vec(ui) else { return };
let file = self.file(digest, point, decl);
let _ = std::fs::create_dir_all(&self.dir);
let tmp = file.with_extension(format!("tmp{}", std::process::id()));
if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).is_err() {
let _ = std::fs::remove_file(&tmp);
}
}
/// Removes entries of packages other than `live` (digests).
pub fn prune<'a>(&self, live: impl IntoIterator<Item = &'a str>) {
let keep: BTreeSet<String> = live.into_iter().map(|d| prefix(d).to_owned()).collect();
let Ok(entries) = std::fs::read_dir(&self.dir) else { return };
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.split_once('-').is_some_and(|(p, _)| keep.contains(p)) {
let _ = std::fs::remove_file(e.path());
}
}
}
}
fn prefix(digest: &str) -> &str {
let d = digest.strip_prefix("blake3:").unwrap_or(digest);
let end = d.char_indices().nth(16).map_or(d.len(), |(i, _)| i);
&d[..end]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
#[test]
fn hits_only_the_same_package_point_declaration_and_salt() {
let tmp = tempfile::tempdir().unwrap();
let index = ContributionIndex::new(tmp.path().join("idx"), "1.0");
let manifest =
Manifest::parse("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n").unwrap();
let pkg = Package::new(manifest, tmp.path().to_path_buf()).with_digest("blake3:00aa11bb22cc33dd44ee");
let decl: toml::Value = toml::from_str("id = \"x\"").unwrap();
let ui = UiDecl::new("x", "X", serde_json::json!({ "id": "x" }));
assert!(index.get(&pkg, "pt", &decl).is_none());
index.put(&pkg, "pt", &decl, &ui);
assert_eq!(index.get(&pkg, "pt", &decl).unwrap().label, "X");
assert!(index.get(&pkg, "other", &decl).is_none());
assert!(index.get(&pkg, "pt", &toml::from_str("id = \"y\"").unwrap()).is_none());
assert!(ContributionIndex::new(tmp.path().join("idx"), "1.1").get(&pkg, "pt", &decl).is_none());
let unhashed = Package { digest: None, ..pkg.clone() };
assert!(index.get(&unhashed, "pt", &decl).is_none(), "not an installed copy");
index.prune(["blake3:ffff"]);
assert!(index.get(&pkg, "pt", &decl).is_none());
}
}
+483
View File
@@ -0,0 +1,483 @@
//! The grant ledger (plan 4.6): which capabilities the user granted to which
//! plugin, saved as `grants.json` next to the installed plugins.
//!
//! - Installed plugins are keyed by id and remember the version and package
//! hash they were confirmed for. An update that needs no new capability is
//! accepted silently; one that does must be confirmed again.
//! - Linked development plugins and project plugins (`.openidea/plugins/*`)
//! are keyed by their directory, not by hash, so rebuilding does not ask
//! again; new capabilities still do.
//! - `process:<program>` grants record the program's absolute path and file
//! hash at grant time. Calls use that path, never a `PATH` lookup, and fail
//! when the file changed (re-confirmation needed).
use crate::capability::{Capability, Risk};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub const LEDGER_FILE: &str = "grants.json";
const FORMAT: u32 = 1;
/// Whose grant an entry is.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
pub enum Subject {
/// Installed under the plugins directory, by plugin id.
Installed(String),
/// A linked development plugin or a project plugin, by package directory.
Path(PathBuf),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BinaryGrant {
pub path: PathBuf,
/// blake3 of the file when granted.
pub hash: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Grant {
pub plugin_id: String,
/// The version and package hash the user confirmed (installed plugins).
#[serde(default)]
pub version: String,
#[serde(default)]
pub package_hash: String,
/// The user confirmed the plugin (the install page or the project-plugin prompt).
#[serde(default)]
pub confirmed: bool,
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
/// `process:<program>` → the program confirmed for it.
#[serde(default)]
pub binaries: BTreeMap<String, BinaryGrant>,
/// Migrated from 0.1: capabilities not granted yet are asked for on first
/// use instead of blocking the plugin from loading.
#[serde(default)]
pub ask_on_use: bool,
}
impl Grant {
pub fn has(&self, cap: &Capability) -> bool {
self.granted.iter().filter_map(|s| s.parse::<Capability>().ok()).any(|g| g.covers(cap))
}
}
/// What a plugin (in its current form) needs, to compare with its grant.
#[derive(Clone, Debug)]
pub struct Request<'a> {
pub subject: Subject,
pub plugin_id: &'a str,
pub version: &'a str,
pub package_hash: &'a str,
pub required: Vec<Capability>,
pub optional: Vec<Capability>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Decision {
/// Load with these capabilities. `pending` required ones are asked for when
/// the plugin is first used (plugins migrated from 0.1 only).
Allowed { granted: Vec<Capability>, pending: Vec<Capability> },
/// Ask the user first. `new` lists what was never granted (all of it on first use).
NeedsConfirmation { new: Vec<Capability>, first_time: bool },
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct File {
#[serde(default)]
format: u32,
/// The one-time migration from 0.1 ("declared means granted") ran.
#[serde(default)]
migrated: bool,
#[serde(default)]
grants: Vec<(Subject, Grant)>,
}
/// The ledger, loaded into memory; [`Ledger::save`] writes it atomically.
#[derive(Debug, Default)]
pub struct Ledger {
path: Option<PathBuf>,
migrated: bool,
grants: BTreeMap<Subject, Grant>,
}
impl Ledger {
/// Loads `<dir>/grants.json`; a missing or unreadable file gives an empty
/// ledger with `needs_migration()` true.
pub fn load(dir: &Path) -> Self {
let path = dir.join(LEDGER_FILE);
let file: Option<File> = std::fs::read_to_string(&path).ok().and_then(|s| match serde_json::from_str(&s) {
Ok(f) => Some(f),
Err(e) => {
tracing::warn!("{}: {e}; starting with an empty grant ledger", path.display());
None
}
});
let existed = file.is_some();
let file = file.unwrap_or_default();
Self { path: Some(path), migrated: file.migrated || existed, grants: file.grants.into_iter().collect() }
}
pub fn in_memory() -> Self {
Self { path: None, migrated: true, grants: BTreeMap::new() }
}
pub fn save(&self) -> std::io::Result<()> {
let Some(path) = &self.path else { return Ok(()) };
let file = File {
format: FORMAT,
migrated: self.migrated,
grants: self.grants.iter().map(|(s, g)| (s.clone(), g.clone())).collect(),
};
let json = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
}
pub fn get(&self, subject: &Subject) -> Option<&Grant> {
self.grants.get(subject)
}
pub fn remove(&mut self, subject: &Subject) -> Option<Grant> {
self.grants.remove(subject)
}
/// Puts back an earlier state of `subject`'s grant (`None`: none), e.g.
/// after rolling back an update.
pub fn restore(&mut self, subject: &Subject, grant: Option<Grant>) {
match grant {
Some(g) => {
self.grants.insert(subject.clone(), g);
}
None => {
self.grants.remove(subject);
}
}
}
/// The one-time migration of 0.1's "declared means granted" has not run yet.
pub fn needs_migration(&self) -> bool {
!self.migrated
}
/// Migrates plugins installed before the ledger existed: low-risk
/// capabilities are granted, the rest are asked on first use.
pub fn migrate(&mut self, installed: &[Request<'_>]) {
for req in installed {
let granted: BTreeSet<String> =
req.required.iter().chain(&req.optional).filter(|c| c.auto_migrates()).map(|c| c.to_string()).collect();
self.grants.entry(req.subject.clone()).or_insert_with(|| Grant {
plugin_id: req.plugin_id.to_owned(),
version: req.version.to_owned(),
package_hash: req.package_hash.to_owned(),
confirmed: true,
granted,
ask_on_use: true,
..Grant::default()
});
}
self.migrated = true;
}
/// Compares what the plugin needs with what was granted.
pub fn decide(&mut self, req: &Request<'_>) -> Decision {
let Some(grant) = self.grants.get_mut(&req.subject) else {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
};
if !grant.confirmed {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
}
let new: Vec<Capability> = req.required.iter().filter(|c| !grant.has(c)).cloned().collect();
if !new.is_empty() && !grant.ask_on_use {
return Decision::NeedsConfirmation { new, first_time: false };
}
// Same or fewer needs: accept an update silently (plan 4.6).
if grant.version != req.version || grant.package_hash != req.package_hash {
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
}
let granted = req.required.iter().chain(&req.optional).filter(|c| grant.has(c)).cloned().collect();
Decision::Allowed { granted, pending: new }
}
/// Records the user's confirmation: `required` plus the accepted optional capabilities.
pub fn confirm(
&mut self,
req: &Request<'_>,
accepted_optional: &[Capability],
binaries: BTreeMap<String, BinaryGrant>,
) {
let grant = self.grants.entry(req.subject.clone()).or_default();
grant.plugin_id = req.plugin_id.to_owned();
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
grant.confirmed = true;
grant.ask_on_use = false;
for cap in req.required.iter().chain(accepted_optional) {
grant.granted.insert(cap.to_string());
}
for cap in &req.optional {
let s = cap.to_string();
if accepted_optional.contains(cap) {
grant.declined.remove(&s);
} else if !grant.granted.contains(&s) {
grant.declined.insert(s);
}
}
grant.binaries.extend(binaries);
}
/// Grants or revokes one capability (Settings › Plugins › permissions).
pub fn set(&mut self, subject: &Subject, cap: &Capability, granted: bool) -> bool {
let Some(grant) = self.grants.get_mut(subject) else { return false };
let s = cap.to_string();
if granted {
grant.declined.remove(&s);
grant.granted.insert(s)
} else {
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
}
grant.granted.remove(&s)
}
}
/// Records the program approved for a `process:` grant.
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
if let Some(grant) = self.grants.get_mut(subject) {
grant.binaries.insert(cap.to_string(), binary);
}
}
/// Checks a `process:` grant at call time: the program confirmed for it,
/// unchanged since. Errors name what to do.
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
let Capability::Process(name) = cap else { return Err(format!("{cap} is not a process capability")) };
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
let key = cap.to_string();
if !grant.granted.contains(&key) {
return Err(format!("{cap} was not granted"));
}
let bin = grant.binaries.get(&key).ok_or_else(|| format!("{cap}: no program was confirmed for {name}"))?;
match file_hash(&bin.path) {
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
Ok(_) => Err(format!(
"{} changed since it was approved for {cap}; confirm the plugin's permissions again",
bin.path.display()
)),
Err(e) => Err(format!("{}: {e}; confirm the plugin's permissions again", bin.path.display())),
}
}
/// The program approved for a granted `process:` capability, without
/// checking the file: whoever starts it verifies the hash then
/// ([`check_binary`](Self::check_binary) or the pin's own check).
pub fn pinned_binary(&self, subject: &Subject, cap: &Capability) -> Option<BinaryGrant> {
let grant = self.grants.get(subject)?;
let key = cap.to_string();
if !grant.granted.contains(&key) {
return None;
}
grant.binaries.get(&key).cloned()
}
pub fn subjects(&self) -> impl Iterator<Item = (&Subject, &Grant)> {
self.grants.iter()
}
}
/// What a confirmation page shows: every capability with its risk, high risk first.
pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
let mut groups: BTreeMap<std::cmp::Reverse<Risk>, Vec<Capability>> = BTreeMap::new();
for c in caps {
groups.entry(std::cmp::Reverse(c.risk())).or_default().push(c.clone());
}
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
}
/// Resolves a `process:` program to an absolute path once, at grant time:
/// absolute names are taken as is, others are looked up in `path_var`.
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
let candidate = Path::new(name);
if candidate.is_absolute() {
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
}
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
} else {
vec![String::new()]
};
for dir in std::env::split_paths(path_var.unwrap_or_default()) {
if !dir.is_absolute() {
continue; // `.` or relative entries could point into the project
}
for ext in &exts {
let p = dir.join(format!("{name}{ext}"));
if is_executable(&p) {
return Ok(dunce_canonical(&p));
}
}
}
Err(format!("{name} was not found on PATH"))
}
fn dunce_canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
let Ok(meta) = std::fs::metadata(p) else { return false };
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
}
/// blake3 of a file, hex.
pub fn file_hash(path: &Path) -> std::io::Result<String> {
let mut hasher = blake3::Hasher::new();
hasher.update_reader(std::fs::File::open(path)?)?;
Ok(hasher.finalize().to_hex().to_string())
}
/// blake3 over a package's files (sorted relative paths and contents).
pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
let mut sorted: Vec<&PathBuf> = files.iter().collect();
sorted.sort();
sorted.dedup();
let mut hasher = blake3::Hasher::new();
for rel in sorted {
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(&[0]);
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
hasher.update(&[0]);
}
Ok(hasher.finalize().to_hex().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn caps(list: &[&str]) -> Vec<Capability> {
list.iter().map(|s| s.parse().unwrap()).collect()
}
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
Request {
subject: Subject::Installed("p".into()),
plugin_id: "p",
version,
package_hash: hash,
required: caps(required),
optional: caps(optional),
}
}
#[test]
fn confirmation_then_silent_update_then_reconfirm_on_new_capability() {
let mut l = Ledger::in_memory();
let r1 = req("1.0.0", "h1", &["project.read"], &["net:crates.io"]);
assert_eq!(l.decide(&r1), Decision::NeedsConfirmation { new: caps(&["project.read"]), first_time: true });
l.confirm(&r1, &[], BTreeMap::new());
assert_eq!(
l.decide(&r1),
Decision::Allowed { granted: caps(&["project.read"]), pending: vec![] },
"declined optional"
);
assert!(l.get(&r1.subject).unwrap().declined.contains("net:crates.io"));
// Same needs, new version: silent.
let r2 = req("1.1.0", "h2", &["project.read"], &["net:crates.io"]);
assert!(matches!(l.decide(&r2), Decision::Allowed { .. }));
assert_eq!(l.get(&r2.subject).unwrap().version, "1.1.0");
// A new required capability: ask again, only for it.
let r3 = req("1.2.0", "h3", &["project.read", "editor.write"], &[]);
assert_eq!(l.decide(&r3), Decision::NeedsConfirmation { new: caps(&["editor.write"]), first_time: false });
l.confirm(&r3, &[], BTreeMap::new());
assert!(matches!(l.decide(&r3), Decision::Allowed { .. }));
// Revoking a required capability blocks loading again.
assert!(l.set(&r3.subject, &Capability::EditorWrite, false));
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
}
#[test]
fn migration_grants_low_risk_only() {
let tmp = tempfile::tempdir().unwrap();
let mut l = Ledger::load(tmp.path());
assert!(l.needs_migration());
let r = req("0.1.0", "h", &["project.read", "editor.write"], &[]);
l.migrate(std::slice::from_ref(&r));
// Loads with the low-risk part; the rest is asked for on first use.
assert_eq!(
l.decide(&r),
Decision::Allowed { granted: caps(&["project.read"]), pending: caps(&["editor.write"]) }
);
l.confirm(&r, &[], BTreeMap::new());
assert_eq!(
l.decide(&r),
Decision::Allowed { granted: caps(&["project.read", "editor.write"]), pending: vec![] }
);
l.save().unwrap();
let again = Ledger::load(tmp.path());
assert!(!again.needs_migration());
assert!(again.get(&r.subject).unwrap().has(&Capability::ProjectRead));
}
#[cfg(unix)]
#[test]
fn binaries_are_pinned_by_path_and_hash() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir(&bin_dir).unwrap();
let tool = bin_dir.join("tool");
std::fs::write(&tool, "#!/bin/sh\necho v1\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let path_var = std::env::join_paths([PathBuf::from("relative"), bin_dir.clone()]).unwrap();
let resolved = resolve_program("tool", Some(&path_var)).unwrap();
assert!(resolved.is_absolute());
assert!(resolve_program("missing", Some(&path_var)).is_err());
let mut l = Ledger::in_memory();
let r = req("1.0.0", "h", &["process:tool"], &[]);
let cap: Capability = "process:tool".parse().unwrap();
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
std::fs::write(&tool, "#!/bin/sh\necho evil\n").unwrap();
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
#[test]
fn hashes_and_grouping() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("a"), "1").unwrap();
std::fs::write(tmp.path().join("b"), "2").unwrap();
let h1 = package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap();
assert_eq!(h1, package_hash(tmp.path(), &["b".into(), "a".into()]).unwrap());
std::fs::write(tmp.path().join("b"), "3").unwrap();
assert_ne!(h1, package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap());
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
}
}
+36
View File
@@ -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;
+501
View File
@@ -0,0 +1,501 @@
//! `plugin.toml`, the package manifest (plan 4.4).
//!
//! ```toml
//! id = "acme.rust-tools" # optional publisher namespace before the first '.'
//! name = "Rust Tools"
//! version = "1.2.0"
//! engines.openidea = "^1.0"
//! wasm = "plugin.wasm"
//! activation = ["on-language:rust", "on-command:check"]
//!
//! [capabilities]
//! required = ["project.read", "editor.write"]
//! optional = ["net:crates.io", "process:cargo"]
//!
//! [[contributes.commands]]
//! id = "check"
//! title = "Check"
//! ```
//!
//! `contributes` is kept as raw TOML: each `contributes.<point>` is parsed and
//! validated by the extension point registered under that id
//! ([`crate::ExtensionRegistry`]), so new contribution types need no change here.
//!
//! Manifests in the 0.1 format (`openidea-api = "0.1"`, `permissions = [...]`)
//! still parse: permissions become required capabilities and the plugin is
//! activated at start-up, as before.
use crate::capability::Capability;
use serde::Deserialize;
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.0.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
#[derive(Clone, Debug, PartialEq)]
pub struct Manifest {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub license: Option<String>,
/// Release notes of this version (Settings › Plugins › What's New).
pub change_notes: Option<String>,
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
pub engine: String,
/// Written in the 0.1 format.
pub legacy: bool,
/// Component file relative to the package directory.
pub wasm: Option<PathBuf>,
pub activation: Vec<ActivationEvent>,
pub capabilities: Capabilities,
/// `contributes.<point>` → its declarations, validated by the extension points.
pub contributes: toml::Table,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Capabilities {
pub required: Vec<Capability>,
pub optional: Vec<Capability>,
}
impl Capabilities {
pub fn all(&self) -> impl Iterator<Item = &Capability> {
self.required.iter().chain(&self.optional)
}
pub fn is_optional(&self, cap: &Capability) -> bool {
self.optional.contains(cap) && !self.required.contains(cap)
}
}
/// When a plugin's code is instantiated (plan 4.4). Contributions are live
/// without it; code only runs once one of these fires.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ActivationEvent {
/// One of the plugin's commands is run (`on-command:<id>`; `*` = any).
Command(String),
/// A file of this language is opened.
Language(String),
/// A file matching this glob (project-relative) is opened.
File(String),
/// One of the plugin's views is shown.
View(String),
/// Periodically: `on-schedule:<seconds>`. Does not keep the plugin alive.
Schedule(u32),
/// A workbench event of this kind (`on-event:file-saved`): the plugin is
/// subscribed from the start, and activated to receive it.
Event(String),
/// Shortly after the workbench is ready.
StartupFinished,
}
impl ActivationEvent {
pub fn parse(s: &str) -> Result<Self, String> {
let s = s.trim();
if s == "on-startup-finished" {
return Ok(Self::StartupFinished);
}
let (kind, arg) = s.split_once(':').ok_or_else(|| format!("unknown activation event {s:?}"))?;
let arg = arg.trim();
if arg.is_empty() {
return Err(format!("activation event {s:?} needs a value"));
}
Ok(match kind {
"on-command" => Self::Command(arg.into()),
"on-language" => Self::Language(arg.into()),
"on-file" => Self::File(arg.into()),
"on-view" => Self::View(arg.into()),
"on-schedule" => {
let secs: u32 =
arg.trim_end_matches('s').parse().map_err(|_| format!("{s:?}: expected seconds, e.g. 300"))?;
if secs < 60 {
return Err(format!("{s:?}: the shortest schedule is 60 seconds"));
}
Self::Schedule(secs)
}
"on-event" => {
if !EVENT_KINDS.contains(&arg) {
return Err(format!("{s:?}: unknown event kind (one of {})", EVENT_KINDS.join(", ")));
}
Self::Event(arg.into())
}
_ => return Err(format!("unknown activation event {s:?}")),
})
}
}
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
impl std::fmt::Display for ActivationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Command(c) => write!(f, "on-command:{c}"),
Self::Language(l) => write!(f, "on-language:{l}"),
Self::File(g) => write!(f, "on-file:{g}"),
Self::View(v) => write!(f, "on-view:{v}"),
Self::Schedule(s) => write!(f, "on-schedule:{s}"),
Self::Event(k) => write!(f, "on-event:{k}"),
Self::StartupFinished => f.write_str("on-startup-finished"),
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct Raw {
id: String,
name: String,
version: String,
#[serde(default)]
description: String,
#[serde(default)]
authors: Vec<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
license: Option<String>,
#[serde(default)]
change_notes: Option<String>,
#[serde(default)]
engines: Option<Engines>,
/// 0.1 format.
#[serde(default)]
openidea_api: Option<String>,
#[serde(default)]
wasm: Option<PathBuf>,
#[serde(default)]
activation: Option<Vec<String>>,
#[serde(default)]
capabilities: Option<RawCapabilities>,
/// 0.1 format.
#[serde(default)]
permissions: Option<Vec<String>>,
#[serde(default)]
contributes: toml::Table,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Engines {
openidea: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCapabilities {
#[serde(default)]
required: Vec<String>,
#[serde(default)]
optional: Vec<String>,
}
impl Manifest {
pub fn parse(src: &str) -> Result<Self, String> {
let raw: Raw = toml::from_str(src).map_err(|e| e.to_string())?;
let (engine, legacy) = match (raw.engines, raw.openidea_api) {
(Some(e), None) => (e.openidea, false),
(None, Some(api)) => (api, true),
(Some(_), Some(_)) => return Err("use `engines.openidea`, not `openidea-api`".into()),
(None, None) => return Err("missing `engines.openidea` (e.g. \"^1.0\")".into()),
};
if legacy && raw.capabilities.is_some() {
return Err("`capabilities` requires `engines.openidea`; 0.1 manifests use `permissions`".into());
}
if !legacy && raw.permissions.is_some() {
return Err("`permissions` is the 0.1 format; use `[capabilities] required = [...]`".into());
}
let parse_caps = |list: Vec<String>| -> Result<Vec<Capability>, String> {
let mut out: Vec<Capability> = Vec::new();
for s in list {
let cap: Capability = s.parse()?;
if !out.contains(&cap) {
out.push(cap);
}
}
Ok(out)
};
let capabilities = match (raw.capabilities, raw.permissions) {
(Some(c), _) => Capabilities { required: parse_caps(c.required)?, optional: parse_caps(c.optional)? },
(None, Some(p)) => Capabilities { required: parse_caps(p)?, optional: Vec::new() },
(None, None) => Capabilities::default(),
};
let activation = match raw.activation {
Some(list) => list.iter().map(|s| ActivationEvent::parse(s)).collect::<Result<Vec<_>, _>>()?,
// 0.1 plugins were activated at start-up; keep that until they move to 1.0.
None if legacy && raw.wasm.is_some() => vec![ActivationEvent::StartupFinished],
None => Vec::new(),
};
let m = Manifest {
id: raw.id,
name: raw.name,
version: raw.version,
description: raw.description,
authors: raw.authors,
homepage: raw.homepage,
license: raw.license,
change_notes: raw.change_notes,
engine,
legacy,
wasm: raw.wasm,
activation,
capabilities,
contributes: raw.contributes,
};
m.validate()?;
Ok(m)
}
pub fn load(dir: &Path) -> Result<Self, String> {
let path = dir.join(MANIFEST_FILE);
let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
}
fn validate(&self) -> Result<(), String> {
validate_plugin_id(&self.id)?;
if self.name.trim().is_empty() {
return Err("`name` must not be empty".into());
}
if semver::Version::parse(&self.version).is_err() {
return Err(format!("`version` must be a semantic version like 1.2.3, got {:?}", self.version));
}
if !engine_compatible(&self.engine, self.legacy) {
return Err(if self.legacy {
format!(
"unsupported openidea-api {:?} (this IDE implements {LEGACY_API} and {ENGINE_VERSION})",
self.engine
)
} else {
format!("engines.openidea {:?} does not match this IDE's plugin API {ENGINE_VERSION}", self.engine)
});
}
if let Some(wasm) = &self.wasm {
check_relative(wasm, "wasm")?;
}
if self.wasm.is_none() && !self.activation.is_empty() {
return Err("`activation` needs `wasm`: there is no code to activate".into());
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if !seen.insert(event) {
return Err(format!("duplicate activation event {event}"));
}
}
for (point, decls) in &self.contributes {
validate_point_id(point)?;
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
return Err(format!("`contributes.{point}` must be a table or an array of tables"));
}
}
Ok(())
}
/// The declarations of one extension point: `[[contributes.x]]` gives
/// several, `[contributes.x]` one.
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
match self.contributes.get(point) {
Some(toml::Value::Array(items)) => items.clone(),
Some(single @ toml::Value::Table(_)) => vec![single.clone()],
_ => Vec::new(),
}
}
/// Number of declarations per extension point (for listings before loading).
pub fn declaration_counts(&self) -> Vec<(String, u32)> {
self.contributes.keys().map(|p| (p.clone(), self.declarations(p).len() as u32)).collect()
}
/// Publisher namespace: the id's part before the first `.` (`acme` in `acme.tools`).
pub fn publisher(&self) -> Option<&str> {
self.id.split_once('.').map(|(p, _)| p)
}
/// Whether `cap` is declared (required or optional).
pub fn declares(&self, cap: &Capability) -> bool {
self.capabilities.all().any(|c| c.covers(cap))
}
}
/// Whether this host can load a plugin written for `engine`.
pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
if legacy {
return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.'));
}
let Ok(req) = semver::VersionReq::parse(engine) else { return false };
let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version");
req.matches(&host)
}
/// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher
/// namespace first), at most 64 chars.
pub fn validate_plugin_id(id: &str) -> Result<(), String> {
let ok = id.len() <= 64 && !id.is_empty() && id.split('.').all(segment_ok);
if ok {
Ok(())
} else {
Err(format!(
"invalid plugin id {id:?}: use a-z, 0-9, '-', '_' in '.'-separated parts (e.g. acme.tools), max 64"
))
}
}
/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars (commands, themes, views…).
pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
let ok_rest = id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
if ok_first && ok_rest && id.len() <= 64 {
Ok(())
} else {
Err(format!("invalid {what} {id:?}: use a-z, 0-9, '.', '-', '_', starting with a letter or digit (max 64)"))
}
}
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
pub fn validate_point_id(id: &str) -> Result<(), String> {
let ok = !id.is_empty()
&& id.len() <= 64
&& id.starts_with(|c: char| c.is_ascii_lowercase())
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
}
fn segment_ok(s: &str) -> bool {
s.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
/// Package paths must stay inside the package directory.
pub fn check_relative(path: &Path, what: &str) -> Result<(), String> {
let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)));
if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) }
}
#[cfg(test)]
mod tests {
use super::*;
const FULL: &str = r#"
id = "acme.rust-tools"
name = "Rust Tools"
version = "1.2.0"
engines.openidea = "^1.0"
wasm = "plugin.wasm"
activation = ["on-language:rust", "on-command:check", "on-schedule:300"]
[capabilities]
required = ["project.read", "editor.write"]
optional = ["net:crates.io", "process:cargo"]
[[contributes.commands]]
id = "check"
title = "%check.title%"
[[contributes.menus]]
menu = "editor.context"
command = "check"
when = "editorLang == rust"
[contributes.settings]
schema = "settings.schema.json"
"#;
#[test]
fn event_activation() {
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
}
#[test]
fn parses_the_02_format() {
let m = Manifest::parse(FULL).unwrap();
assert_eq!(m.publisher(), Some("acme"));
assert!(!m.legacy);
assert_eq!(m.activation.len(), 3);
assert_eq!(m.activation[2], ActivationEvent::Schedule(300));
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
assert!(m.capabilities.is_optional(&Capability::Process("cargo".into())));
assert_eq!(m.declarations("commands").len(), 1);
assert_eq!(m.declarations("settings").len(), 1, "a single table is one declaration");
assert!(m.declarations("nope").is_empty());
let counts: Vec<_> = m.declaration_counts();
assert!(counts.contains(&("menus".into(), 1)));
}
#[test]
fn parses_the_01_format() {
let m = Manifest::parse(
"id = \"hello\"\nname = \"Hello\"\nversion = \"0.1.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n\
permissions = [\"project-read\", \"editor-write\"]\n[[contributes.commands]]\nid = \"x\"\ntitle = \"X\"",
)
.unwrap();
assert!(m.legacy);
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
assert_eq!(m.activation, vec![ActivationEvent::StartupFinished]);
assert_eq!(m.publisher(), None);
}
fn err(src: &str) -> String {
Manifest::parse(src).unwrap_err()
}
#[test]
fn rejects_invalid_manifests() {
let base = |extra: &str| format!("id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\n{extra}");
assert!(err(&base("")).contains("engines.openidea"));
assert!(err(&base("engines.openidea = \"^0.3\"")).contains("does not match"));
assert!(err(&base("engines.openidea = \"^2\"")).contains("does not match"));
assert!(err(&base("engines.openidea = \"^0.2\"")).contains("does not match"));
assert!(err(&base("openidea-api = \"0.2\"")).contains("unsupported"));
assert!(err(&base("engines.openidea = \"^1.0\"\npermissions = [\"project-read\"]")).contains("0.1 format"));
assert!(
err(&base("engines.openidea = \"^1.0\"\n[capabilities]\nrequired = [\"network\"]")).contains("unknown")
);
assert!(err(&base("engines.openidea = \"^1.0\"\nwasm = \"../x.wasm\"")).contains("relative path"));
assert!(err(&base("engines.openidea = \"^1.0\"\nactivation = [\"on-command:x\"]")).contains("needs `wasm`"));
assert!(
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-boot\"]"))
.contains("unknown")
);
assert!(
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-schedule:5\"]"))
.contains("60 seconds")
);
assert!(err(&base("engines.openidea = \"^1.0\"\nbogus = 1")).contains("unknown field"));
assert!(err(&base("engines.openidea = \"^1.0\"\ncontributes.commands = 3")).contains("table"));
assert!(
err("id = \"Bad\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
);
assert!(
err("id = \"a..b\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
);
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version"));
}
#[test]
fn ids() {
assert!(validate_plugin_id("openidea.material-icons").is_ok());
assert!(validate_plugin_id("hello_2").is_ok());
assert!(validate_plugin_id(".x").is_err());
assert!(validate_item_id("word-count.v2_x", "command id").is_ok());
assert!(validate_item_id("-x", "command id").is_err());
assert!(validate_point_id("iconThemes").is_ok());
assert!(validate_point_id("Icon").is_err());
assert!(engine_compatible("1.0", false));
assert!(engine_compatible(">=1.0, <2", false));
assert!(!engine_compatible("^0.2", false), "0.x components don't run on 1.0");
assert!(engine_compatible("0.1.3", true));
assert!(!engine_compatible("0.10", true));
}
}
+465
View File
@@ -0,0 +1,465 @@
//! Plugin distribution: the package file, its
//! signature, and the static marketplace index.
//!
//! - **Package** (`<id>-<version>.oiplugin`): a zip of the manifest, the files
//! its declarations reference, the component and licence files, plus
//! [`SIGNATURE_FILE`] when signed.
//! - **Signature**: Ed25519 over `openidea-plugin/1\n<id>\n<version>\n<digest>`,
//! `digest` being [`package_digest`] of the unpacked files. A signature
//! proves who published the bytes; whether to trust that publisher is the
//! index's call.
//! - **Index** (`index.json`): the publishers and their keys (the trust root),
//! the published plugins, and revocations. A publisher owns the id prefix
//! `<publisher>.`; a revoked key invalidates everything it signed, a
//! revoked plugin version is disabled where it is installed.
use crate::MANIFEST_FILE;
use base64::Engine as _;
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
pub const SIGNATURE_FILE: &str = "SIGNATURE.json";
pub const PACKAGE_EXTENSION: &str = "oiplugin";
pub const INDEX_FORMAT: u32 = 1;
/// Limits when unpacking an untrusted package.
const MAX_ENTRIES: usize = 10_000;
const MAX_TOTAL: u64 = 512 << 20;
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
/// Content digest of a package: the manifest plus `files` (relative paths).
pub fn package_digest(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
let mut all = files.to_vec();
all.push(PathBuf::from(MANIFEST_FILE));
crate::ledger::package_hash(dir, &all)
}
fn signed_message(id: &str, version: &str, digest: &str) -> Vec<u8> {
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
}
/// `SIGNATURE.json` of a signed package.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageSignature {
pub publisher: String,
/// Ed25519 public key, base64.
pub key: String,
/// Ed25519 signature, base64.
pub signature: String,
}
/// A publisher's key pair (the 32-byte seed, base64, in a file only the publisher has).
pub struct PublisherKey(SigningKey);
impl PublisherKey {
pub fn from_seed(seed: [u8; 32]) -> Self {
Self(SigningKey::from_bytes(&seed))
}
/// The seed as written by `openidea-plugin keygen`.
pub fn from_base64(text: &str) -> Result<Self, String> {
let bytes = B64.decode(text.trim()).map_err(|e| format!("key file: {e}"))?;
let seed: [u8; 32] = bytes.try_into().map_err(|_| "key file: expected a 32-byte key".to_owned())?;
Ok(Self::from_seed(seed))
}
pub fn seed_base64(&self) -> String {
B64.encode(self.0.to_bytes())
}
pub fn public_base64(&self) -> String {
B64.encode(self.0.verifying_key().to_bytes())
}
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
let sig = self.0.sign(&signed_message(id, version, digest));
PackageSignature {
publisher: publisher.into(),
key: self.public_base64(),
signature: B64.encode(sig.to_bytes()),
}
}
}
impl PackageSignature {
/// Whether the signature is valid for `id`, `version` and `digest` (the
/// key's validity is checked against an [`Index`]).
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
let key: [u8; 32] = B64
.decode(&self.key)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| "signature: malformed public key".to_owned())?;
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
let sig: [u8; 64] = B64
.decode(&self.signature)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| "signature: malformed".to_owned())?;
key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
.map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
}
}
/// Writes the package `out` from `dir`: the manifest, `files`, and the signature if any.
pub fn pack(dir: &Path, files: &[PathBuf], out: &Path, signature: Option<&PackageSignature>) -> Result<(), String> {
let io = |e: std::io::Error| e.to_string();
let file = std::fs::File::create(out).map_err(io)?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut names: Vec<PathBuf> = std::iter::once(PathBuf::from(MANIFEST_FILE)).chain(files.iter().cloned()).collect();
names.sort();
names.dedup();
for rel in names {
let name = rel.to_str().ok_or("non UTF-8 file name")?.replace('\\', "/");
zip.start_file(name, options).map_err(|e| e.to_string())?;
let bytes = std::fs::read(dir.join(&rel)).map_err(|e| format!("{}: {e}", rel.display()))?;
zip.write_all(&bytes).map_err(io)?;
}
if let Some(sig) = signature {
zip.start_file(SIGNATURE_FILE, options).map_err(|e| e.to_string())?;
zip.write_all(serde_json::to_string_pretty(sig).map_err(|e| e.to_string())?.as_bytes()).map_err(io)?;
}
zip.finish().map_err(|e| e.to_string())?;
Ok(())
}
/// What [`unpack`] wrote.
#[derive(Debug)]
pub struct Unpacked {
/// Every file but the manifest and the signature, relative.
pub files: Vec<PathBuf>,
pub signature: Option<PackageSignature>,
}
/// `name` as a relative path that stays inside the package.
fn safe_path(name: &str) -> Option<PathBuf> {
let p = Path::new(name);
(!name.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))).then(|| p.to_path_buf())
}
/// Archiver metadata at the top of an archive, not part of a package
/// (`__MACOSX/…`, `.DS_Store`, AppleDouble `._…` files).
fn archiver_metadata(name: &str) -> bool {
let top = name.split('/').next().unwrap_or_default();
top == "__MACOSX" || top == ".DS_Store" || (top.starts_with("._") && top.len() > 2)
}
/// Where the package is inside an archive: at its root (`.oiplugin`, or a zip
/// of the package's contents) or in its only folder (a zip of the package
/// folder). `None` when neither holds a manifest.
fn package_prefix(names: &[String]) -> Option<String> {
if names.iter().any(|n| n == MANIFEST_FILE) {
return Some(String::new());
}
let mut tops = names.iter().filter(|n| !archiver_metadata(n)).map(|n| n.split('/').next().unwrap_or_default());
let top = tops.next()?;
if tops.any(|t| t != top) {
return None;
}
let prefix = format!("{top}/");
names.iter().any(|n| *n == format!("{prefix}{MANIFEST_FILE}")).then_some(prefix)
}
/// Extracts the package `archive` (an `.oiplugin`, or a `.zip` of a package
/// folder or of its contents) into the empty directory `dest`, refusing
/// paths leaving it, links, and oversized contents.
pub fn unpack(archive: &Path, dest: &Path) -> Result<Unpacked, String> {
let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?;
let mut zip =
zip::ZipArchive::new(file).map_err(|e| format!("{}: not a plugin package ({e})", archive.display()))?;
if zip.len() > MAX_ENTRIES {
return Err(format!("more than {MAX_ENTRIES} files"));
}
let mut names = Vec::with_capacity(zip.len());
for i in 0..zip.len() {
let entry = zip.by_index(i).map_err(|e| e.to_string())?;
if !entry.is_dir() {
names.push(entry.name().map_err(|e| e.to_string())?.into_owned());
}
}
// An IntelliJ plugin or any other zip: say what is missing rather than fail later.
let prefix = package_prefix(&names).ok_or_else(|| {
format!("{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", archive.display())
})?;
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
let (mut files, mut signature, mut total, mut manifest) = (Vec::new(), None, 0u64, false);
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
if entry.is_dir() {
continue;
}
let full = entry.name().map_err(|e| e.to_string())?.into_owned();
// Outside the package folder there is only archiver metadata (`package_prefix` checked).
let Some(name) = full.strip_prefix(&prefix).map(str::to_owned) else { continue };
if archiver_metadata(&name) {
continue;
}
if entry.is_symlink() {
return Err(format!("{full}: links are not allowed in packages"));
}
let rel = safe_path(&name).ok_or_else(|| format!("invalid path {full:?} in package"))?;
let mut bytes = Vec::new();
entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
total += bytes.len() as u64;
if total > MAX_TOTAL {
return Err(format!("the package unpacks to more than {} MiB", MAX_TOTAL >> 20));
}
if rel == Path::new(SIGNATURE_FILE) {
signature = Some(serde_json::from_slice(&bytes).map_err(|e| format!("{SIGNATURE_FILE}: {e}"))?);
continue;
}
let to = dest.join(&rel);
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&to, bytes).map_err(|e| e.to_string())?;
if rel == Path::new(MANIFEST_FILE) {
manifest = true;
} else {
files.push(rel);
}
}
if !manifest {
return Err(format!("the package has no {MANIFEST_FILE}"));
}
files.sort();
Ok(Unpacked { files, signature })
}
// ---- index -------------------------------------------------------------------
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct Index {
pub format: u32,
#[serde(default)]
pub publishers: Vec<Publisher>,
#[serde(default)]
pub plugins: Vec<IndexEntry>,
#[serde(default)]
pub revoked: Vec<Revocation>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct Publisher {
/// Owns plugin ids `<id>.…`.
pub id: String,
pub name: String,
pub keys: Vec<KeyEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct KeyEntry {
/// Ed25519 public key, base64.
pub key: String,
/// Packages signed with it are no longer trusted.
#[serde(default)]
pub revoked: bool,
}
/// A published plugin version.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct IndexEntry {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub authors: Vec<String>,
pub publisher: String,
/// The package, relative to the index (or an absolute URL).
pub download: String,
/// [`package_digest`] of its contents.
pub digest: String,
#[serde(default)]
pub capabilities: Vec<String>,
}
/// A plugin (all versions when `versions` is empty) that must not run.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct Revocation {
pub id: String,
#[serde(default)]
pub versions: Vec<String>,
pub reason: String,
}
impl Index {
pub fn parse(text: &str) -> Result<Self, String> {
let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
if index.format != INDEX_FORMAT {
return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
}
Ok(index)
}
/// The revocation that applies to `id` at `version`.
pub fn revocation(&self, id: &str, version: &str) -> Option<&Revocation> {
self.revoked.iter().find(|r| r.id == id && (r.versions.is_empty() || r.versions.iter().any(|v| v == version)))
}
/// Whether `key` (base64) belongs to a publisher and was revoked.
pub fn key_revoked(&self, key: &str) -> bool {
self.publishers.iter().flat_map(|p| &p.keys).any(|k| k.key == key && k.revoked)
}
/// The trusted publisher of a signed package: the signature is valid, its
/// key is one of the publisher's and not revoked, and the publisher owns
/// the plugin's id prefix.
pub fn verify(&self, sig: &PackageSignature, id: &str, version: &str, digest: &str) -> Result<String, String> {
let publisher = self
.publishers
.iter()
.find(|p| p.id == sig.publisher)
.ok_or_else(|| format!("unknown publisher {:?}", sig.publisher))?;
let key = publisher
.keys
.iter()
.find(|k| k.key == sig.key)
.ok_or_else(|| format!("the key is not one of {}'s", publisher.name))?;
if key.revoked {
return Err(format!("{}'s signing key was revoked", publisher.name));
}
if !id.starts_with(&format!("{}.", publisher.id)) {
return Err(format!("{} may only publish plugins named {}.*", publisher.name, publisher.id));
}
sig.check(id, version, digest)?;
Ok(publisher.id.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn package(dir: &Path) -> Vec<PathBuf> {
std::fs::create_dir_all(dir.join("dist")).unwrap();
std::fs::write(dir.join(MANIFEST_FILE), "id = \"acme.tools\"\nversion = \"1.0.0\"").unwrap();
std::fs::write(dir.join("dist/plugin.wasm"), b"\0asm").unwrap();
vec![PathBuf::from("dist/plugin.wasm")]
}
fn index(key: &PublisherKey) -> Index {
Index {
format: INDEX_FORMAT,
publishers: vec![Publisher {
id: "acme".into(),
name: "ACME".into(),
keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
}],
plugins: Vec::new(),
revoked: vec![Revocation {
id: "acme.bad".into(),
versions: vec!["1.0.0".into()],
reason: "malware".into(),
}],
}
}
#[test]
fn signed_packages_round_trip_and_tampering_is_caught() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
let files = package(&src);
let digest = package_digest(&src, &files).unwrap();
let key = PublisherKey::from_seed([7; 32]);
let sig = key.sign("acme", "acme.tools", "1.0.0", &digest);
let out = tmp.path().join("acme.tools-1.0.0.oiplugin");
pack(&src, &files, &out, Some(&sig)).unwrap();
let dest = tmp.path().join("dest");
let unpacked = unpack(&out, &dest).unwrap();
assert_eq!(unpacked.files, files);
assert_eq!(package_digest(&dest, &unpacked.files).unwrap(), digest);
let index = index(&key);
assert_eq!(index.verify(unpacked.signature.as_ref().unwrap(), "acme.tools", "1.0.0", &digest).unwrap(), "acme");
// Changed bytes, another id, someone else's prefix, a revoked key.
assert!(index.verify(&sig, "acme.tools", "1.0.0", "0000").is_err());
assert!(index.verify(&sig, "acme.tools", "1.0.1", &digest).is_err());
let other = PublisherKey::from_seed([9; 32]).sign("acme", "acme.tools", "1.0.0", &digest);
assert!(index.verify(&other, "acme.tools", "1.0.0", &digest).unwrap_err().contains("not one of"));
let foreign = key.sign("acme", "evil.tools", "1.0.0", &digest);
assert!(index.verify(&foreign, "evil.tools", "1.0.0", &digest).unwrap_err().contains("acme.*"));
let mut revoked = index.clone();
revoked.publishers[0].keys[0].revoked = true;
assert!(revoked.verify(&sig, "acme.tools", "1.0.0", &digest).unwrap_err().contains("revoked"));
assert!(revoked.key_revoked(&sig.key));
assert_eq!(index.revocation("acme.bad", "1.0.0").unwrap().reason, "malware");
assert!(index.revocation("acme.bad", "1.0.1").is_none());
assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64());
}
#[test]
fn unpacking_refuses_paths_leaving_the_package() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("evil.oiplugin");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
let o = zip::write::SimpleFileOptions::default();
zip.start_file(MANIFEST_FILE, o).unwrap();
zip.write_all(b"id = \"x\"").unwrap();
zip.start_file("../outside.txt", o).unwrap();
zip.write_all(b"x").unwrap();
zip.finish().unwrap();
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
assert!(err.contains("invalid path"), "{err}");
assert!(!tmp.path().join("outside.txt").exists());
assert!(Index::parse(r#"{"format": 2}"#).is_err());
}
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, body) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(body.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
#[test]
fn a_zip_holds_the_package_at_its_root_or_in_its_only_folder() {
let tmp = tempfile::tempdir().unwrap();
let manifest = "id = \"acme.tools\"";
let at_root = tmp.path().join("root.zip");
zip_of(&at_root, &[(MANIFEST_FILE, manifest), ("dist/plugin.wasm", "w"), (".DS_Store", "x")]);
let got = unpack(&at_root, &tmp.path().join("a")).unwrap();
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
// What zipping the folder in Finder makes: the folder plus `__MACOSX`.
let folder = tmp.path().join("folder.zip");
zip_of(
&folder,
&[
("acme-tools/plugin.toml", manifest),
("acme-tools/dist/plugin.wasm", "w"),
("__MACOSX/acme-tools/._plugin.toml", "x"),
],
);
let dest = tmp.path().join("b");
let got = unpack(&folder, &dest).unwrap();
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
assert!(dest.join(MANIFEST_FILE).is_file());
assert!(!dest.join("__MACOSX").exists() && !dest.join("acme-tools").exists());
// Two folders, a manifest one level too deep, or none (an IntelliJ plugin): not a package.
for (name, entries) in [
("two.zip", &[("a/plugin.toml", manifest), ("b/x.txt", "x")][..]),
("deep.zip", &[("a/b/plugin.toml", manifest)][..]),
("intellij.zip", &[("my-plugin/lib/my-plugin.jar", "jar")][..]),
] {
let path = tmp.path().join(name);
zip_of(&path, entries);
let err = unpack(&path, &tmp.path().join(name).with_extension("out")).unwrap_err();
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
}
}
}
+175
View File
@@ -0,0 +1,175 @@
//! The extension point contract (plan 4.1).
use crate::capability::Capability;
use crate::manifest::{Manifest, check_relative};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Where a contribution takes effect (plan 3, placement rules).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum Placement {
/// Projected to the client (menus, keybindings, themes, icons).
Ui,
/// Takes effect on the machine the project is on (languages, LSP, run configurations).
Workspace,
}
/// The package a declaration comes from, as extension points see it.
#[derive(Clone, Debug)]
pub struct Package {
pub manifest: Arc<Manifest>,
/// Package directory.
pub dir: PathBuf,
/// Hash of the package's files when they never change (an installed
/// copy): validated declarations are then reused from the
/// [`ContributionIndex`](crate::ContributionIndex).
pub digest: Option<Arc<str>>,
}
/// Largest single package file an extension point reads.
pub const MAX_PACKAGE_FILE: u64 = 8 << 20;
impl Package {
pub fn new(manifest: Manifest, dir: PathBuf) -> Self {
Self { manifest: Arc::new(manifest), dir, digest: None }
}
pub fn with_digest(mut self, digest: &str) -> Self {
self.digest = Some(digest.into());
self
}
pub fn id(&self) -> &str {
&self.manifest.id
}
/// Resolves a package-relative path, refusing anything that leaves the
/// package (`..`, absolute paths, symlinks pointing outside).
pub fn resolve(&self, rel: &Path) -> Result<PathBuf, String> {
check_relative(rel, "path")?;
let joined = self.dir.join(rel);
let real = joined.canonicalize().map_err(|_| format!("{}: no such file in the plugin", rel.display()))?;
let root = self.dir.canonicalize().map_err(|e| format!("plugin directory: {e}"))?;
if !real.starts_with(&root) {
return Err(format!("{}: points outside the plugin", rel.display()));
}
Ok(real)
}
pub fn read_bytes(&self, rel: &Path) -> Result<Vec<u8>, String> {
let real = self.resolve(rel)?;
let meta = std::fs::metadata(&real).map_err(|e| format!("{}: {e}", rel.display()))?;
if !meta.is_file() {
return Err(format!("{}: not a file", rel.display()));
}
if meta.len() > MAX_PACKAGE_FILE {
return Err(format!("{}: larger than {} MiB", rel.display(), MAX_PACKAGE_FILE >> 20));
}
std::fs::read(&real).map_err(|e| format!("{}: {e}", rel.display()))
}
pub fn read_text(&self, rel: &Path) -> Result<String, String> {
String::from_utf8(self.read_bytes(rel)?).map_err(|_| format!("{}: not UTF-8 text", rel.display()))
}
}
/// Undoes one committed contribution. Dropping without [`dispose`](Self::dispose) also disposes.
#[must_use = "dropping a Disposable undoes the contribution"]
pub struct Disposable(Option<Box<dyn FnOnce() + Send>>);
impl Disposable {
pub fn new(f: impl FnOnce() + Send + 'static) -> Self {
Self(Some(Box::new(f)))
}
/// A contribution with nothing to undo.
pub fn noop() -> Self {
Self(None)
}
pub fn dispose(mut self) {
if let Some(f) = self.0.take() {
f();
}
}
}
impl Drop for Disposable {
fn drop(&mut self) {
if let Some(f) = self.0.take() {
f();
}
}
}
impl std::fmt::Debug for Disposable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Disposable")
}
}
pub type Commit = Box<dyn FnOnce() -> Result<Disposable, String> + Send>;
/// A validated declaration, ready to take effect.
pub struct Prepared {
/// Capabilities this declaration needs (a language with an LSP → `process:<binary>`).
pub capabilities: Vec<Capability>,
/// Package files the declaration references (copied on install).
pub files: Vec<PathBuf>,
/// Identity within the point: a later declaration with the same key replaces
/// the earlier one of the same plugin (re-registering a command).
pub key: Option<String>,
/// User-facing one-liner for the plugin details page ("Insert Date").
pub label: String,
/// Makes it take effect. Must not fail for reasons `prepare` could have checked.
pub commit: Commit,
}
impl Prepared {
pub fn new(label: impl Into<String>, commit: impl FnOnce() -> Result<Disposable, String> + Send + 'static) -> Self {
Self { capabilities: Vec::new(), files: Vec::new(), key: None, label: label.into(), commit: Box::new(commit) }
}
pub fn key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
self.files.push(path.into());
self
}
pub fn capability(mut self, cap: Capability) -> Self {
if !self.capabilities.contains(&cap) {
self.capabilities.push(cap);
}
self
}
}
impl std::fmt::Debug for Prepared {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Prepared").field("key", &self.key).field("label", &self.label).finish_non_exhaustive()
}
}
/// A kind of contribution (`contributes.<id>` in the manifest). Implemented by
/// the crate that owns the feature; registered once in an [`crate::ExtensionRegistry`].
pub trait ExtensionPoint: Send + Sync {
/// `"languages"`, `"iconThemes"`, …
fn id(&self) -> &'static str;
fn placement(&self) -> Placement;
/// Parses and validates one declaration without side effects, and works
/// out the capabilities it needs. `Ui` points validate completely here: the
/// client's projection is not allowed to fail.
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String>;
}
/// Deserializes a declaration with serde, prefixing errors with the point id.
pub fn decode<T: serde::de::DeserializeOwned>(point: &str, decl: toml::Value) -> Result<T, String> {
decl.try_into().map_err(|e: toml::de::Error| format!("contributes.{point}: {}", e.message()))
}
+305
View File
@@ -0,0 +1,305 @@
//! Extension points plus the contributions committed to them, accounted per plugin.
use crate::capability::Capability;
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
/// A contribution as the plugin details page lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContributionInfo {
pub point: String,
pub label: String,
}
struct Entry {
point: &'static str,
key: Option<String>,
label: String,
disposable: Disposable,
}
/// Every declaration of a package, validated; nothing has taken effect yet.
pub struct PreparedSet {
pub plugin: String,
/// Union of what the declarations need (beyond the manifest's own list).
pub capabilities: Vec<Capability>,
/// Package files the declarations reference.
pub files: Vec<PathBuf>,
/// Declarations for extension points this host does not know (skipped).
pub warnings: Vec<String>,
items: Vec<(&'static str, Prepared)>,
}
impl PreparedSet {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
}
impl std::fmt::Debug for PreparedSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedSet")
.field("plugin", &self.plugin)
.field("items", &self.items.len())
.field("warnings", &self.warnings)
.finish()
}
}
#[derive(Default)]
pub struct ExtensionRegistry {
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
/// plugin id → its contributions in commit order.
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
}
impl ExtensionRegistry {
pub fn new() -> Self {
Self::default()
}
/// Adds an extension point. Ids are unique.
pub fn register(&self, point: Arc<dyn ExtensionPoint>) -> Result<(), String> {
let id = point.id();
crate::manifest::validate_point_id(id)?;
let mut points = self.points.write();
if points.contains_key(id) {
return Err(format!("extension point {id:?} is already registered"));
}
points.insert(id, point);
Ok(())
}
pub fn point_ids(&self) -> Vec<&'static str> {
self.points.read().keys().copied().collect()
}
pub fn placement(&self, point: &str) -> Option<Placement> {
self.points.read().get(point).map(|p| p.placement())
}
fn point(&self, id: &str) -> Option<Arc<dyn ExtensionPoint>> {
self.points.read().get(id).cloned()
}
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
/// whole on the first invalid declaration; unknown points are skipped with
/// a warning (forward compatibility).
pub fn prepare(&self, pkg: &Package) -> Result<PreparedSet, String> {
let mut set = PreparedSet {
plugin: pkg.id().to_owned(),
capabilities: Vec::new(),
files: Vec::new(),
warnings: Vec::new(),
items: Vec::new(),
};
for point_id in pkg.manifest.contributes.keys() {
let Some(point) = self.point(point_id) else {
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
tracing::warn!(plugin = %pkg.id(), "{msg}");
set.warnings.push(msg);
continue;
};
for (i, decl) in pkg.manifest.declarations(point_id).into_iter().enumerate() {
let prepared = point
.prepare(pkg, decl)
.map_err(|e| format!("contributes.{point_id}[{i}]: {}", strip_prefix(&e, point_id)))?;
for cap in &prepared.capabilities {
if !set.capabilities.contains(cap) {
set.capabilities.push(cap.clone());
}
}
for f in &prepared.files {
if !set.files.contains(f) {
set.files.push(f.clone());
}
}
set.items.push((point.id(), prepared));
}
}
Ok(set)
}
/// Makes a prepared set take effect, all or nothing: if one commit fails,
/// the ones before it are disposed in reverse order.
pub fn commit(&self, set: PreparedSet) -> Result<(), String> {
let mut committed: Vec<Entry> = Vec::with_capacity(set.items.len());
for (point, prepared) in set.items {
let Prepared { key, label, commit, .. } = prepared;
match commit() {
Ok(disposable) => committed.push(Entry { point, key, label, disposable }),
Err(e) => {
tracing::error!(plugin = %set.plugin, "contributes.{point}: commit failed: {e}");
while let Some(entry) = committed.pop() {
entry.disposable.dispose();
}
return Err(format!("contributes.{point}: {e}"));
}
}
}
let replaced = {
let mut entries = self.entries.lock();
let list = entries.entry(set.plugin).or_default();
let mut replaced = Vec::new();
for new in &committed {
if let Some(key) = &new.key
&& let Some(pos) = list.iter().position(|e| e.point == new.point && e.key.as_ref() == Some(key))
{
replaced.push(list.remove(pos));
}
}
list.extend(committed);
replaced
};
for old in replaced.into_iter().rev() {
old.disposable.dispose();
}
Ok(())
}
/// Withdraws everything `plugin` contributed, newest first.
pub fn dispose_plugin(&self, plugin: &str) -> bool {
let list = self.entries.lock().remove(plugin).unwrap_or_default();
let any = !list.is_empty();
for entry in list.into_iter().rev() {
entry.disposable.dispose();
}
any
}
pub fn contributions(&self, plugin: &str) -> Vec<ContributionInfo> {
self.entries
.lock()
.get(plugin)
.map(|list| {
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
})
.unwrap_or_default()
}
/// How many contributions `plugin` has at `point`.
pub fn count(&self, plugin: &str, point: &str) -> usize {
self.entries.lock().get(plugin).map_or(0, |list| list.iter().filter(|e| e.point == point).count())
}
/// Plugins with at least one contribution.
pub fn plugins(&self) -> Vec<String> {
self.entries.lock().iter().filter(|(_, l)| !l.is_empty()).map(|(id, _)| id.clone()).collect()
}
}
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Records live items in a shared list; `fail = true` makes commit fail.
struct Recording {
id: &'static str,
live: Arc<Mutex<Vec<String>>>,
commits: Arc<AtomicUsize>,
}
impl ExtensionPoint for Recording {
fn id(&self) -> &'static str {
self.id
}
fn placement(&self) -> Placement {
Placement::Ui
}
fn prepare(&self, _pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let name = decl.get("name").and_then(|v| v.as_str()).ok_or("missing name")?.to_owned();
let fail = decl.get("fail").and_then(|v| v.as_bool()).unwrap_or(false);
let (live, commits) = (self.live.clone(), self.commits.clone());
let label = name.clone();
let mut p = Prepared::new(label, move || {
commits.fetch_add(1, Ordering::SeqCst);
if fail {
return Err("boom".into());
}
live.lock().push(name.clone());
Ok(Disposable::new(move || live.lock().retain(|n| *n != name)))
});
if let Some(f) = decl.get("file").and_then(|v| v.as_str()) {
p = p.file(f);
}
if decl.get("key").and_then(|v| v.as_bool()).unwrap_or(false) {
p = p.key("k");
}
Ok(p)
}
}
fn setup() -> (ExtensionRegistry, Arc<Mutex<Vec<String>>>) {
let reg = ExtensionRegistry::new();
let live = Arc::new(Mutex::new(Vec::new()));
let commits = Arc::new(AtomicUsize::new(0));
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: commits.clone() })).unwrap();
reg.register(Arc::new(Recording { id: "others", live: live.clone(), commits })).unwrap();
(reg, live)
}
fn pkg(contributes: &str) -> Package {
let src = format!("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n{contributes}");
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
}
#[test]
fn commits_and_disposes_per_plugin() {
let (reg, live) = setup();
assert!(
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: Default::default() }))
.is_err()
);
let p = pkg(
"[[contributes.things]]\nname = \"a\"\nfile = \"x.json\"\n[[contributes.things]]\nname = \"b\"\n[contributes.others]\nname = \"c\"",
);
let set = reg.prepare(&p).unwrap();
assert_eq!(set.len(), 3);
assert_eq!(set.files, [PathBuf::from("x.json")]);
assert!(live.lock().is_empty(), "prepare has no side effects");
reg.commit(set).unwrap();
assert_eq!(*live.lock(), ["c", "a", "b"]);
assert_eq!(reg.count("p", "things"), 2);
assert_eq!(reg.contributions("p").len(), 3);
assert!(reg.dispose_plugin("p"));
assert!(live.lock().is_empty());
}
#[test]
fn a_failed_commit_rolls_back() {
let (reg, live) = setup();
let p = pkg("[[contributes.things]]\nname = \"a\"\n[[contributes.things]]\nname = \"b\"\nfail = true");
let err = reg.commit(reg.prepare(&p).unwrap()).unwrap_err();
assert!(err.contains("boom"));
assert!(live.lock().is_empty(), "a was undone");
assert!(reg.contributions("p").is_empty());
}
#[test]
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
let (reg, _) = setup();
let err = reg.prepare(&pkg("[[contributes.things]]\nnope = 1")).unwrap_err();
assert_eq!(err, "contributes.things[0]: missing name");
let set =
reg.prepare(&pkg("[[contributes.futureThing]]\nx = 1\n[[contributes.things]]\nname = \"a\"")).unwrap();
assert_eq!(set.len(), 1);
assert_eq!(set.warnings.len(), 1);
assert!(set.warnings[0].contains("futureThing"));
}
}
+247
View File
@@ -0,0 +1,247 @@
//! UI contributions: validated declarations projected to the client.
//!
//! A `Ui` extension point validates a declaration completely in Rust and
//! commits it as JSON into the [`UiStore`]; the client receives the store's
//! snapshot and projects each item onto an existing `register*` function
//! (`defineUiPoint` in `src/features/plugins/projection.ts`). Large payloads
//! (icon SVGs) go into the store's assets and are fetched on demand.
use crate::index::ContributionIndex;
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
/// One projected declaration.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UiContribution {
pub plugin_id: String,
/// Extension point id (`commands`, `iconThemes`, …).
pub point: String,
/// Stable within (plugin, point): the client keys its registrations on it.
pub key: String,
/// The validated declaration, shaped by the point.
#[specta(type = specta_typescript::Unknown)]
pub decl: serde_json::Value,
}
struct Item {
seq: u64,
contribution: UiContribution,
}
/// Committed UI contributions of all plugins.
#[derive(Default)]
pub struct UiStore {
items: RwLock<Vec<Item>>,
/// (plugin, asset id) → content.
assets: RwLock<BTreeMap<(String, String), Arc<str>>>,
seq: AtomicU64,
revision: AtomicU64,
index: RwLock<Option<Arc<ContributionIndex>>>,
}
impl UiStore {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Adds an item; the returned disposable removes it.
pub fn insert(self: &Arc<Self>, contribution: UiContribution) -> Disposable {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push(Item { seq, contribution });
self.revision.fetch_add(1, Ordering::Release);
let store = Arc::downgrade(self);
Disposable::new(move || {
if let Some(store) = store.upgrade() {
store.items.write().retain(|i| i.seq != seq);
store.revision.fetch_add(1, Ordering::Release);
}
})
}
/// Stores assets of a plugin (ids are unique per plugin; later ones replace).
pub fn insert_assets(self: &Arc<Self>, plugin: &str, assets: Vec<(String, String)>) -> Disposable {
let keys: Vec<(String, String)> = assets.iter().map(|(id, _)| (plugin.to_owned(), id.clone())).collect();
{
let mut map = self.assets.write();
for (id, content) in assets {
map.insert((plugin.to_owned(), id), Arc::from(content));
}
}
let store = Arc::downgrade(self);
Disposable::new(move || {
if let Some(store) = store.upgrade() {
let mut map = store.assets.write();
for k in keys {
map.remove(&k);
}
}
})
}
pub fn asset(&self, plugin: &str, id: &str) -> Option<Arc<str>> {
self.assets.read().get(&(plugin.to_owned(), id.to_owned())).cloned()
}
/// Assets of `plugin` whose id starts with `prefix`.
pub fn assets_with_prefix(&self, plugin: &str, prefix: &str) -> Vec<(String, Arc<str>)> {
self.assets
.read()
.range((plugin.to_owned(), prefix.to_owned())..)
.take_while(|((p, id), _)| p == plugin && id.starts_with(prefix))
.map(|((_, id), v)| (id.clone(), v.clone()))
.collect()
}
/// Where [`UiPoint`]s keep validated declarations of installed packages.
pub fn set_index(&self, index: Option<Arc<ContributionIndex>>) {
*self.index.write() = index;
}
/// Bumped on every change: clients compare it to skip re-projection.
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
pub fn snapshot(&self) -> Vec<UiContribution> {
self.items.read().iter().map(|i| i.contribution.clone()).collect()
}
/// Items of one point, optionally of one plugin.
pub fn items(&self, point: &str, plugin: Option<&str>) -> Vec<UiContribution> {
self.items
.read()
.iter()
.filter(|i| i.contribution.point == point && plugin.is_none_or(|p| i.contribution.plugin_id == p))
.map(|i| i.contribution.clone())
.collect()
}
}
/// What a [`UiPoint`]'s validator produces for one declaration.
#[derive(Debug, Serialize, Deserialize)]
pub struct UiDecl {
pub key: String,
pub label: String,
pub json: serde_json::Value,
/// Package files referenced (copied on install).
pub files: Vec<PathBuf>,
/// Large validated payloads fetched separately by the client.
pub assets: Vec<(String, String)>,
}
impl UiDecl {
pub fn new(key: impl Into<String>, label: impl Into<String>, json: serde_json::Value) -> Self {
Self { key: key.into(), label: label.into(), json, files: Vec::new(), assets: Vec::new() }
}
}
type Validate = dyn Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync;
/// A `Ui` extension point defined by a validator: committing stores the
/// validated JSON (and assets) in the [`UiStore`]. Most UI contribution types
/// (menus, keybindings, status bar items, icon themes) are one of these.
pub struct UiPoint {
id: &'static str,
store: Arc<UiStore>,
validate: Box<Validate>,
}
impl UiPoint {
pub fn new(
id: &'static str,
store: Arc<UiStore>,
validate: impl Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync + 'static,
) -> Self {
Self { id, store, validate: Box::new(validate) }
}
}
impl ExtensionPoint for UiPoint {
fn id(&self) -> &'static str {
self.id
}
fn placement(&self) -> Placement {
Placement::Ui
}
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let index = pkg.digest.as_ref().and_then(|_| self.store.index.read().clone());
let ui = match index.as_ref().and_then(|i| i.get(pkg, self.id, &decl)) {
Some(ui) => ui,
None => match &index {
Some(i) => {
let ui = (self.validate)(pkg, decl.clone())?;
i.put(pkg, self.id, &decl, &ui);
ui
}
None => (self.validate)(pkg, decl)?,
},
};
let UiDecl { key, label, json, files, assets } = ui;
let store = self.store.clone();
let contribution =
UiContribution { plugin_id: pkg.id().to_owned(), point: self.id.to_owned(), key: key.clone(), decl: json };
let plugin = pkg.id().to_owned();
let mut prepared = Prepared::new(label, move || {
let item = store.insert(contribution);
if assets.is_empty() {
return Ok(item);
}
let assets = store.insert_assets(&plugin, assets);
Ok(Disposable::new(move || {
item.dispose();
assets.dispose();
}))
})
.key(key);
prepared.files = files;
Ok(prepared)
}
}
/// TOML declaration → JSON for the client.
pub fn to_json(v: &toml::Value) -> serde_json::Value {
serde_json::to_value(v).unwrap_or(serde_json::Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
use crate::registry::ExtensionRegistry;
#[test]
fn ui_point_projects_into_the_store() {
let store = UiStore::new();
let reg = ExtensionRegistry::new();
reg.register(Arc::new(UiPoint::new("badges", store.clone(), |_pkg, decl| {
let text = decl.get("text").and_then(|v| v.as_str()).ok_or("missing text")?.to_owned();
let mut d = UiDecl::new(text.clone(), text.clone(), serde_json::json!({ "text": text }));
d.assets.push((format!("badge/{text}"), "<svg/>".into()));
Ok(d)
})))
.unwrap();
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.badges]]\ntext = \"hi\"";
let pkg = Package::new(Manifest::parse(src).unwrap(), PathBuf::from("/x"));
let rev = store.revision();
reg.commit(reg.prepare(&pkg).unwrap()).unwrap();
assert!(store.revision() > rev);
let items = store.items("badges", Some("p"));
assert_eq!(items.len(), 1);
assert_eq!(items[0].decl["text"], "hi");
assert_eq!(store.asset("p", "badge/hi").as_deref(), Some("<svg/>"));
assert_eq!(store.assets_with_prefix("p", "badge/").len(), 1);
reg.dispose_plugin("p");
assert!(store.snapshot().is_empty());
assert!(store.asset("p", "badge/hi").is_none());
}
}
+136
View File
@@ -0,0 +1,136 @@
//! Syntax check for `when` clauses in UI declarations. The client evaluates
//! them (`src/platform/context.ts`); validating here keeps the client's
//! projection from failing. Grammar (same as the client):
//!
//! ```text
//! expr := or ; or := and ("||" and)* ; and := unary ("&&" unary)*
//! unary := "!" unary | "(" expr ")" | cmp ; cmp := key (("==" | "!=") value)?
//! ```
const MAX_LEN: usize = 512;
#[derive(Debug, PartialEq)]
enum Tok<'a> {
Id(&'a str),
Str,
Op(&'a str),
}
fn tokenize(src: &str) -> Result<Vec<Tok<'_>>, String> {
let mut out = Vec::new();
let b = src.as_bytes();
let mut i = 0;
while i < b.len() {
let c = b[i] as char;
if c.is_whitespace() {
i += 1;
} else if ["&&", "||", "==", "!="].iter().any(|op| src[i..].starts_with(op)) {
out.push(Tok::Op(&src[i..i + 2]));
i += 2;
} else if matches!(c, '!' | '(' | ')') {
out.push(Tok::Op(&src[i..i + 1]));
i += 1;
} else if c == '\'' || c == '"' {
let end = src[i + 1..].find(c).ok_or_else(|| format!("unterminated string in when clause: {src}"))?;
out.push(Tok::Str);
i += end + 2;
} else {
let len = src[i..]
.find(|ch: char| !(ch.is_alphanumeric() || matches!(ch, '_' | '.' | '-' | ':' | '/')))
.unwrap_or(src.len() - i);
if len == 0 {
return Err(format!("unexpected '{c}' in when clause: {src}"));
}
out.push(Tok::Id(&src[i..i + len]));
i += len;
}
}
Ok(out)
}
/// Checks a `when` clause; empty means "always".
pub fn validate_when(src: &str) -> Result<(), String> {
if src.len() > MAX_LEN {
return Err(format!("when clause longer than {MAX_LEN} bytes"));
}
if src.trim().is_empty() {
return Ok(());
}
let toks = tokenize(src)?;
let mut p = 0;
parse_or(&toks, &mut p, src)?;
if p != toks.len() {
return Err(format!("unexpected token in when clause: {src}"));
}
Ok(())
}
fn parse_or(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
parse_and(t, p, src)?;
while t.get(*p) == Some(&Tok::Op("||")) {
*p += 1;
parse_and(t, p, src)?;
}
Ok(())
}
fn parse_and(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
parse_unary(t, p, src)?;
while t.get(*p) == Some(&Tok::Op("&&")) {
*p += 1;
parse_unary(t, p, src)?;
}
Ok(())
}
fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
match t.get(*p) {
Some(Tok::Op("!")) => {
*p += 1;
parse_unary(t, p, src)
}
Some(Tok::Op("(")) => {
*p += 1;
parse_or(t, p, src)?;
if t.get(*p) != Some(&Tok::Op(")")) {
return Err(format!("missing ')' in when clause: {src}"));
}
*p += 1;
Ok(())
}
Some(Tok::Id(_)) => {
*p += 1;
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
*p += 1;
match t.get(*p) {
Some(Tok::Id(_) | Tok::Str) => *p += 1,
_ => return Err(format!("missing value after comparison in when clause: {src}")),
}
}
Ok(())
}
_ => Err(format!("expected a context key in when clause: {src}")),
}
}
#[cfg(test)]
mod tests {
use super::validate_when;
#[test]
fn accepts_the_client_grammar() {
for ok in [
"",
"editorFocus",
"editorLang == rust",
"editorLang != 'rust' && !readOnly",
"(a || b) && toolWindow.project.active",
"resourceScheme == file:/x",
] {
assert!(validate_when(ok).is_ok(), "{ok}");
}
for bad in ["a &&", "(a", "a == ", "== a", "a b", "a $ b", "'x", "!()"] {
assert!(validate_when(bad).is_err(), "{bad}");
}
}
}
+14
View File
@@ -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
+149
View File
@@ -0,0 +1,149 @@
//! `cargo bench -p ide-host`: the plugin runtime budgets, checked by `pnpm perf:rust` against
//! `perf/budgets.json` › rust (`plugins/…`).
//!
//! - `load_50`: start-up load of 50 installed plugins (20 with code, 10
//! languages with a syntax provider, 10 icon themes, 10 declarations only),
//! from the contribution index; no Wasm is instantiated.
//! - `cold_activation`: an idle-reclaimed plugin's next command: activation
//! from the precompiled component plus the call.
//! - `syntax_highlights_2k_lines`: an edit-path call (Solidity highlighting).
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use ide_core::LanguageId;
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Invocation, Limits};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for e in std::fs::read_dir(from).unwrap().flatten() {
let name = e.file_name();
if matches!(name.to_str(), Some("target" | "src" | "Cargo.lock" | ".gitignore")) {
continue;
}
let dst = to.join(&name);
if e.path().is_dir() { copy_dir(&e.path(), &dst) } else { std::fs::copy(e.path(), &dst).map(|_| ()).unwrap() }
}
}
/// A copy of `src` installed under a new id (and new language / theme ids).
fn variant(work: &Path, src: &Path, n: usize, rename: &[(&str, String)]) -> PathBuf {
let dir = work.join(format!("pkg{n}"));
copy_dir(src, &dir);
for file in ["plugin.toml", "languages/solidity.toml"] {
let path = dir.join(file);
if let Ok(mut text) = std::fs::read_to_string(&path) {
for (from, to) in rename {
text = text.replace(from, to);
}
std::fs::write(&path, text).unwrap();
}
}
dir
}
fn sink() -> Arc<dyn ide_host::plugins::PluginEventSink> {
Arc::new(|_: Option<&str>, _| {})
}
fn install_50(plugins: &Path, work: &Path) {
let host = PluginHost::new(plugins.to_path_buf(), sink(), Limits::default()).unwrap();
host.load_all();
let root = repo();
let mut n = 0;
let mut install = |src: PathBuf, rename: Vec<(&str, String)>| {
let pkg = variant(work, &src, n, &rename);
n += 1;
host.install_from_dir(&pkg, &Consent::default()).unwrap();
};
for i in 0..20 {
install(
root.join("crates/ide-plugin/tests/fixtures/hello"),
vec![("id = \"hello\"", format!("id = \"bench.hello{i}\""))],
);
}
for i in 0..10 {
install(
root.join("plugins/official/solidity"),
vec![
("openidea.solidity", format!("bench.solidity{i}")),
("id = \"solidity\"", format!("id = \"solidity{i}\"")),
("extensions = [\"sol\"]", format!("extensions = [\"sol{i}\"]")),
("on-language:solidity", format!("on-language:solidity{i}")),
("id = \"forge-", format!("id = \"f{i}-")),
("id = \"hardhat-", format!("id = \"h{i}-")),
],
);
}
for i in 0..10 {
install(
root.join("plugins/official/material-icons"),
vec![
("openidea.material-icons", format!("bench.icons{i}")),
("id = \"material\"", format!("id = \"material{i}\"")),
],
);
}
for i in 0..10 {
let dir = work.join(format!("static{i}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("plugin.toml"),
format!(
"id = \"bench.static{i}\"\nname = \"Static {i}\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.commands]]\nid = \"a\"\ntitle = \"Static {i}: A\"\nwhen = \"editorLang == rust\"\n"
),
)
.unwrap();
host.install_from_dir(&dir, &Consent::default()).unwrap();
}
}
fn bench(c: &mut Criterion) {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
install_50(&plugins, &tmp.path().join("work"));
let mut g = c.benchmark_group("plugins");
g.sample_size(20);
g.bench_function("load_50", |b| {
b.iter(|| {
let host = PluginHost::new(plugins.clone(), sink(), Limits::default()).unwrap();
host.load_all();
assert_eq!(host.instances(), 0, "start-up instantiates no Wasm");
black_box(host.list(None).plugins.len())
})
});
let limits = Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() };
let host = Arc::new(PluginHost::new(plugins.clone(), sink(), limits).unwrap());
host.load_all();
host.warm_component_cache();
g.bench_function("cold_activation", |b| {
b.iter(|| {
host.tick();
// Fails for want of an editor, after activating and running.
let r = host.run_command("bench.hello0", "word-count", None, Invocation::default());
black_box(r.is_err())
})
});
let provider = host.syntax_provider();
let lang = LanguageId::new("solidity0");
let unit = "contract Token {\n uint256 public total;\n mapping(address => uint256) balances;\n event Sent(address to, uint256 v);\n function send(address to, uint256 v) public returns (bool) {\n // move funds\n balances[to] += v;\n emit Sent(to, v);\n return true;\n }\n}\n";
let text = unit.repeat(2000 / unit.lines().count());
assert!(provider.highlights(&lang, &text).is_some());
g.bench_function("syntax_highlights_2k_lines", |b| {
b.iter(|| black_box(provider.highlights(&lang, &text).map(|t| t.len())))
});
g.finish();
}
criterion_group!(benches, bench);
criterion_main!(benches);
@@ -0,0 +1,624 @@
//! `openidea-plugin`: scaffolds, builds, checks and links OpenIDEA plugins.
//!
//! ```text
//! openidea-plugin new <dir> [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH]
//! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check`
//! openidea-plugin check [dir] # validate the package as the IDE would install it
//! openidea-plugin link [dir] # load the folder in the IDE without installing it
//! openidea-plugin unlink [dir]
//! openidea-plugin links # list linked folders
//! openidea-plugin keygen <key-file> # a publisher's Ed25519 key
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # <id>-<version>.oiplugin
//! openidea-plugin index <dir> --publisher ID --name NAME --key F # marketplace index.json
//! ```
//!
//! `link` writes `links.json` in the IDE's plugins directory
//! (`$OPENIDEA_PLUGINS_DIR`, or the app data directory); the IDE picks the
//! change up when Settings › Plugins is opened or refreshed. Linked plugins
//! are re-read from the folder, so `build` + refresh is the edit loop.
mod templates;
use ide_extension::{Manifest, validate_plugin_id};
use ide_host::plugins::PluginHost;
use ide_plugin::{Limits, PluginEngine};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::sync::Arc;
const USAGE: &str = "\
usage: openidea-plugin <command> [options]
commands:
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
create a plugin; T = command (default), theme, icon-theme, language
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
check [dir] validate the package as the IDE would install it
link [dir] load the folder in the IDE without installing it
unlink [dir] stop loading a linked folder
links list linked folders
keygen <key-file> create a publisher signing key (keep the file secret)
pack [dir] [--key F --publisher ID] [--out FILE]
write <id>-<version>.oiplugin, signed with the key
index <dir> --publisher ID --name NAME --key F [--out FILE]
write the marketplace index.json for the packages in dir
environment:
OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = match args.first().map(String::as_str) {
Some("new") => cmd_new(&args[1..]),
Some("build") => cmd_build(&args[1..]),
Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)),
Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)),
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
Some("links") => list_links(),
Some("keygen") => cmd_keygen(&args[1..]),
Some("pack") => cmd_pack(&args[1..]),
Some("index") => cmd_index(&args[1..]),
Some("--version" | "-V") => {
println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION);
Ok(())
}
Some("help" | "--help" | "-h") | None => {
println!("{USAGE}");
Ok(())
}
Some(other) => Err(format!("unknown command {other:?}\n\n{USAGE}")),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// Splits `args` into positionals and `--flag value` / `--switch` options.
struct Args {
positional: Vec<String>,
options: Vec<(String, Option<String>)>,
}
impl Args {
fn parse(args: &[String], switches: &[&str], valued: &[&str]) -> Result<Self, String> {
let mut out = Args { positional: Vec::new(), options: Vec::new() };
let mut it = args.iter();
while let Some(a) = it.next() {
if let Some(name) = a.strip_prefix("--") {
let (name, inline) = match name.split_once('=') {
Some((n, v)) => (n, Some(v.to_owned())),
None => (name, None),
};
if switches.contains(&name) && inline.is_none() {
out.options.push((name.into(), None));
} else if valued.contains(&name) {
let v = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("--{name} needs a value"))?;
out.options.push((name.into(), Some(v)));
} else {
return Err(format!("unknown option --{name}"));
}
} else {
out.positional.push(a.clone());
}
}
Ok(out)
}
fn value(&self, name: &str) -> Option<&str> {
self.options.iter().rev().find(|(n, _)| n == name).and_then(|(_, v)| v.as_deref())
}
fn has(&self, name: &str) -> bool {
self.options.iter().any(|(n, _)| n == name)
}
fn dir(&self) -> Result<PathBuf, String> {
match self.positional.as_slice() {
[] => Ok(PathBuf::from(".")),
[d] => Ok(PathBuf::from(d)),
_ => Err("expected at most one directory".into()),
}
}
}
fn dir_arg(args: &[String]) -> Result<PathBuf, String> {
Args::parse(args, &[], &[])?.dir()
}
fn canonical(dir: &Path) -> Result<PathBuf, String> {
dunce::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display()))
}
// ---------------------------------------------------------------- new
fn cmd_new(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["template", "id", "name", "sdk"])?;
let [dir] = a.positional.as_slice() else {
return Err("usage: openidea-plugin new <dir> [--template T] [--id ID] [--name NAME]".into());
};
let dir = PathBuf::from(dir);
let template = templates::Template::parse(a.value("template").unwrap_or("command"))?;
let base = dir.file_name().and_then(|n| n.to_str()).ok_or("the directory needs a name")?;
let id = match a.value("id") {
Some(id) => id.to_owned(),
None => slugify(base),
};
validate_plugin_id(&id)?;
let name = match a.value("name") {
Some(n) => n.to_owned(),
None => title_case(id.rsplit('.').next().unwrap_or(&id)),
};
if name.trim().is_empty() || name.contains(['"', '\\', '\n']) {
return Err("--name must not be empty or contain quotes, backslashes or newlines".into());
}
if dir.exists() && std::fs::read_dir(&dir).map_err(|e| e.to_string())?.next().is_some() {
return Err(format!("{} already exists and is not empty", dir.display()));
}
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let dir = canonical(&dir)?;
let sdk = match template {
templates::Template::Command => Some(sdk_path(a.value("sdk"), &dir)?),
_ => None,
};
let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() };
let files = templates::render(template, &vars);
for (rel, content) in &files {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(&path, content).map_err(|e| format!("{}: {e}", path.display()))?;
}
// Generated packages must be valid as written.
Manifest::load(&dir).map_err(|e| format!("internal error: the template is invalid: {e}"))?;
println!("created {} ({id}) in {}", template.label(), dir.display());
let shown = std::env::current_dir().ok().and_then(|cwd| dir.strip_prefix(cwd).ok().map(Path::to_path_buf));
let shown = shown.as_deref().unwrap_or(&dir).display().to_string();
println!();
println!("next:");
if template == templates::Template::Command {
println!(" cargo test --manifest-path {shown}/Cargo.toml # unit tests against the mock host");
println!(" openidea-plugin build {shown}");
} else {
println!(" openidea-plugin check {shown}");
}
println!(" openidea-plugin link {shown} # then Settings › Plugins");
Ok(())
}
/// `My Cool_Plugin` → `my-cool-plugin`.
fn slugify(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
match c {
'a'..='z' | '0'..='9' | '.' => out.push(c),
'A'..='Z' => out.push(c.to_ascii_lowercase()),
_ if !out.ends_with('-') && !out.is_empty() => out.push('-'),
_ => {}
}
}
out.trim_matches(['-', '.']).to_owned()
}
/// `my-cool_plugin` → `My Cool Plugin`.
fn title_case(s: &str) -> String {
s.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
c.next().map(|f| f.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
}
/// The SDK dependency path written into a new plugin's Cargo.toml: `--sdk`,
/// or the SDK of the source tree this tool was built from.
fn sdk_path(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
let sdk = match explicit {
Some(p) => PathBuf::from(p),
None => PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin")),
};
let sdk = dunce::canonicalize(&sdk).map_err(|_| {
format!("the plugin SDK was not found at {}; pass --sdk <path to sdk/rust/openidea-plugin>", sdk.display())
})?;
if !sdk.join("Cargo.toml").is_file() {
return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display()));
}
let path = relative(plugin_dir, &sdk).unwrap_or(sdk);
Ok(path.to_string_lossy().replace('\\', "/"))
}
/// `to` relative to `from` when they share a root (keeps packages in a repo movable).
fn relative(from: &Path, to: &Path) -> Option<PathBuf> {
let (f, t): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
let common = f.iter().zip(&t).take_while(|(a, b)| a == b).count();
if common <= 1 {
return None;
}
let mut out = PathBuf::new();
for _ in common..f.len() {
out.push("..");
}
for c in &t[common..] {
out.push(c);
}
Some(out)
}
// ---------------------------------------------------------------- build / check
fn cmd_build(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &["debug"], &[])?;
let dir = canonical(&a.dir()?)?;
let manifest = Manifest::load(&dir)?;
let cargo_toml = dir.join("Cargo.toml");
if !cargo_toml.is_file() {
if manifest.wasm.is_some() {
return Err(format!(
"{} has no Cargo.toml: build the component with your toolchain, then run `check`",
dir.display()
));
}
println!("{} has no code to build", manifest.id);
return check(&dir);
}
let wasm_rel = manifest.wasm.clone().ok_or("plugin.toml has no `wasm = \"dist/plugin.wasm\"`")?;
let crate_name = crate_name(&cargo_toml)?;
ensure_wasm_target();
let release = !a.has("debug");
let target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| dir.join("target"));
let mut cmd = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
cmd.arg("build").arg("--manifest-path").arg(&cargo_toml).args(["--target", "wasm32-wasip2"]);
if release {
cmd.arg("--release");
}
cmd.env("CARGO_TARGET_DIR", &target_dir);
println!("building {crate_name} for wasm32-wasip2 ({})", if release { "release" } else { "debug" });
let status = cmd.status().map_err(|e| format!("cannot run cargo: {e}"))?;
if !status.success() {
return Err("cargo build failed".into());
}
let built = target_dir
.join("wasm32-wasip2")
.join(if release { "release" } else { "debug" })
.join(format!("{}.wasm", crate_name.replace('-', "_")));
if !built.is_file() {
return Err(format!("{} was not produced: is `crate-type = [\"cdylib\"]` set in Cargo.toml?", built.display()));
}
let out = dir.join(&wasm_rel);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::copy(&built, &out).map_err(|e| format!("{}: {e}", out.display()))?;
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
println!(" -> {} ({})", wasm_rel.display(), human_size(size));
check(&dir)
}
fn crate_name(cargo_toml: &Path) -> Result<String, String> {
let src = std::fs::read_to_string(cargo_toml).map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
let doc: toml::Table = src.parse().map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
let package = doc.get("package").and_then(|p| p.as_table());
let lib_name = doc.get("lib").and_then(|l| l.get("name")).and_then(|n| n.as_str());
lib_name
.or_else(|| package.and_then(|p| p.get("name")).and_then(|n| n.as_str()))
.map(str::to_owned)
.ok_or_else(|| format!("{}: no [package] name", cargo_toml.display()))
}
/// Installs the target through rustup when it is missing (no-op without rustup).
fn ensure_wasm_target() {
let Ok(out) = Command::new("rustup").args(["target", "list", "--installed"]).output() else { return };
if String::from_utf8_lossy(&out.stdout).lines().any(|l| l.trim() == "wasm32-wasip2") {
return;
}
println!("installing the wasm32-wasip2 target");
let _ = Command::new("rustup").args(["target", "add", "wasm32-wasip2"]).status();
}
/// Validates `dir` exactly as Install from Disk would, and checks the
/// component imports only the plugin API.
fn check(dir: &Path) -> Result<(), String> {
let dir = canonical(dir)?;
let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id()));
let result = (|| {
let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.map_err(|e| e.to_string())?;
let preview = host.preview_install(&dir).map_err(|e| e.to_string())?;
let manifest = Manifest::load(&dir)?;
println!("{} {} — {}", preview.id, preview.version, preview.name);
if manifest.legacy {
println!(" note: plugin.toml uses the 0.1 format (`openidea-api`); see plugins/README.md to move to 0.2");
}
if let Some(wasm) = &manifest.wasm {
let engine = PluginEngine::new()?;
let component = engine.compile(&dir.join(wasm))?;
let built_for = engine.check_component(&component)?;
let size = std::fs::metadata(dir.join(wasm)).map(|m| m.len()).unwrap_or(0);
let api = built_for.map(|v| format!(", plugin API {}.{}", v.major, v.minor)).unwrap_or_default();
println!(" code: {} ({}{api})", wasm.display(), human_size(size));
} else {
println!(" code: none (contributions only)");
}
if !manifest.activation.is_empty() {
let events: Vec<String> = manifest.activation.iter().map(ToString::to_string).collect();
println!(" activation: {}", events.join(", "));
}
for c in &preview.contributions {
println!(" contributes {} × {}", c.count, c.point);
}
for c in &preview.capabilities {
let kind = if c.optional { "optional" } else { "required" };
println!(" capability {} ({kind}, {:?} risk): {}", c.id, c.risk, c.description);
}
for w in &preview.warnings {
println!(" warning: {w}");
}
println!("ok");
Ok(())
})();
let _ = std::fs::remove_dir_all(&scratch);
result
}
fn human_size(bytes: u64) -> String {
match bytes {
b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / (1 << 20) as f64),
b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0),
b => format!("{b} B"),
}
}
// ---------------------------------------------------------------- link
fn plugins_dir() -> Result<PathBuf, String> {
if let Some(dir) = std::env::var_os("OPENIDEA_PLUGINS_DIR") {
return Ok(PathBuf::from(dir));
}
dirs::data_dir()
.map(|d| d.join("ai.l0ng.openidea").join("plugins"))
.ok_or_else(|| "cannot find the app data directory; set OPENIDEA_PLUGINS_DIR".into())
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct Links {
#[serde(default)]
paths: Vec<PathBuf>,
}
fn read_links(file: &Path) -> Result<Links, String> {
match std::fs::read_to_string(file) {
Ok(s) => serde_json::from_str(&s).map_err(|e| format!("{}: {e}", file.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Links::default()),
Err(e) => Err(format!("{}: {e}", file.display())),
}
}
fn link(dir: &Path, add: bool) -> Result<(), String> {
let plugins = plugins_dir()?;
let file = plugins.join("links.json");
let mut links = read_links(&file)?;
let dir = if add {
let dir = canonical(dir)?;
let m = Manifest::load(&dir)?;
if let Some(wasm) = &m.wasm
&& !dir.join(wasm).is_file()
{
println!("note: {} does not exist yet; run `openidea-plugin build` first", wasm.display());
}
if links.paths.contains(&dir) {
println!("{} is already linked", dir.display());
return Ok(());
}
println!("linked {} ({})", m.id, dir.display());
links.paths.push(dir.clone());
dir
} else {
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
let before = links.paths.len();
links.paths.retain(|p| p != &dir);
if links.paths.len() == before {
return Err(format!("{} is not linked", dir.display()));
}
println!("unlinked {}", dir.display());
dir
};
let _ = dir;
std::fs::create_dir_all(&plugins).map_err(|e| format!("{}: {e}", plugins.display()))?;
let json = serde_json::to_string_pretty(&links).map_err(|e| e.to_string())?;
let tmp = file.with_extension("json.tmp");
std::fs::write(&tmp, json)
.and_then(|()| std::fs::rename(&tmp, &file))
.map_err(|e| format!("{}: {e}", file.display()))?;
println!("open Settings › Plugins in OpenIDEA to load the change");
Ok(())
}
fn list_links() -> Result<(), String> {
let links = read_links(&plugins_dir()?.join("links.json"))?;
if links.paths.is_empty() {
println!("no linked plugins");
}
for p in links.paths {
let id = Manifest::load(&p).map(|m| m.id).unwrap_or_else(|e| format!("invalid: {e}"));
println!("{id}\t{}", p.display());
}
Ok(())
}
// ---------------------------------------------------------------- distribution
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PublisherKey};
fn read_key(path: &str) -> Result<PublisherKey, String> {
PublisherKey::from_base64(&std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?)
}
fn cmd_keygen(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &[])?;
let [file] = a.positional.as_slice() else { return Err("usage: openidea-plugin keygen <key-file>".into()) };
if Path::new(file).exists() {
return Err(format!("{file} exists; not overwriting a key"));
}
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).map_err(|e| format!("random: {e}"))?;
let key = PublisherKey::from_seed(seed);
std::fs::write(file, key.seed_base64()).map_err(|e| format!("{file}: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600));
}
println!("public key: {}", key.public_base64());
println!("register it with your publisher id in the marketplace index; keep {file} secret");
Ok(())
}
/// A scratch host to list a package's files as the IDE would.
fn with_scratch_host<T>(f: impl FnOnce(&PluginHost) -> Result<T, String>) -> Result<T, String> {
let scratch = std::env::temp_dir().join(format!("openidea-plugin-pack-{}", std::process::id()));
let result = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.map_err(|e| e.to_string())
.and_then(|h| f(&h));
let _ = std::fs::remove_dir_all(&scratch);
result
}
fn cmd_pack(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["key", "publisher", "out"])?;
let dir = canonical(&a.dir()?)?;
check(&dir)?;
let manifest = Manifest::load(&dir)?;
let mut files = with_scratch_host(|h| h.package_files_of(&dir).map_err(|e| e.to_string()))?;
let digest = package::package_digest(&dir, &files).map_err(|e| e.to_string())?;
let signature = match (a.value("key"), a.value("publisher")) {
(Some(key), Some(publisher)) => {
if !manifest.id.starts_with(&format!("{publisher}.")) {
return Err(format!("publisher {publisher:?} may only sign plugins named {publisher}.*"));
}
Some(read_key(key)?.sign(publisher, &manifest.id, &manifest.version, &digest))
}
(None, None) => None,
_ => return Err("--key and --publisher go together".into()),
};
for lic in ["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "COPYING", "README.md"] {
if dir.join(lic).is_file() {
files.push(PathBuf::from(lic));
}
}
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| {
PathBuf::from(format!("{}-{}.{}", manifest.id, manifest.version, package::PACKAGE_EXTENSION))
});
package::pack(&dir, &files, &out, signature.as_ref())?;
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
let signed = signature.as_ref().map_or("unsigned".to_owned(), |s| format!("signed by {}", s.publisher));
println!("-> {} ({}, {signed})", out.display(), human_size(size));
Ok(())
}
fn cmd_index(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?;
let dir = canonical(&a.dir()?)?;
let publisher = a.value("publisher").ok_or("--publisher is required")?;
let name = a.value("name").unwrap_or(publisher);
let key = read_key(a.value("key").ok_or("--key is required")?)?;
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json"));
// Revocations and other publishers of an existing index are kept.
let mut index = match std::fs::read_to_string(&out) {
Ok(text) => Index::parse(&text)?,
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
};
// Earlier keys of the publisher stay listed (packages they signed remain valid until revoked).
let current = key.public_base64();
let earlier: Vec<KeyEntry> = index
.publishers
.iter()
.filter(|p| p.id == publisher)
.flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned())
.collect();
index.publishers.retain(|p| p.id != publisher);
let keys: Vec<KeyEntry> = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect();
index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
index.plugins.clear();
let mut packages: Vec<PathBuf> = std::fs::read_dir(&dir)
.map_err(|e| e.to_string())?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION))
.collect();
packages.sort();
for path in packages {
let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
let unpacked = package::unpack(&path, &scratch)?;
let entry = (|| {
let m = Manifest::load(&scratch)?;
let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?;
let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?;
let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?;
sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?;
Ok::<_, String>(IndexEntry {
id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
authors: m.authors.clone(),
publisher: sig.publisher.clone(),
download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
digest,
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
})
})();
let _ = std::fs::remove_dir_all(&scratch);
let entry = entry?;
println!(" {} {}", entry.id, entry.version);
index.plugins.push(entry);
}
let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?;
println!("-> {} ({} plugins)", out.display(), index.plugins.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names() {
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
assert_eq!(slugify("acme.Tools"), "acme.tools");
assert_eq!(title_case("material-icons"), "Material Icons");
assert_eq!(
relative(Path::new("/a/b/plugins/x"), Path::new("/a/b/sdk/rust/p")).unwrap(),
PathBuf::from("../../sdk/rust/p")
);
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
}
#[test]
fn every_template_is_a_valid_package() {
for t in templates::Template::ALL {
let dir =
std::env::temp_dir().join(format!("openidea-plugin-template-{}-{}", t.label(), std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("../sdk") };
for (rel, content) in templates::render(t, &vars) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
let m = Manifest::load(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
assert_eq!(m.id, "acme.sample");
if m.wasm.is_none() {
check(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
}
std::fs::remove_dir_all(&dir).unwrap();
}
}
}
@@ -0,0 +1,278 @@
//! The files of `openidea-plugin new`. Placeholders: `__ID__` (plugin id),
//! `__SLUG__` (last id segment), `__NAME__`, `__CRATE__`, `__SDK__`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Template {
Command,
Theme,
IconTheme,
Language,
}
impl Template {
pub const ALL: [Template; 4] = [Self::Command, Self::Theme, Self::IconTheme, Self::Language];
pub fn parse(s: &str) -> Result<Self, String> {
Self::ALL
.into_iter()
.find(|t| t.label() == s)
.ok_or_else(|| format!("unknown template {s:?} (command, theme, icon-theme, language)"))
}
pub fn label(self) -> &'static str {
match self {
Self::Command => "command",
Self::Theme => "theme",
Self::IconTheme => "icon-theme",
Self::Language => "language",
}
}
}
pub struct Vars<'a> {
pub id: &'a str,
pub name: &'a str,
pub sdk: Option<&'a str>,
}
/// `(relative path, contents)` of every file of the template.
pub fn render(template: Template, vars: &Vars) -> Vec<(String, String)> {
let slug = vars.id.rsplit('.').next().unwrap_or(vars.id).to_owned();
let crate_name = format!("openidea-plugin-{}", vars.id.replace('.', "-"));
let fill = |s: &str| {
s.replace("__ID__", vars.id)
.replace("__SLUG__", &slug)
.replace("__NAME__", vars.name)
.replace("__CRATE__", &crate_name)
.replace("__SDK__", vars.sdk.unwrap_or(""))
// The theme files come from the Harbor Night plugin.
.replace("harbor-night", &slug)
.replace("Harbor Night", vars.name)
};
let files: Vec<(String, &str)> = match template {
Template::Command => vec![
("plugin.toml".into(), COMMAND_MANIFEST),
("Cargo.toml".into(), COMMAND_CARGO),
("src/lib.rs".into(), COMMAND_LIB),
(".gitignore".into(), "/target\n"),
("README.md".into(), COMMAND_README),
],
Template::Theme => vec![
("plugin.toml".into(), THEME_MANIFEST),
(format!("themes/{slug}.theme.json"), THEME_UI),
(format!("themes/{slug}.scheme.json"), THEME_SCHEME),
],
Template::IconTheme => vec![
("plugin.toml".into(), ICON_MANIFEST),
("icons/theme.json".into(), ICON_THEME),
("icons/file.svg".into(), ICON_FILE),
("icons/folder.svg".into(), ICON_FOLDER),
("icons/folder-open.svg".into(), ICON_FOLDER_OPEN),
("icons/markdown.svg".into(), ICON_MARKDOWN),
],
Template::Language => {
vec![("plugin.toml".into(), LANGUAGE_MANIFEST), (format!("languages/{slug}.toml"), LANGUAGE_DEF)]
}
};
files.into_iter().map(|(path, content)| (path, fill(content))).collect()
}
const COMMAND_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "Adds two sample commands."
authors = []
engines.openidea = "^1.0"
# Built by `openidea-plugin build`. The code is loaded the first time one of
# its commands runs; the commands below show in Find Action before that.
wasm = "dist/plugin.wasm"
# What the code may do. The user approves these when installing.
[capabilities]
required = ["editor.read"]
[[contributes.commands]]
id = "say-hello"
title = "__NAME__: Say Hello"
category = "__NAME__"
[[contributes.commands]]
id = "line-count"
title = "__NAME__: Line Count of Active File"
category = "__NAME__"
"#;
const COMMAND_CARGO: &str = r#"[package]
name = "__CRATE__"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "__SDK__" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
"#;
const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin.
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
#[derive(Default)]
pub struct Main {
greetings: u32,
}
impl Plugin for Main {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
match id {
"say-hello" => {
// Instance state lasts until the plugin is idle for a while;
// keep what must survive in `host::storage`.
self.greetings += 1;
let body = format!("Hello #{} from __NAME__!", self.greetings);
host::notify(NotificationLevel::Info, "__NAME__", &body);
Ok(None)
}
"line-count" => {
let editor = ctx.editor().ok_or("no file is open")?;
let text = editor.document()?.text;
Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path())))
}
_ => Err(format!("unknown command {id}")),
}
}
}
register_plugin!(Main);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
#[test]
fn says_hello() {
let host = MockHost::install();
let mut plugin = Main::default();
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
assert_eq!(host.notifications()[1].body, "Hello #2 from __NAME__!");
}
#[test]
fn counts_lines() {
let host = MockHost::install();
host.open_editor("/project/src/main.rs", "rust", "fn main() {\n}\n", &[]);
let out = Main::default().run_command("line-count", &host.invocation(), None).unwrap();
assert_eq!(out.as_deref(), Some("2 lines in /project/src/main.rs"));
}
}
"#;
const COMMAND_README: &str = r#"# __NAME__
An OpenIDEA plugin.
```sh
cargo test # logic, against the SDK's mock host
openidea-plugin build # → dist/plugin.wasm, validated like an install
openidea-plugin link # load this folder in the IDE (Settings › Plugins)
```
`plugin.toml` declares the commands, so they appear in Find Action before the
code is loaded, and the capabilities the code needs. `src/lib.rs` implements
them with the `openidea-plugin` SDK.
"#;
const THEME_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "A dark theme."
authors = []
engines.openidea = "^1.0"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
[[contributes.themes]]
path = "themes/__SLUG__.theme.json"
editor-scheme = "themes/__SLUG__.scheme.json"
"#;
const THEME_UI: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.theme.json");
const THEME_SCHEME: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.scheme.json");
const ICON_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "File and folder icons."
authors = []
engines.openidea = "^1.0"
# Apply it in Settings › Plugins › __NAME__. SVGs are sanitised on install:
# no scripts, event handlers, external references or entities.
[[contributes.iconThemes]]
id = "__SLUG__"
label = "__NAME__"
path = "icons/theme.json"
"#;
const ICON_THEME: &str = r#"{
"iconDefinitions": {
"file": "icons/file.svg",
"folder": "icons/folder.svg",
"folder-open": "icons/folder-open.svg",
"markdown": "icons/markdown.svg"
},
"file": "file",
"folder": "folder",
"folderExpanded": "folder-open",
"fileExtensions": { "md": "markdown", "markdown": "markdown" },
"fileNames": { "readme": "markdown" },
"folderNames": {}
}
"#;
const ICON_FILE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1.5h5.5L13 5v9.5H4z"/><path fill="#cfd8dc" d="M9.5 1.5V5H13z"/></svg>
"##;
const ICON_FOLDER: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6.5v9h-13z"/></svg>
"##;
const ICON_FOLDER_OPEN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6v2h-10l-2 7h-.5z"/><path fill="#b0bec5" d="M4 7.5h11.5l-2 6H2z"/></svg>
"##;
const ICON_MARKDOWN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="3.5" width="14" height="9" rx="1.5" fill="#42a5f5"/><path fill="#fff" d="M3 10.5v-5h1.5L6 7.5l1.5-2H9v5H7.5V8L6 10 4.5 8v2.5zm8 0L9.5 8h1V5.5H12V8h1z"/></svg>
"##;
const LANGUAGE_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "Language support for .__SLUG__ files: detection, comments and indentation."
authors = []
engines.openidea = "^1.0"
# Same format as languages/<id>.toml in the IDE repository.
[[contributes.languages]]
path = "languages/__SLUG__.toml"
"#;
const LANGUAGE_DEF: &str = r##"id = "__SLUG__"
name = "__NAME__"
extensions = ["__SLUG__"]
line-comment = "#"
indent = { tab-size = 2, insert-spaces = true }
# Borrow an existing CodeMirror mode for instant highlighting.
ui-mode = "Properties files"
"##;
+30 -1
View File
@@ -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");
+109 -49
View File
@@ -2,26 +2,48 @@
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::PrettierSettings;
use ide_core::settings::{ExternalFormatter, LanguagesSettings};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_language::LanguageConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier
/// settings (languages, preference, on save) apply to both.
fn external_tool(file: &Path) -> &'static str {
let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
if oxfmt { "oxfmt" } else { "prettier" }
}
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
config.is_some_and(|c| c.tool == "prettier")
&& settings.languages.iter().any(|l| l == language)
&& (settings.prefer || !lsp)
/// The external formatter of a file, for a language with an external formatter.
/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt
/// applies when it is enabled for the language and either an oxfmt
/// configuration is found above the file or oxfmt is the selected provider;
/// else Prettier, when it is enabled. Turning Prettier off for a language
/// leaves it to the language server, not to oxfmt.
fn external_tool(
forced: Option<ExternalFormatter>,
language: Option<&LanguageConfig>,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
use ExternalFormatter::{Oxfmt, Prettier};
let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else {
return match forced {
Some(tool) => Err(IdeError::invalid(format!(
"{} cannot format {} files",
formatter::display_name(tool.name()),
language.map_or("these", |l| l.name.as_str())
))),
None => Ok(None),
};
};
if forced.is_some() {
return Ok(forced);
}
let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str());
let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) {
Some(Oxfmt)
} else {
enabled(Prettier).then_some(Prettier)
})
}
pub async fn format(
@@ -34,29 +56,33 @@ pub async fn format(
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let prettier = &settings.languages.prettier;
let language = project.languages.get(&snap.language);
let config = language.as_ref().and_then(|l| l.formatter.as_ref());
let candidate =
config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str());
let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false };
if !external_selected(config, snap.language.as_str(), prettier, lsp) {
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
if let Some(tool) = selected
&& options.tool.is_none()
&& !settings.languages.formatter(tool).prefer
&& project.lsp.can_format(doc).await?
{
selected = None;
}
let Some(selected) = selected else {
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
return project.lsp.format(doc, range, options).await;
}
if options.on_save && !prettier.run_on_save {
};
let tool_settings = settings.languages.formatter(selected);
if options.on_save && !tool_settings.run_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let config = config.unwrap().clone();
let tool = external_tool(&snap.path);
let config = language.and_then(|l| l.formatter).expect("external_tool selects languages with a formatter");
let tool = selected.name();
let name = formatter::display_name(tool);
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier");
let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path);
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
@@ -94,42 +120,76 @@ pub async fn format(
#[cfg(test)]
mod tests {
use super::*;
use ExternalFormatter::{Oxfmt, Prettier};
use ide_core::LanguageId;
use ide_language::LanguageRegistry;
#[test]
fn language_enablement_and_preference_preserve_lsp_formatters() {
let languages = ide_language::LanguageRegistry::load(None).unwrap();
let mut settings = PrettierSettings::default();
for lang in languages.all() {
for lsp in [false, true] {
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
assert_eq!(
external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp),
configured && !lsp,
"{}",
lang.id
);
}
}
let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap();
settings.prefer = true;
assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true));
settings.languages.clear();
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
assert!(!external_selected(None, "rust", &settings, false));
fn tool(
forced: Option<ExternalFormatter>,
language: &str,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
let language = LanguageRegistry::load(None).unwrap().get(&LanguageId::new(language));
external_tool(forced, language.as_ref(), file, settings)
}
#[test]
fn oxfmt_config_selects_oxfmt_over_prettier() {
fn default_settings_give_every_external_formatter_language_to_prettier() {
let settings = LanguagesSettings::default();
let file = Path::new("/project/file");
for lang in LanguageRegistry::load(None).unwrap().all() {
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
let selected = external_tool(None, Some(&lang), file, &settings).unwrap();
assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id);
}
assert_eq!(external_tool(None, None, file, &settings).unwrap(), None);
}
#[test]
fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("web/src")).unwrap();
let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts"));
assert_eq!(external_tool(&web), "prettier");
let mut settings = LanguagesSettings::default();
assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Prettier));
for config in OXFMT_CONFIGS {
std::fs::write(root.join("web").join(config), "{}").unwrap();
assert_eq!(external_tool(&web), "oxfmt", "{config}");
assert_eq!(external_tool(&api), "prettier", "{config}");
assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Oxfmt), "{config}");
assert_eq!(tool(None, "typescript", &api, &settings).unwrap(), Some(Prettier), "{config}");
std::fs::remove_file(root.join("web").join(config)).unwrap();
}
std::fs::write(root.join("web/.oxfmtrc.json"), "{}").unwrap();
settings.oxfmt.languages.retain(|l| l != "markdown");
assert_eq!(tool(None, "markdown", &web, &settings).unwrap(), Some(Prettier));
assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Oxfmt));
}
#[test]
fn oxfmt_provider_selects_oxfmt_without_a_config_and_prettier_off_leaves_the_language_server() {
let file = Path::new("/project/src/app.ts");
let mut settings = LanguagesSettings::default();
settings.oxfmt.prefer = true;
assert_eq!(tool(None, "typescript", file, &settings).unwrap(), Some(Oxfmt));
settings.oxfmt.prefer = false;
settings.prettier.languages.clear();
assert_eq!(tool(None, "typescript", file, &settings).unwrap(), None);
settings.oxfmt.prefer = true;
settings.oxfmt.languages.clear();
assert_eq!(tool(None, "typescript", file, &settings).unwrap(), None);
}
#[test]
fn forced_tool_runs_that_formatter_or_reports_why_not() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".oxfmtrc.json"), "{}").unwrap();
let file = dir.path().join("app.ts");
let mut settings = LanguagesSettings::default();
settings.oxfmt.languages.clear();
assert_eq!(tool(Some(Prettier), "typescript", &file, &settings).unwrap(), Some(Prettier));
assert_eq!(tool(Some(Oxfmt), "typescript", &file, &settings).unwrap(), Some(Oxfmt));
let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err();
assert_eq!(rust.message, "oxfmt cannot format Rust files");
}
}
+13 -1
View File
@@ -1,6 +1,6 @@
//! The IDE backend without a UI toolkit: open projects per window, terminals,
//! processes, settings and the remote services. The desktop shell (`src-tauri`)
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! wraps one [`Host`] and adds windows and menus; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod data_dirs;
@@ -10,6 +10,7 @@ mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
pub mod plugins;
mod project;
mod proxy;
mod requests;
@@ -133,6 +134,17 @@ impl Host {
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
}
/// Plugin languages or the servers they may start changed (they are part
/// of [`Self::languages`] and every project's fork): open documents are
/// detected again and language servers follow their new configuration.
pub fn plugin_languages_changed(&self) {
for project in self.projects() {
project.documents.redetect_languages();
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.reconfigure().await });
}
}
/// Every open project (all windows).
pub fn projects(&self) -> Vec<Arc<Project>> {
self.projects.read().values().cloned().collect()
+8
View File
@@ -23,6 +23,13 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
Ok(providers)
}
/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the
/// built-in's driver and arguments), or returns it to auto-detection with `None`.
pub fn set_command(host: &Host, window: &str, id: String, command: Option<String>) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.set_builtin_command(&id, command)
}
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
@@ -187,6 +194,7 @@ pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -
pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
d.add("agent_registry_install", |c| async move {
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
+42 -3
View File
@@ -74,6 +74,18 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
model
}
/// `model` building with the Maven profiles of the last sync (`-P`), as Maven runs from
/// the tool window do.
fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
let profiles = ide_build::maven_profiles(cache);
if let (Some(m), false) = (&mut model.maven, profiles.is_empty()) {
for p in &mut m.projects {
p.options.push(format!("-P{}", profiles.join(",")));
}
}
model
}
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
host.require_trust(window)?;
@@ -98,6 +110,12 @@ pub async fn build_sync(
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let bridge_profiles = profiles.clone();
// What the Java language server and Build Project resolve the project with from now on.
let profiles_changed = tool == BuildTool::Maven
&& ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| {
tracing::warn!("Maven profiles not saved: {e}");
false
});
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
@@ -132,6 +150,13 @@ pub async fn build_sync(
.map_err(join_err);
host.end_request(request);
let model = result??;
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories());
}
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
@@ -150,8 +175,21 @@ pub async fn build_sync(
}
};
let lsp = project.lsp.clone();
let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect();
// With a module of a reactor unlinked, jdtls imports the other modules one by one and
// not the reactor: naming the reactor here would import it again, all modules included.
let imported = match tool {
BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1,
BuildTool::Gradle => None,
};
let uris = match imported {
Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(),
None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(),
};
tokio::spawn(async move {
if profiles_changed {
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
lsp.reconfigure().await;
}
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
@@ -257,7 +295,8 @@ pub async fn build_run(
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?;
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = compile::plan(member, &model, kind, file.as_deref())?;
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
@@ -267,7 +306,7 @@ pub async fn build_run(
plan.title = workspace.name;
plan
} else {
compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?
compile::plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), kind, file.as_deref())?
};
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
+422
View File
@@ -0,0 +1,422 @@
//! Database commands shared by the desktop shell's IPC commands and the
//! remote server ([`crate::rpc`]). `db_export_file` stays in the desktop
//! shell: its path comes from a local save dialog, so the export must land
//! on the user's machine, not the side the project runs on.
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
use ide_db::split::SqlStatement;
use ide_db::{
CompletionModel, DataSource, Databases, DbObjectKind, DbRowEdit, HistoryEntry, PlanNode, QueryResult,
SchemaObjects, SortKey, TableDetails, TableInfo, TablePage,
};
use serde::{Deserialize, Serialize};
use specta::Type;
/// One statement of a console run.
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbStatementOutcome")]
pub struct StatementOutcome {
pub statement: SqlStatement,
pub result: Option<QueryResult>,
pub error: Option<String>,
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("db_sources", |c| async move { sources(&c.host, &c.window) });
d.add("db_save_sources", |c| async move {
let sources = c.arg("sources")?;
save_sources(&c.host, &c.window, sources).await
});
d.add("db_set_password", |c| async move {
let (id, password, save) =
(c.arg::<String>("id")?, c.arg::<Option<String>>("password")?, c.arg::<bool>("save")?);
set_password(&c.host, &c.window, &id, password, save).await
});
d.add("db_test", |c| async move {
let (source, password, ssh_secret) = (
c.arg::<DataSource>("source")?,
c.arg::<Option<String>>("password")?,
c.arg::<Option<String>>("sshSecret")?,
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
});
d.add("db_trust_ssh_host", |c| async move {
let id = c.arg::<String>("id")?;
trust_ssh_host(&c.host, &c.window, &id).await
});
d.add("db_connected", |c| async move { connected(&c.host, &c.window) });
d.add("db_connect", |c| async move {
let id = c.arg::<String>("id")?;
connect(&c.host, &c.window, &id).await
});
d.add("db_set_manual_commit", |c| async move {
let (id, manual) = (c.arg::<String>("id")?, c.arg::<bool>("manual")?);
set_manual_commit(&c.host, &c.window, &id, manual).await
});
d.add("db_finish_transaction", |c| async move {
let (id, commit) = (c.arg::<String>("id")?, c.arg::<bool>("commit")?);
finish_transaction(&c.host, &c.window, &id, commit).await
});
d.add("db_disconnect", |c| async move {
let id = c.arg::<String>("id")?;
disconnect(&c.host, &c.window, &id).await
});
d.add("db_databases", |c| async move {
let id = c.arg::<String>("id")?;
databases(&c.host, &c.window, &id).await
});
d.add("db_schemas", |c| async move {
let id = c.arg::<String>("id")?;
schemas(&c.host, &c.window, &id).await
});
d.add("db_tables", |c| async move {
let (id, schema) = (c.arg::<String>("id")?, c.arg::<String>("schema")?);
tables(&c.host, &c.window, &id, &schema).await
});
d.add("db_table_details", |c| async move {
let (id, schema, table) = (c.arg::<String>("id")?, c.arg::<String>("schema")?, c.arg::<String>("table")?);
table_details(&c.host, &c.window, &id, &schema, &table).await
});
d.add("db_ddl", |c| async move {
let (id, schema, table) = (c.arg::<String>("id")?, c.arg::<String>("schema")?, c.arg::<String>("table")?);
ddl(&c.host, &c.window, &id, &schema, &table).await
});
d.add("db_schema_objects", |c| async move {
let (id, schema) = (c.arg::<String>("id")?, c.arg::<String>("schema")?);
schema_objects(&c.host, &c.window, &id, &schema).await
});
d.add("db_object_ddl", |c| async move {
let (id, schema, kind, name) = (
c.arg::<String>("id")?,
c.arg::<String>("schema")?,
c.arg::<DbObjectKind>("kind")?,
c.arg::<String>("name")?,
);
object_ddl(&c.host, &c.window, &id, &schema, kind, &name).await
});
d.add("db_explain", |c| async move {
let (request, id, statement) =
(c.arg::<RequestId>("request")?, c.arg::<String>("id")?, c.arg::<String>("statement")?);
explain(&c.host, &c.window, request, &id, &statement).await
});
d.add("db_history", |c| async move {
let id = c.arg::<String>("id")?;
history(&c.host, &c.window, &id).await
});
d.add("db_remove_history", |c| async move {
let (id, texts) = (c.arg::<String>("id")?, c.arg::<Option<Vec<String>>>("texts")?);
remove_history(&c.host, &c.window, &id, texts).await
});
d.add("db_completion", |c| async move {
let id = c.arg::<String>("id")?;
completion(&c.host, &c.window, &id).await
});
d.add("db_statements", |c| async move {
let (id, text, from, to) =
(c.arg::<String>("id")?, c.arg::<String>("text")?, c.arg::<u32>("from")?, c.arg::<u32>("to")?);
statements(&c.host, &c.window, &id, &text, from, to)
});
d.add("db_execute", |c| async move {
let (request, id, statements, max_rows) = (
c.arg::<RequestId>("request")?,
c.arg::<String>("id")?,
c.arg::<Vec<SqlStatement>>("statements")?,
c.arg::<u32>("maxRows")?,
);
execute(&c.host, &c.window, request, &id, statements, max_rows).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
c.arg::<RequestId>("request")?,
c.arg::<String>("id")?,
c.arg::<String>("schema")?,
c.arg::<String>("table")?,
);
let (filter, sort, offset, limit) = (
c.arg::<Option<String>>("filter")?,
c.arg::<Vec<SortKey>>("sort")?,
c.arg::<u32>("offset")?,
c.arg::<u32>("limit")?,
);
table_page(&c.host, &c.window, request, &id, &schema, &table, filter, sort, offset, limit).await
});
d.add("db_apply_edits", |c| async move {
let (id, schema, table, edits) = (
c.arg::<String>("id")?,
c.arg::<String>("schema")?,
c.arg::<String>("table")?,
c.arg::<Vec<DbRowEdit>>("edits")?,
);
apply_edits(&c.host, &c.window, &id, &schema, &table, edits).await
});
d.add("db_console_path", |c| async move { console_path(&c.host, &c.arg::<String>("id")?) });
}
/// The data sources configured for the project (the tree's roots).
pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
host.project(window)?.db.sources()
}
/// Replaces the project's data sources with `sources`.
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.save_sources(sources).await
}
/// Remembers the password of data source `id` for the session, and in the OS
/// saved passwords when `save` (`None` forgets it).
pub async fn set_password(host: &Host, window: &str, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.set_password(id, password, save).await
}
/// Test Connection: connects with unsaved settings and returns the server version.
pub async fn test(
host: &Host,
window: &str,
source: DataSource,
password: Option<String>,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test(&source, password, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.set_ssh_secret(id, secret, save).await
}
/// Trusts the SSH host key that made the last connection of `id` fail with
/// "Unknown SSH host key" (records it in `~/.ssh/known_hosts`).
pub async fn trust_ssh_host(host: &Host, window: &str, id: &str) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.trust_ssh_host(id).await
}
/// The data sources with an open connection.
pub fn connected(host: &Host, window: &str) -> IdeResult<Vec<String>> {
Ok(host.project(window)?.db.connected())
}
/// Connects (fails with `permissionDenied` when the password is missing or wrong).
pub async fn connect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.pool(id).await.map(|_| ())
}
/// Switches a data source between auto-commit and manual commit (leaving
/// manual mode rolls back what was not committed).
pub async fn set_manual_commit(host: &Host, window: &str, id: &str, manual: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.set_manual_commit(id, manual).await;
Ok(())
}
/// Commits or rolls back the open transaction of a manual-commit data source.
pub async fn finish_transaction(host: &Host, window: &str, id: &str, commit: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.finish_transaction(id, commit).await
}
/// Closes the connection of data source `id`.
pub async fn disconnect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.disconnect(id).await;
Ok(())
}
/// Databases of the server of data source `id` (PostgreSQL); connect to one as `<id>:<database>`.
pub async fn databases(host: &Host, window: &str, id: &str) -> IdeResult<Databases> {
let db = host.project(window)?.db.clone();
db.databases(id).await
}
/// The schemas of data source `id`.
pub async fn schemas(host: &Host, window: &str, id: &str) -> IdeResult<Vec<String>> {
let db = host.project(window)?.db.clone();
db.schemas(id).await
}
/// The tables and views of `schema`.
pub async fn tables(host: &Host, window: &str, id: &str, schema: &str) -> IdeResult<Vec<TableInfo>> {
let db = host.project(window)?.db.clone();
db.tables(id, schema).await
}
/// Columns, keys and indexes of `table`.
pub async fn table_details(host: &Host, window: &str, id: &str, schema: &str, table: &str) -> IdeResult<TableDetails> {
let db = host.project(window)?.db.clone();
db.table_details(id, schema, table).await
}
/// The DDL of table or view `table` (Go to DDL, Generate DDL).
pub async fn ddl(host: &Host, window: &str, id: &str, schema: &str, table: &str) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.ddl(id, schema, table).await
}
/// The routines and sequences of `schema` (the tree's `routines` / `sequences`).
pub async fn schema_objects(host: &Host, window: &str, id: &str, schema: &str) -> IdeResult<SchemaObjects> {
let db = host.project(window)?.db.clone();
db.schema_objects(id, schema).await
}
/// The definition of a routine, trigger or sequence (Go to DDL, Quick Documentation).
pub async fn object_ddl(
host: &Host,
window: &str,
id: &str,
schema: &str,
kind: DbObjectKind,
name: &str,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.object_ddl(id, schema, kind, name).await
}
/// Explain Plan: the execution plan of `statement` on `id`, without running it.
pub async fn explain(
host: &Host,
window: &str,
request: RequestId,
id: &str,
statement: &str,
) -> IdeResult<Vec<PlanNode>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let result = db.explain(id, statement, &cancel).await;
host.end_request(request);
result
}
/// The statements data source `id` ran, newest first (Browse Query History).
pub async fn history(host: &Host, window: &str, id: &str) -> IdeResult<Vec<HistoryEntry>> {
let db = host.project(window)?.db.clone();
db.history(id).await
}
/// Removes statements from the query history of `id` (`None`: all of them).
pub async fn remove_history(host: &Host, window: &str, id: &str, texts: Option<Vec<String>>) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.remove_history(id, texts).await
}
/// Tables, columns and foreign keys for SQL completion.
pub async fn completion(host: &Host, window: &str, id: &str) -> IdeResult<CompletionModel> {
let db = host.project(window)?.db.clone();
db.completion(id).await
}
/// Statements a console runs: the selection's (`from < to`) or the one at the caret (`from`).
pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to: u32) -> IdeResult<Vec<SqlStatement>> {
let driver = host.project(window)?.db.source(id)?.driver;
Ok(if from < to {
ide_db::split::statements_in(text, from, to, driver)
} else {
ide_db::split::statement_at(text, from, driver).into_iter().collect()
})
}
/// Runs `statements` in order on data source `id`, stopping at the first
/// error. Cancel with `cancel_request(request)`.
pub async fn execute(
host: &Host,
window: &str,
request: RequestId,
id: &str,
statements: Vec<SqlStatement>,
max_rows: u32,
) -> IdeResult<Vec<StatementOutcome>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let mut out = Vec::new();
for statement in statements {
match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
host.end_request(request);
return Err(e);
}
Err(e) if e.kind == ide_core::error::ErrorKind::PermissionDenied && out.is_empty() => {
// Not connected for lack of a password: the UI asks and retries.
host.end_request(request);
return Err(e);
}
Err(e) => {
out.push(StatementOutcome { statement, result: None, error: Some(e.message) });
break;
}
}
}
host.end_request(request);
Ok(out)
}
/// One page of the rows of `table` (the table editor).
#[allow(clippy::too_many_arguments)]
pub async fn table_page(
host: &Host,
window: &str,
request: RequestId,
id: &str,
schema: &str,
table: &str,
filter: Option<String>,
sort: Vec<SortKey>,
offset: u32,
limit: u32,
) -> IdeResult<TablePage> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let result =
db.table_page(id, schema, table, filter.as_deref(), &sort, offset.into(), limit.max(1).into(), &cancel).await;
host.end_request(request);
result
}
/// Submits the table editor's changes in one transaction; returns the rows changed.
pub async fn apply_edits(
host: &Host,
window: &str,
id: &str,
schema: &str,
table: &str,
edits: Vec<DbRowEdit>,
) -> IdeResult<f64> {
let db = host.project(window)?.db.clone();
Ok(db.apply_edits(id, schema, table, &edits).await? as f64)
}
/// The query console file of data source `id` (`<dataDir>/consoles/db/<id>/console.sql`),
/// created on first use. Consoles are files of the project, so a remote
/// window resolves it on the host the project runs on.
pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
let (source, database) = ide_db::split_target(id);
if source.is_empty() || !source.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(IdeError::invalid("invalid data source id"));
}
// One console per database: `<id>` for the default one, `<id>.<database>` for the others.
let name = match database {
Some(db) => {
let safe: String =
db.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
format!("{source}.{safe}")
}
None => source.to_owned(),
};
let dir = host.data_dir.join("consoles").join("db").join(&name);
std::fs::create_dir_all(&dir)?;
let file = dir.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
+1 -1
View File
@@ -91,7 +91,7 @@ mod tests {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
+7 -8
View File
@@ -1,6 +1,6 @@
use crate::Host;
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
use ide_remote::{DeployedPath, DeploymentCoverage, DeploymentRoot, SyncEntry, SyncStep, TransferReport};
use ide_ssh::{FsConnection, RemoteEntry};
use std::path::PathBuf;
@@ -134,13 +134,12 @@ pub async fn deploy_info(
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
}
/// Remote Host tool window: the server root of `server`, resolved on the host.
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<String> {
let settings = match state.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => state.global_settings.effective(),
};
state.remote.server_root(&settings, &server).await
/// Remote Host tool window: the resolved server root and project mappings.
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<DeploymentRoot> {
let project = state.project(window).ok();
let settings =
project.as_ref().map(|p| p.settings.effective()).unwrap_or_else(|| state.global_settings.effective());
state.remote.server_info(&settings, project.as_ref().map(|p| p.root.as_path()), &server).await
}
/// Entries of a folder on the selected filesystem (`~`: its home directory).
+72 -4
View File
@@ -86,7 +86,7 @@ async fn resolve_launch(
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
}
@@ -96,7 +96,13 @@ async fn resolve_launch(
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
// The build model's level includes what the module inherits from its parent POMs.
let release = model
.maven
.as_ref()
.and_then(|m| m.projects.iter().find(|p| project.root.join(&p.dir) == module))
.and_then(|p| p.java_version)
.or_else(|| ide_sdk::java::project_java_version(&module));
let jdks = ide_sdk::java::installed_jdks();
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
@@ -131,15 +137,44 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
/// classpath next to its launch snapshot and every resource was found twice.
fn entry_key(path: &str, windows: bool) -> String {
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(share) => format!(r"\\{share}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
};
let mut key = if windows { path.replace('\\', "/") } else { path };
while key.len() > 1 && key.ends_with('/') {
key.pop();
}
if windows { key.to_lowercase() } else { key }
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows))
}
fn use_snapshot_on(
info: &mut JavaLaunchInfo,
built: &[PathBuf],
snapshot: &std::path::Path,
windows: bool,
) -> Vec<PathBuf> {
let mut copies = Vec::new();
for (index, source) in built.iter().enumerate() {
let dest = snapshot.join(index.to_string());
let original = source.to_string_lossy();
info.class_paths.retain(|p| p != original.as_ref());
info.class_paths.retain(|p| !same_entry(p, &original, windows));
let mut modular = false;
for path in &mut info.module_paths {
if path == original.as_ref() {
if same_entry(path, &original, windows) {
*path = dest.to_string_lossy().into_owned();
modular = true;
}
@@ -185,6 +220,39 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
/// JDT LS and our build model spell a module output differently on Windows; the
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
#[test]
fn snapshots_replace_outputs_however_windows_spells_them() {
let built = PathBuf::from(r"D:\workspace\arm\arm-web\target/classes");
let jdtls = [
r"D:\workspace\arm\arm-web\target\classes",
r"d:\workspace\arm\arm-web\target\classes",
r"D:/workspace/arm/arm-web/target/classes/",
r"\\?\D:\workspace\arm\arm-web\target\classes",
];
for spelled in jdtls {
let mut info = JavaLaunchInfo {
class_paths: vec![spelled.into(), r"D:\m2\mybatis.jar".into()],
module_paths: vec![spelled.into()],
..Default::default()
};
use_snapshot_on(&mut info, std::slice::from_ref(&built), std::path::Path::new(r"C:\cache\launch-x"), true);
assert_eq!(info.class_paths, [r"D:\m2\mybatis.jar"], "{spelled}");
assert_eq!(
info.module_paths,
[std::path::Path::new(r"C:\cache\launch-x").join("0").to_string_lossy()],
"{spelled}"
);
}
assert!(same_entry(r"\\?\UNC\server\share\app\target\classes", r"\\server\share\app\target/classes", true));
// Different directories stay different, and other platforms keep case and `\`.
assert!(!same_entry(r"D:\a\target\classes", r"D:\a\target\test-classes", true));
assert!(!same_entry("/work/App/target/classes", "/work/app/target/classes", false));
assert!(!same_entry(r"/work/a\b", "/work/a/b", false));
assert!(same_entry("/work/app/target/classes/", "/work/app/target/classes", false));
}
#[test]
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
let root = tempfile::tempdir().unwrap();
+40 -8
View File
@@ -1,7 +1,7 @@
//! Bounded, read-only JVM presentation for visible files, also served remotely.
use crate::Host;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
use ide_syntax::{JvmPresentation, Snapshot};
use ide_syntax::{JvmPresentation, Snapshot, StructureService, jvm_type_header};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{
@@ -38,6 +38,27 @@ fn prefix(path: &PathBuf) -> std::io::Result<String> {
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// The presentation of the file at `path` from its first [`PREFIX_BYTES`].
fn read_presentation(
structure: &StructureService,
path: &PathBuf,
language: &LanguageId,
) -> IdeResult<JvmPresentation> {
let source = prefix(path)?;
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let present =
|source: &str| structure.jvm_presentation(&Snapshot::text(language, source), stem, &CancellationToken::new());
let presentation = present(&source)?;
// Cut off inside its body, a large file doesn't parse as a declaration: its header does.
if presentation.kind.is_none()
&& source.len() as u64 >= PREFIX_BYTES
&& let Some(header) = jvm_type_header(&source)
{
return Ok(present(&format!("{header}}}"))?);
}
Ok(presentation)
}
pub async fn jvm_file_presentations(
host: &Host,
window: &str,
@@ -67,13 +88,7 @@ pub async fn jvm_file_presentations(
let presentation = if let Some(hit) = hit {
hit.presentation
} else {
let Ok(source) = prefix(&path) else { continue };
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let presentation = project.structure.jvm_presentation(
&Snapshot::text(&language, &source),
stem,
&CancellationToken::new(),
)?;
let Ok(presentation) = read_presentation(&project.structure, &path, &language) else { continue };
let mut cache = cache.lock().unwrap();
if cache.len() >= MAX_CACHE {
cache.clear();
@@ -99,4 +114,21 @@ mod tests {
assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize);
std::fs::remove_file(path).unwrap();
}
#[test]
fn large_files_cut_off_in_their_body_keep_their_kind() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Foo.java");
let mut text = String::from("package a;\n\n@Service\npublic class Foo implements IFoo {\n");
while text.len() < PREFIX_BYTES as usize + 1024 {
// The prefix ends inside one of these comments.
text.push_str(" /**\n * Does something.\n */\n public void m() { call(\"x\"); }\n");
}
std::fs::write(&path, &text).unwrap();
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = LanguageId::new("java");
let presentation = read_presentation(&structure, &path, &java).unwrap();
assert_eq!(presentation.kind, Some(ide_syntax::JvmTypeKind::Class));
}
}
+210 -6
View File
@@ -1,9 +1,11 @@
use super::doc::blocking;
use crate::Host;
use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
@@ -38,9 +40,99 @@ pub async fn lsp_completion(
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger, cancel).await;
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
host.end_request(request);
result
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return result;
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
Ok(list)
}
/// The plugin host and the document, when some plugin provides `kind`.
fn plugin_providers(
host: &Host,
window: &str,
doc: DocId,
kind: ProviderKind,
) -> Option<(std::sync::Arc<crate::plugins::PluginHost>, ProviderDocument)> {
let plugins = editor_providers(kind)?;
let snap = host.project(window).ok()?.documents.snapshot(doc).ok()?;
let d = ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
Some((plugins, d))
}
/// Plugin calls block (the plugin's actor thread does the work).
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
tokio::task::spawn_blocking(f).await.ok()
}
/// Items from plugins carry no server (resolve and commands don't apply).
const PLUGIN_SERVER: ServerId = ServerId(0);
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
fn plugin_completion_kind(kind: &str) -> Option<CompletionKind> {
Some(match kind {
"text" => CompletionKind::Text,
"method" => CompletionKind::Method,
"function" => CompletionKind::Function,
"constructor" => CompletionKind::Constructor,
"field" => CompletionKind::Field,
"variable" => CompletionKind::Variable,
"class" => CompletionKind::Class,
"interface" => CompletionKind::Interface,
"module" => CompletionKind::Module,
"property" => CompletionKind::Property,
"unit" => CompletionKind::Unit,
"value" => CompletionKind::Value,
"enum" => CompletionKind::Enum,
"keyword" => CompletionKind::Keyword,
"snippet" => CompletionKind::Snippet,
"color" => CompletionKind::Color,
"file" => CompletionKind::File,
"reference" => CompletionKind::Reference,
"folder" => CompletionKind::Folder,
"enum-member" => CompletionKind::EnumMember,
"constant" => CompletionKind::Constant,
"struct" => CompletionKind::Struct,
"event" => CompletionKind::Event,
"operator" => CompletionKind::Operator,
"type-parameter" => CompletionKind::TypeParameter,
_ => return None,
})
}
fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
let kind = item.kind.as_deref().and_then(plugin_completion_kind);
CompletionItem {
label: item.label,
label_detail: None,
detail: item.detail,
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
sort_text: item.sort_text,
filter_text: None,
preselect: false,
deprecated: false,
insert_text: item.insert_text,
is_snippet: false,
replace_range: item.replace,
replace_range_tab: None,
additional_edits: Vec::new(),
commit_characters: Vec::new(),
command: None,
data: None,
server: PLUGIN_SERVER,
}
}
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
@@ -54,9 +146,26 @@ pub async fn lsp_inline_completion(
) -> IdeResult<Vec<InlineCompletionItem>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
let result = lsp.inline_completion(doc, pos, automatic, cancel.clone()).await;
// Plugins: cancelled (cooperatively) when the editor cancels the request.
let plugin = match plugin_providers(host, window, doc, ProviderKind::InlineCompletion) {
Some((plugins, d)) if !matches!(&result, Ok(items) if !items.is_empty()) => {
let cancel = cancel.clone();
plugin_call(move || plugins.provide_inline_completion(&d, pos, &cancel)).await.flatten()
}
_ => None,
};
host.end_request(request);
result
match plugin {
Some((_, s)) => Ok(vec![InlineCompletionItem {
insert_text: s.insert_text,
range: s.replace,
filter_text: None,
command: None,
server: PLUGIN_SERVER,
}]),
None => result,
}
}
pub async fn lsp_resolve_completion(
@@ -87,7 +196,40 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
host.project(window)?.lsp.goto(doc, pos, kind).await
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
if kind != GotoKind::Definition {
return result;
}
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Definition) else { return result };
let found = plugin_call(move || plugins.provide_definition(&d, pos)).await.unwrap_or_default();
if found.is_empty() {
return result;
}
let mut out = result.unwrap_or_default();
out.extend(found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None }));
Ok(out)
}
/// Gutter markers from plugins (`line-markers` providers) for `doc`.
pub async fn plugin_line_markers(
host: &Host,
window: &str,
doc: DocId,
) -> IdeResult<Vec<crate::plugins::LineMarkerInfo>> {
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::LineMarkers) else {
return Ok(Vec::new());
};
let markers = plugin_call(move || plugins.provide_line_markers(&d)).await.unwrap_or_default();
Ok(markers
.into_iter()
.map(|(plugin_id, m)| crate::plugins::LineMarkerInfo {
plugin_id,
line: m.line,
icon: m.icon,
tooltip: m.tooltip,
target: m.target.map(|l| Location { path: l.path.into(), range: l.range, uri: None }),
})
.collect())
}
/// Implementations of the symbols at `positions`, one list per position (the
@@ -165,10 +307,47 @@ pub async fn lsp_code_actions(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let actions = project.lsp.code_actions(doc, range, only).await?;
let result = project.lsp.code_actions(doc, range, only.clone()).await;
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
None => Vec::new(),
};
let plugin_actions: Vec<CodeAction> = plugin_actions
.into_iter()
.map(|(_, a)| plugin_code_action(a))
.filter(|a| {
only.is_empty()
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
})
.collect();
let actions = if plugin_actions.is_empty() {
result?
} else {
let mut out = result.unwrap_or_default();
out.extend(plugin_actions);
out
};
prepare_actions(project, doc, version, actions).await
}
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
let files = a
.edits
.into_iter()
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
.collect();
CodeAction {
title: a.title,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason: None,
edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
command: None,
data: None,
server: PLUGIN_SERVER,
}
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
pub async fn lsp_code_action_query(
host: &Host,
@@ -551,3 +730,28 @@ pub(crate) fn register(d: &mut Dispatcher) {
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
let doc: String = wit
.lines()
.map(|l| l.trim().trim_start_matches("///").trim())
.skip_while(|l| !l.starts_with("Icon and sort group:"))
.take_while(|l| !l.starts_with("kind:"))
.collect::<Vec<_>>()
.join(" ");
let list = doc.strip_prefix("Icon and sort group:").unwrap().split(". Unknown").next().unwrap();
let list = list.replace(" or ", ", ");
let kinds: Vec<&str> = list.split(',').map(str::trim).filter(|k| !k.is_empty()).collect();
assert_eq!(kinds.len(), 25, "{kinds:?}");
for k in kinds {
assert!(plugin_completion_kind(k).is_some(), "{k:?}");
}
assert_eq!(plugin_completion_kind("nonsense"), None);
}
}
+2
View File
@@ -7,6 +7,7 @@ pub mod analysis;
pub mod build;
pub mod bytes;
pub mod coverage;
pub mod db;
pub mod debug;
pub mod dependencies;
pub mod deploy;
@@ -86,6 +87,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
run::register(d);
env::register(d);
deploy::register(d);
db::register(d);
}
impl Host {
+24
View File
@@ -4,6 +4,7 @@ use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
use ide_run::import::{RunImport, RunImportSource};
pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult<RunConfig> {
host.require_trust(window)?;
@@ -105,6 +106,25 @@ pub fn run_save_templates(host: &Host, window: &str, templates: Vec<RunConfig>)
ide_run::save_templates(&root, &templates)
}
/// Project files run configurations can be imported from (`.vscode/launch.json`).
pub fn run_import_sources(host: &Host, window: &str) -> IdeResult<Vec<RunImportSource>> {
let root = host.project(window)?.root.clone();
Ok(ide_run::import::available(&root))
}
/// Configurations read from `source`, not saved; names already in `existing` (the
/// configurations being edited) are skipped.
pub fn run_import_configs(
host: &Host,
window: &str,
source: RunImportSource,
existing: Vec<RunConfig>,
) -> IdeResult<RunImport> {
host.require_trust(window)?;
let root = host.project(window)?.root.clone();
ide_run::import_configs(&root, source, &existing)
}
/// The environment runs inherit when "Include system environment variables" is on.
pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap<String, String> {
ide_run::system_env()
@@ -240,6 +260,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
});
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
d.add("run_import_sources", |c| async move { run_import_sources(&c.host, &c.window) });
d.add("run_import_configs", |c| async move {
run_import_configs(&c.host, &c.window, c.arg("source")?, c.arg("existing")?)
});
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
+2 -1
View File
@@ -122,6 +122,7 @@ pub async fn sdk_python_pip(
on_output: Channel<String>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project_root = host.project(window).ok().map(|p| p.root.clone());
let cancel = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let send = |line: String| {
@@ -130,7 +131,7 @@ pub async fn sdk_python_pip(
if uninstall {
ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send)
} else {
ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send)
ide_sdk::python::install_package(&python, &requirement, upgrade, project_root.as_deref(), &cancel, &send)
}
})
.await
+12 -2
View File
@@ -552,12 +552,13 @@ pub async fn vcs_clone(
request: RequestId,
url: String,
dest: PathBuf,
depth: Option<u32>,
on_output: Channel<String>,
) -> IdeResult<()> {
apply_git_executable(&host.global_settings.effective());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
ide_vcs::clone(&url, &dest, &cancel, &|line| {
ide_vcs::clone(&url, &dest, depth, &cancel, &|line| {
let _ = on_output.send(line);
})
})
@@ -1070,7 +1071,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?).await },
);
d.add("vcs_clone", |c| async move {
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
vcs_clone(
&c.host,
&c.window,
c.arg("request")?,
c.arg("url")?,
c.arg("dest")?,
c.arg("depth")?,
c.sender("onOutput")?,
)
.await
});
d.add("vcs_init", |c| async move { vcs_init(&c.host, &c.window, c.arg("dir")?).await });
d.add("vcs_git_version", |c| async move { vcs_git_version(&c.host, &c.window, c.arg("path")?).await });
+803
View File
@@ -0,0 +1,803 @@
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
//! Capabilities were checked by the runtime before any of this runs.
use super::{PluginEvent, PluginEventSink};
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
use ide_plugin::{
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus,
StatusItem,
};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
/// Largest storage of one plugin (keys + values).
const MAX_STORAGE: usize = 1 << 20;
const MAX_KEY: usize = 256;
/// Most status bar items per plugin.
const MAX_STATUS_ITEMS: usize = 8;
/// What plugins see of the open projects; provided by the app
/// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop.
pub trait PluginWorkspace: Send + Sync {
/// Roots of the open projects.
fn projects(&self) -> Vec<PathBuf>;
/// Unsaved text of the open document at `path`.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
/// `version`, as one change (one undo step in the editor).
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError>;
/// Code from the project may run (git hooks and config on fetch).
fn trusted(&self, root: &Path) -> bool;
/// Root of the project open in `window`.
fn project_of(&self, window: &str) -> Option<PathBuf>;
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
/// The user's values of `plugin`'s settings: the project's at `root`
/// (over global), or global.
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
serde_json::Map::new()
}
}
/// Where plugin secrets live (the OS keychain on the desktop). Plugins can
/// only ask whether one is set; values go out only inside `net` requests.
pub trait SecretStore: Send + Sync {
fn get(&self, plugin: &str, name: &str) -> Option<String>;
/// Sets, or (`None`) deletes.
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String>;
}
/// Fallback [`SecretStore`]: one file per plugin, readable only by the user.
pub struct FileSecretStore {
pub dir: PathBuf,
}
impl FileSecretStore {
fn file(&self, plugin: &str) -> PathBuf {
self.dir.join(plugin).join("secrets.json")
}
fn load(&self, plugin: &str) -> BTreeMap<String, String> {
std::fs::read(self.file(plugin)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
}
impl SecretStore for FileSecretStore {
fn get(&self, plugin: &str, name: &str) -> Option<String> {
self.load(plugin).remove(name)
}
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
let mut map = self.load(plugin);
match value {
Some(v) => map.insert(name.to_owned(), v.to_owned()),
None => map.remove(name),
};
let file = self.file(plugin);
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?;
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, &file).map_err(|e| e.to_string())
}
}
/// Largest response body a plugin receives.
const MAX_RESPONSE: usize = 8 << 20;
/// Largest stdout / stderr kept from a process.
const MAX_OUTPUT: usize = 4 << 20;
/// Most files `find-files` walks before giving up.
const MAX_WALK: usize = 200_000;
/// Runtime for plugin HTTP requests (actor threads block on it).
pub(super) fn net_runtime() -> &'static tokio::runtime::Runtime {
static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("plugin-net")
.enable_all()
.build()
.expect("plugin network runtime")
})
}
/// Replaces `{{secret:<name>}}` in `value` with the plugin's secrets.
fn fill_secrets(value: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, HostError> {
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(i) = rest.find("{{secret:") {
out.push_str(&rest[..i]);
let after = &rest[i + "{{secret:".len()..];
let end = after.find("}}").ok_or_else(|| HostError::Invalid("unterminated {{secret:…}}".into()))?;
let name = &after[..end];
let secret = lookup(name).ok_or_else(|| HostError::NotFound(format!("secret {name:?} is not set")))?;
out.push_str(&secret);
rest = &after[end + 2..];
}
out.push_str(rest);
Ok(out)
}
/// A plugin task shown in the UI.
struct TaskEntry {
plugin: String,
cancel: CancellationToken,
cancelled: bool,
}
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
pub workspace: RwLock<Option<Arc<dyn PluginWorkspace>>>,
/// `<plugins>/.data`: one directory per plugin id.
pub data_dir: PathBuf,
tasks: Mutex<HashMap<u64, TaskEntry>>,
next_task: AtomicU64,
/// (plugin, item id) → item, for windows opened later.
status: Mutex<BTreeMap<(String, String), StatusItem>>,
/// plugin → kinds subscribed at run time (kept across idle reclaim).
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
/// plugin → files it shows problems for, withdrawn when it stops.
diagnostics: Mutex<HashMap<String, BTreeSet<PathBuf>>>,
pub secrets: RwLock<Arc<dyn SecretStore>>,
/// (plugin, `process:` capability) → the approved program.
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
/// Validated UI declarations (settings schemas, declared views).
pub ui: Arc<ide_extension::UiStore>,
}
impl Shared {
pub fn new(
sink: Arc<dyn PluginEventSink>,
data_dir: PathBuf,
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
ui: Arc<ide_extension::UiStore>,
) -> Arc<Self> {
Arc::new(Self {
ui,
sink,
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
pins,
data_dir,
tasks: Mutex::default(),
next_task: AtomicU64::new(1),
status: Mutex::default(),
subscriptions: Mutex::default(),
diagnostics: Mutex::default(),
})
}
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
self.workspace.read().clone()
}
pub fn project_of(&self, window: &str) -> Option<PathBuf> {
self.workspace()?.project_of(window)
}
/// Shows `plugin`'s problems of `path`, remembering the file until they are
/// cleared (by the plugin, or when it stops).
fn publish_diagnostics(
&self,
plugin: &str,
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
let workspace = self.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
let empty = items.is_empty();
workspace.publish_diagnostics(plugin, path, items)?;
let mut published = self.diagnostics.lock();
if empty {
if let Some(paths) = published.get_mut(plugin) {
paths.remove(path);
if paths.is_empty() {
published.remove(plugin);
}
}
} else {
published.entry(plugin.to_owned()).or_default().insert(path.to_path_buf());
}
Ok(())
}
pub fn status_items(&self) -> Vec<(String, String, StatusItem)> {
self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect()
}
/// Withdraws a plugin's run-time state (it stopped or failed). Its
/// subscriptions go only with `subscriptions`, as they survive restarts.
pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) {
let removed: Vec<String> = {
let mut status = self.status.lock();
let ids: Vec<String> = status.keys().filter(|(p, _)| p == plugin).map(|(_, id)| id.clone()).collect();
for id in &ids {
status.remove(&(plugin.to_owned(), id.clone()));
}
ids
};
for id in removed {
self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None });
}
let tasks: Vec<u64> = self.tasks.lock().iter().filter(|(_, t)| t.plugin == plugin).map(|(id, _)| *id).collect();
for id in tasks {
self.end_task(id);
}
// Its problems go with it (an idle reclaim keeps them: it isn't cleared then).
let paths = self.diagnostics.lock().remove(plugin).unwrap_or_default();
if let Some(workspace) = self.workspace() {
for path in paths {
let _ = workspace.publish_diagnostics(plugin, &path, Vec::new());
}
}
if subscriptions && self.subscriptions.lock().remove(plugin).is_some() {
self.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
/// The user cancelled a task in the UI.
pub fn cancel_task(&self, id: u64) -> bool {
let mut tasks = self.tasks.lock();
let Some(t) = tasks.get_mut(&id) else { return false };
t.cancelled = true;
t.cancel.cancel();
true
}
fn end_task(&self, id: u64) {
if let Some(t) = self.tasks.lock().remove(&id) {
self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() });
}
}
pub fn storage_file(&self, plugin: &str) -> PathBuf {
self.data_dir.join(plugin).join("storage.json")
}
}
pub(super) struct PluginApi {
pub pkg: Package,
pub shared: Arc<Shared>,
}
impl PluginApi {
fn id(&self) -> &str {
self.pkg.id()
}
fn load_storage(&self) -> Result<BTreeMap<String, String>, HostError> {
match std::fs::read(self.shared.storage_file(self.id())) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
Err(e) => Err(HostError::Failed(format!("storage: {e}"))),
}
}
fn save_storage(&self, map: &BTreeMap<String, String>) -> Result<(), HostError> {
let file = self.shared.storage_file(self.id());
let bytes = serde_json::to_vec(map).map_err(|e| HostError::Failed(e.to_string()))?;
let io = |e: std::io::Error| HostError::Failed(format!("storage: {e}"));
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(io)?;
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(io)
}
/// The program approved for the (already checked) `process:` grant,
/// verified right before it starts.
fn approved_program(&self, program: &str) -> Result<PathBuf, HostError> {
let cap = format!("process:{program}");
// Granted without a pinned file: the program wasn't found when it was
// allowed, and installing it later approves nothing by itself.
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
HostError::Failed(format!(
"{cap}: `{program}` was not found when this permission was allowed; \
install it, then allow {cap} again in Settings › Plugins"
))
})?;
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
}
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
impl HostApi for PluginApi {
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String) {
self.shared
.sink
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
}
fn status_item(&self, id: &str, item: Option<StatusItem>) {
let key = (self.id().to_owned(), id.to_owned());
{
let mut status = self.shared.status.lock();
match &item {
Some(item) => {
let count = status.keys().filter(|(p, _)| p == self.id()).count();
if !status.contains_key(&key) && count >= MAX_STATUS_ITEMS {
tracing::warn!(plugin = %self.id(), "more than {MAX_STATUS_ITEMS} status items; {id:?} ignored");
return;
}
if status.get(&key) == Some(item) {
return;
}
status.insert(key, item.clone());
}
None => {
if status.remove(&key).is_none() {
return;
}
}
}
}
self.shared
.sink
.emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item });
}
fn projects(&self) -> Vec<PathBuf> {
self.shared.workspace().map(|w| w.projects()).unwrap_or_default()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
self.shared.workspace()?.buffer_text(path)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
self.shared.workspace()?.document(path)
}
fn apply_edits(
&self,
_client: Option<&str>,
path: &Path,
version: u64,
edits: Vec<TextEdit>,
) -> Result<u64, HostError> {
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
workspace.apply_edits(path, version, &edits)
}
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
Ok(self.load_storage()?.get(key).cloned())
}
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError> {
if key.is_empty() || key.len() > MAX_KEY {
return Err(HostError::Invalid(format!("keys are 1–{MAX_KEY} bytes")));
}
let mut map = self.load_storage()?;
let changed = match value {
Some(v) => map.insert(key.to_owned(), v.clone()).as_ref() != Some(&v),
None => map.remove(key).is_some(),
};
if !changed {
return Ok(());
}
let size: usize = map.iter().map(|(k, v)| k.len() + v.len()).sum();
if size > MAX_STORAGE {
return Err(HostError::Invalid(format!("storage is limited to {} KiB", MAX_STORAGE >> 10)));
}
self.save_storage(&map)
}
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect())
}
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(None) };
let info = repo.branch_info().map_err(failed)?;
let changed = repo.status().map_err(failed)?.len();
Ok(Some(RepositoryStatus {
root: repo.root().to_string_lossy().into_owned(),
branch: info.head,
commit: info.head_commit,
upstream: info.upstream,
ahead: info.ahead,
behind: info.behind,
operation: info.operation,
changed_files: u32::try_from(changed).unwrap_or(u32::MAX),
}))
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
let workspace = self.shared.workspace();
if !workspace.is_some_and(|w| w.trusted(root)) {
return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into()));
}
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else {
return Err(HostError::NotFound("the project is not in a git repository".into()));
};
match repo.fetch(cancel, &|_| {}) {
Ok(()) => Ok(()),
Err(_) if cancel.is_cancelled() => Err(HostError::Cancelled),
Err(e) => Err(failed(e.message)),
}
}
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
let matcher = globset::GlobBuilder::new(glob)
.literal_separator(true)
.build()
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
.compile_matcher();
let mut out = Vec::new();
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
if n >= MAX_WALK || out.len() >= limit {
break;
}
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
out.push(entry.path().to_string_lossy().into_owned());
}
}
out.sort();
Ok(out)
}
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
self.shared.publish_diagnostics(self.id(), path, items)
}
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError> {
let secrets = self.shared.secrets.read().clone();
let id = self.id().to_owned();
let mut headers = reqwest::header::HeaderMap::new();
for (name, value) in &request.headers {
let value = fill_secrets(value, |n| secrets.get(&id, n))?;
let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|e| HostError::Invalid(format!("header {name:?}: {e}")))?;
let mut value = reqwest::header::HeaderValue::from_str(&value)
.map_err(|e| HostError::Invalid(format!("header {name}: {e}")))?;
value.set_sensitive(true);
headers.append(name, value);
}
let method = reqwest::Method::from_bytes(request.method.to_ascii_uppercase().as_bytes())
.map_err(|_| HostError::Invalid(format!("method {:?}", request.method)))?;
let client = ide_http::client::shared().map_err(|e| HostError::Failed(e.message))?;
// A redirect to another host is refused after the fact (the shared
// client follows redirects).
let builder = client.request(method, &request.url).headers(headers).timeout(request.timeout);
let builder = match request.body {
Some(body) => builder.body(body),
None => builder,
};
let cancel = cancel.clone();
let host = ide_plugin::net_host(&request.url).map_err(HostError::Invalid)?;
net_runtime().block_on(async move {
let send = async {
let response = builder.send().await.map_err(|e| HostError::Failed(e.to_string()))?;
if ide_plugin::net_host(response.url().as_str()).ok().as_deref() != Some(host.as_str()) {
return Err(HostError::Denied(format!(
"net:{} (redirected)",
response.url().host_str().unwrap_or("?")
)));
}
let status = response.status().as_u16();
let headers = response
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_owned(), String::from_utf8_lossy(v.as_bytes()).into_owned()))
.collect();
let mut body = Vec::new();
let mut response = response;
while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Failed(e.to_string()))? {
if body.len() + chunk.len() > MAX_RESPONSE {
return Err(HostError::Failed("response larger than 8 MiB".into()));
}
body.extend_from_slice(&chunk);
}
Ok(HttpResponse { status, headers, body })
};
tokio::select! {
r = send => r,
() = cancel.cancelled() => Err(HostError::Cancelled),
}
})
}
fn secret_is_set(&self, name: &str) -> bool {
self.shared.secrets.read().get(self.id(), name).is_some()
}
fn run_process(
&self,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
stdin: Option<String>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<ProcessOutput, HostError> {
let binary = self.approved_program(program)?;
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&cwd);
let mut child = std::process::Command::new(&binary)
.args(&args)
.current_dir(&cwd)
.stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() })
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) {
std::thread::spawn(move || {
use std::io::Write;
let _ = pipe.write_all(input.as_bytes());
});
}
let read = |pipe: Option<Box<dyn std::io::Read + Send>>| {
std::thread::spawn(move || {
use std::io::Read;
let mut out = Vec::new();
if let Some(p) = pipe {
let _ = p.take(MAX_OUTPUT as u64).read_to_end(&mut out);
}
String::from_utf8_lossy(&out).into_owned()
})
};
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = child.try_wait().map_err(|e| HostError::Failed(e.to_string()))? {
break status;
}
if cancel.is_cancelled() || Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(if cancel.is_cancelled() {
HostError::Cancelled
} else {
HostError::Failed(format!("{program} timed out after {timeout:?}"))
});
}
std::thread::sleep(Duration::from_millis(10));
};
Ok(ProcessOutput {
status: status.code(),
stdout: stdout.join().unwrap_or_default(),
stderr: stderr.join().unwrap_or_default(),
})
}
fn subscribe(&self, kind: EventKind, on: bool) {
let changed = {
let mut subs = self.shared.subscriptions.lock();
let set = subs.entry(self.id().to_owned()).or_default();
if on { set.insert(kind) } else { set.remove(&kind) }
};
if changed {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64 {
let id = self.shared.next_task.fetch_add(1, Ordering::Relaxed);
self.shared.tasks.lock().insert(id, TaskEntry { plugin: self.id().to_owned(), cancel, cancelled: false });
self.shared.sink.emit(
client,
PluginEvent::TaskStarted { plugin_id: self.id().to_owned(), task_id: id.to_string(), title, cancellable },
);
id
}
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>) {
if !self.shared.tasks.lock().contains_key(&id) {
return;
}
self.shared.sink.emit(
None,
PluginEvent::TaskProgress {
plugin_id: self.id().to_owned(),
task_id: id.to_string(),
done,
total,
message,
},
);
}
fn task_cancelled(&self, id: u64) -> bool {
self.shared.tasks.lock().get(&id).is_some_and(|t| t.cancelled)
}
fn task_end(&self, id: u64) {
self.shared.end_task(id);
}
fn refresh_view(&self, view: &str) {
self.shared
.sink
.emit(None, PluginEvent::ViewChanged { plugin_id: self.id().to_owned(), view: view.to_owned() });
}
fn post_view_message(&self, view: &str, message: String) -> Result<(), HostError> {
let declared = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
.iter()
.any(|point| self.shared.ui.items(point, Some(self.id())).iter().any(|i| i.key == view));
if !declared {
return Err(HostError::NotFound(format!("the plugin declares no view {view:?}")));
}
let event = PluginEvent::ViewMessage { plugin_id: self.id().to_owned(), view: view.to_owned(), message };
self.shared.sink.emit(None, event);
Ok(())
}
fn open_terminal(
&self,
client: Option<&str>,
title: String,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError> {
let binary = self.approved_program(program)?;
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&cwd);
let event = PluginEvent::OpenTerminal {
plugin_id: self.id().to_owned(),
title,
program: binary.into(),
args,
cwd: cwd.into(),
};
self.shared.sink.emit(client, event);
Ok(())
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
.ui
.items(super::ui_points::SETTINGS, Some(self.id()))
.into_iter()
.find_map(|i| serde_json::from_value(i.decl.get("properties")?.clone()).ok())?;
let setting = schema.into_iter().find(|s| s.key == key)?;
let user = self.shared.workspace().map(|w| w.plugin_settings(self.id(), root)).unwrap_or_default();
let value = user.get(key).filter(|v| setting.accepts(v)).cloned().unwrap_or(setting.default);
serde_json::to_string(&value).ok()
}
}
/// [`PluginWorkspace`] over the desktop host's open projects.
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
impl HostWorkspace {
fn projects(&self) -> Vec<Arc<crate::project::Project>> {
self.0.upgrade().map(|h| h.projects()).unwrap_or_default()
}
/// The project holding an open document at `path`, and its id.
fn open_document(&self, path: &Path) -> Option<(Arc<crate::project::Project>, ide_core::DocId)> {
self.projects().into_iter().find_map(|p| p.documents.by_path(path).map(|doc| (p, doc)))
}
}
impl PluginWorkspace for HostWorkspace {
fn projects(&self) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = HostWorkspace::projects(self).iter().map(|p| p.root.clone()).collect();
roots.sort();
roots.dedup();
roots
}
fn buffer_text(&self, path: &Path) -> Option<String> {
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let (project, doc) = self.open_document(path)?;
let snap = project.documents.snapshot(doc).ok()?;
Some((u64::from(snap.version), snap.text))
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError> {
let (project, doc) =
self.open_document(path).ok_or_else(|| HostError::NotFound(format!("{} is not open", path.display())))?;
let version = u32::try_from(version).map_err(|_| HostError::Stale)?;
match project.documents.apply_edits_at(doc, version, edits) {
Ok(Some(v)) => Ok(u64::from(v)),
Ok(None) => Err(HostError::Stale),
Err(e) if e.kind == ide_core::error::ErrorKind::InvalidInput => Err(HostError::Invalid(e.message)),
Err(e) => Err(HostError::Failed(e.message)),
}
}
fn trusted(&self, root: &Path) -> bool {
HostWorkspace::projects(self).iter().any(|p| p.root == root && p.is_trusted())
}
fn project_of(&self, window: &str) -> Option<PathBuf> {
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
}
fn publish_diagnostics(
&self,
source: &str,
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
let projects = HostWorkspace::projects(self);
let project = projects
.iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
project.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin:{source}"),
items,
});
Ok(())
}
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
Some(p) => p.settings.effective(),
None => host.global_settings.effective(),
};
settings.plugins.get(plugin).cloned().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn api(dir: &Path, pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>) -> PluginApi {
let manifest = ide_extension::Manifest::parse(
"id = \"acme.lint\"\nname = \"Lint\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n",
)
.unwrap();
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new());
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("lint");
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
let api = api(tmp.path(), pins.clone());
// Allowed while the program was missing: say how to fix it rather than "not granted".
let err = api.approved_program("lint").unwrap_err().to_string();
assert!(
err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"),
"{err}"
);
pins.write().insert(("acme.lint".into(), "process:lint".into()), ProgramPin::approve(&bin).unwrap());
assert_eq!(api.approved_program("lint").unwrap(), bin);
std::fs::write(&bin, "#!/bin/sh\necho replaced\n").unwrap();
assert!(
matches!(api.approved_program("lint"), Err(HostError::Denied(e)) if e.contains("changed since it was approved"))
);
}
}
+615
View File
@@ -0,0 +1,615 @@
//! The `iconThemes` extension point: file and folder icons.
//!
//! ```toml
//! [[contributes.iconThemes]]
//! id = "material"
//! label = "Material Icons"
//! path = "icons/theme.json"
//! ```
//!
//! `theme.json` (paths relative to the package):
//!
//! ```json
//! {
//! "iconDefinitions": { "rust": "icons/rust.svg", "folder-src": "icons/folder-src.svg" },
//! "file": "file", "folder": "folder", "folderExpanded": "folder-open",
//! "fileExtensions": { "rs": "rust" },
//! "fileNames": { "cargo.toml": "cargo" },
//! "folderNames": { "src": "folder-src" }
//! }
//! ```
//!
//! Every SVG is sanitised here (allow-listed elements and attributes, no
//! scripts, event handlers, external references or entities) and stored as an
//! asset; the client fetches the mapping and the icons only when the theme is
//! selected, so installed icon themes cost nothing at start-up.
use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
pub const POINT_ID: &str = "iconThemes";
const MAX_ICONS: usize = 5000;
const MAX_SVG: usize = 64 << 10;
const MAX_MAPPINGS: usize = 20_000;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
id: String,
label: String,
path: PathBuf,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ThemeFile {
icon_definitions: BTreeMap<String, PathBuf>,
#[serde(default)]
file: Option<String>,
#[serde(default)]
folder: Option<String>,
#[serde(default)]
folder_expanded: Option<String>,
#[serde(default)]
file_extensions: BTreeMap<String, String>,
#[serde(default)]
file_names: BTreeMap<String, String>,
#[serde(default)]
folder_names: BTreeMap<String, String>,
}
/// Asset id of a theme's mapping (JSON).
pub fn map_asset(theme: &str) -> String {
format!("iconTheme/{theme}/map")
}
/// Asset id prefix of a theme's SVGs (`…/svg/<icon id>`).
pub fn svg_prefix(theme: &str) -> String {
format!("iconTheme/{theme}/svg/")
}
/// An SVG icon of the package, size-checked and sanitised.
pub(super) fn read_icon(pkg: &Package, path: &std::path::Path) -> Result<String, String> {
check_relative(path, "icon")?;
let svg = pkg.read_text(path)?;
if svg.len() > MAX_SVG {
return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10));
}
sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display()))
}
pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: Decl = decode(POINT_ID, decl)?;
validate_item_id(&d.id, "icon theme id")?;
if d.label.trim().is_empty() {
return Err(format!("icon theme {:?} has an empty label", d.id));
}
let src = pkg.read_text(&d.path)?;
let theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?;
let at = |what: &str| format!("{} ({what})", d.path.display());
if theme.icon_definitions.len() > MAX_ICONS {
return Err(format!("{}: more than {MAX_ICONS} icons", d.path.display()));
}
let mappings = theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len();
if mappings > MAX_MAPPINGS {
return Err(format!("{}: more than {MAX_MAPPINGS} mappings", d.path.display()));
}
let defined: BTreeSet<&str> = theme.icon_definitions.keys().map(String::as_str).collect();
let check_ref = |icon: &str, what: &str| -> Result<(), String> {
if defined.contains(icon) { Ok(()) } else { Err(format!("{}: unknown icon {icon:?}", at(what))) }
};
for (what, icon) in [("file", &theme.file), ("folder", &theme.folder), ("folderExpanded", &theme.folder_expanded)] {
if let Some(icon) = icon {
check_ref(icon, what)?;
}
}
// Extensions may be written with their dot (`.rs`); names keep theirs (`.github`, `.gitignore`).
let lower = |map: &BTreeMap<String, String>, what: &str| -> Result<BTreeMap<String, String>, String> {
let mut out = BTreeMap::new();
for (k, icon) in map {
check_ref(icon, what)?;
let key = k.trim();
let key = if what == "fileExtensions" { key.trim_start_matches('.') } else { key }.to_lowercase();
if key.is_empty() || key.contains(['/', '\\']) {
return Err(format!("{}: invalid key {k:?}", at(what)));
}
out.insert(key, icon.clone());
}
Ok(out)
};
let file_extensions = lower(&theme.file_extensions, "fileExtensions")?;
let file_names = lower(&theme.file_names, "fileNames")?;
let folder_names = lower(&theme.folder_names, "folderNames")?;
let mut assets = Vec::with_capacity(theme.icon_definitions.len() + 1);
let mut files = vec![d.path.clone()];
for (icon, path) in &theme.icon_definitions {
validate_item_id(icon, "icon id")?;
let clean = read_icon(pkg, path)?;
assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean));
files.push(path.clone());
}
let map = serde_json::json!({
"file": theme.file,
"folder": theme.folder,
"folderExpanded": theme.folder_expanded,
"fileExtensions": file_extensions,
"fileNames": file_names,
"folderNames": folder_names,
});
assets.push((map_asset(&d.id), map.to_string()));
let json = serde_json::json!({ "id": d.id, "label": d.label, "icons": theme.icon_definitions.len() });
let mut out = UiDecl::new(d.id.clone(), d.label, json);
out.files = files;
out.assets = assets;
Ok(out)
}
// ---------------------------------------------------------------------------
// SVG sanitiser
const ELEMENTS: &[&str] = &[
"svg",
"g",
"path",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"rect",
"defs",
"lineargradient",
"radialgradient",
"stop",
"clippath",
"mask",
"use",
"symbol",
"pattern",
"text",
"tspan",
"filter",
"fegaussianblur",
"feoffset",
"feblend",
"fecolormatrix",
"feflood",
"fecomposite",
"femerge",
"femergenode",
"fedropshadow",
];
/// Dropped with their content: harmless metadata.
const STRIPPED: &[&str] = &["title", "desc", "metadata"];
const ATTRIBUTES: &[&str] = &[
"id",
"class",
"viewbox",
"width",
"height",
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"cx",
"cy",
"r",
"rx",
"ry",
"fx",
"fy",
"d",
"points",
"transform",
"fill",
"fill-opacity",
"fill-rule",
"clip-rule",
"clip-path",
"mask",
"stroke",
"stroke-width",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-opacity",
"opacity",
"offset",
"stop-color",
"stop-opacity",
"gradientunits",
"gradienttransform",
"spreadmethod",
"patternunits",
"patterncontentunits",
"patterntransform",
"clippathunits",
"maskunits",
"maskcontentunits",
"preserveaspectratio",
"style",
"href",
"xlink:href",
"xmlns",
"xmlns:xlink",
"version",
"font-family",
"font-size",
"font-weight",
"text-anchor",
"dominant-baseline",
"letter-spacing",
"display",
"visibility",
"color",
"filter",
"in",
"in2",
"result",
"stddeviation",
"dx",
"dy",
"mode",
"operator",
"values",
"type",
"flood-color",
"flood-opacity",
"k1",
"k2",
"k3",
"k4",
"filterunits",
"primitiveunits",
"vector-effect",
"shape-rendering",
"isolation",
"mix-blend-mode",
];
/// Parses `src` and re-serialises the allow-listed subset. Fails on anything
/// that could execute, fetch or escape (scripts, `foreignObject`, event
/// handlers, external `href`s, `url()` to anything but a local fragment,
/// DTDs, entities, CDATA).
pub fn sanitize_svg(src: &str) -> Result<String, String> {
let mut out = String::with_capacity(src.len());
let mut stack: Vec<String> = Vec::new();
// Depth inside a stripped element (its content is skipped).
let mut skip_depth = 0usize;
let mut seen_root = false;
let mut rest = src;
while !rest.is_empty() {
let Some(lt) = rest.find('<') else {
if skip_depth == 0 && !stack.is_empty() {
out.push_str(&text(rest)?);
} else if !rest.trim().is_empty() && stack.is_empty() {
return Err("text outside the <svg> element".into());
}
break;
};
if lt > 0 {
let t = &rest[..lt];
if skip_depth == 0 && !stack.is_empty() {
out.push_str(&text(t)?);
} else if stack.is_empty() && !t.trim().is_empty() {
return Err("text outside the <svg> element".into());
}
}
rest = &rest[lt..];
if let Some(after) = rest.strip_prefix("<!--") {
let end = after.find("-->").ok_or("unterminated comment")?;
rest = &after[end + 3..];
continue;
}
if rest.starts_with("<?") {
let end = rest.find("?>").ok_or("unterminated processing instruction")?;
rest = &rest[end + 2..];
continue;
}
if rest.starts_with("<!") {
return Err("DTDs, entities and CDATA are not allowed".into());
}
if let Some(after) = rest.strip_prefix("</") {
let end = after.find('>').ok_or("unterminated closing tag")?;
let name = after[..end].trim().to_ascii_lowercase();
rest = &after[end + 1..];
let open = stack.pop().ok_or_else(|| format!("unexpected </{name}>"))?;
if open != name {
return Err(format!("</{name}> does not close <{open}>"));
}
if skip_depth > 0 {
skip_depth -= 1;
} else {
out.push_str(&format!("</{name}>"));
}
continue;
}
// Start tag.
let end = tag_end(rest).ok_or("unterminated tag")?;
let inner = &rest[1..end];
rest = &rest[end + 1..];
let self_closing = inner.trim_end().ends_with('/');
let inner = inner.trim_end().trim_end_matches('/');
let name_end = inner.find(|c: char| c.is_whitespace()).unwrap_or(inner.len());
let raw_name = &inner[..name_end];
let name = raw_name.to_ascii_lowercase();
if name.is_empty() {
return Err("empty tag".into());
}
if stack.is_empty() {
if seen_root || name != "svg" {
return Err("the file must contain exactly one <svg> element".into());
}
seen_root = true;
}
if !self_closing {
stack.push(name.clone());
}
if skip_depth > 0 {
if !self_closing {
skip_depth += 1;
}
continue;
}
if STRIPPED.contains(&name.as_str()) || name.contains(':') {
// Editor metadata (sodipodi:namedview) or descriptive text.
if !self_closing {
skip_depth = 1;
}
continue;
}
if !ELEMENTS.contains(&name.as_str()) {
return Err(format!("<{raw_name}> is not allowed"));
}
out.push('<');
out.push_str(raw_name);
for (attr, value) in attributes(&inner[name_end..])? {
let key = attr.to_ascii_lowercase();
if key.starts_with("on") {
return Err(format!("event handler {attr} is not allowed"));
}
if key.contains(':') && !matches!(key.as_str(), "xlink:href" | "xmlns:xlink") {
continue; // inkscape:label, sodipodi:docname, …
}
if !ATTRIBUTES.contains(&key.as_str()) && !key.starts_with("data-") && !key.starts_with("aria-") {
return Err(format!("attribute {attr} is not allowed"));
}
check_value(&key, &value).map_err(|e| format!("{attr}: {e}"))?;
out.push(' ');
out.push_str(&attr);
out.push_str("=\"");
out.push_str(&escape(&value));
out.push('"');
}
out.push_str(if self_closing { "/>" } else { ">" });
}
if !stack.is_empty() {
return Err(format!("<{}> is not closed", stack.last().map(String::as_str).unwrap_or("?")));
}
if !seen_root {
return Err("no <svg> element".into());
}
Ok(out)
}
/// Index of the `>` ending the tag at the start of `s`, skipping quoted values.
fn tag_end(s: &str) -> Option<usize> {
let mut quote: Option<char> = None;
for (i, c) in s.char_indices().skip(1) {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '>') => return Some(i),
(None, '<') => return None,
_ => {}
}
}
None
}
fn attributes(s: &str) -> Result<Vec<(String, String)>, String> {
let mut out = Vec::new();
let mut rest = s.trim_start();
while !rest.is_empty() {
let eq = rest.find('=').ok_or_else(|| format!("attribute without value near {:?}", clip(rest)))?;
let name = rest[..eq].trim();
if name.is_empty() || name.contains(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
return Err(format!("malformed attribute near {:?}", clip(rest)));
}
let after = rest[eq + 1..].trim_start();
let quote = after.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("unquoted attribute value")?;
let close = after[1..].find(quote).ok_or("unterminated attribute value")?;
let value = decode_entities(&after[1..1 + close])?;
if out.iter().any(|(n, _): &(String, String)| n.eq_ignore_ascii_case(name)) {
return Err(format!("duplicate attribute {name}"));
}
out.push((name.to_owned(), value));
rest = after[close + 2..].trim_start();
}
Ok(out)
}
fn check_value(key: &str, value: &str) -> Result<(), String> {
let lower = value.to_ascii_lowercase();
if matches!(key, "href" | "xlink:href") {
return if value.starts_with('#') { Ok(()) } else { Err("only local references (#id) are allowed".into()) };
}
if key == "xmlns" || key == "xmlns:xlink" {
return Ok(());
}
if lower.contains("javascript:")
|| lower.contains("expression(")
|| lower.contains("@import")
|| value.contains('\\')
{
return Err("unsafe value".into());
}
let mut rest = lower.as_str();
while let Some(i) = rest.find("url(") {
let inner = rest[i + 4..].trim_start().trim_start_matches(['"', '\'']);
if !inner.starts_with('#') {
return Err("url() may only reference a local fragment".into());
}
rest = &rest[i + 4..];
}
Ok(())
}
fn decode_entities(s: &str) -> Result<String, String> {
if !s.contains('&') {
return Ok(s.to_owned());
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
let semi = rest[i..].find(';').ok_or("unterminated entity")?;
let ent = &rest[i + 1..i + semi];
let c = match ent {
"amp" => '&',
"lt" => '<',
"gt" => '>',
"quot" => '"',
"apos" => '\'',
_ => {
let code = if let Some(hex) = ent.strip_prefix("#x").or_else(|| ent.strip_prefix("#X")) {
u32::from_str_radix(hex, 16).ok()
} else if let Some(dec) = ent.strip_prefix('#') {
dec.parse().ok()
} else {
None
};
code.and_then(char::from_u32).ok_or_else(|| format!("entity &{ent}; is not allowed"))?
}
};
out.push(c);
rest = &rest[i + semi + 1..];
}
out.push_str(rest);
Ok(out)
}
fn text(t: &str) -> Result<String, String> {
Ok(escape(&decode_entities(t)?))
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
fn clip(s: &str) -> &str {
let mut end = s.len().min(24);
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_ordinary_icons() {
let src = r##"<?xml version="1.0"?>
<!-- Material icon -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="x" viewBox="0 0 24 24" inkscape:version="1">
<title>rust</title>
<defs><linearGradient id="g"><stop offset="0" stop-color="#fff"/></linearGradient></defs>
<path fill="url(#g)" d="M1 2h3 &amp; 4" style="opacity:.5"/>
<use href="#g"/>
</svg>"##;
let out = sanitize_svg(src).unwrap();
assert!(out.starts_with("<svg xmlns="), "{out}");
assert!(!out.contains("title") && !out.contains("inkscape") && !out.contains("<!--"));
assert!(out.contains(r#"fill="url(#g)""#));
assert!(out.contains(r#"d="M1 2h3 &amp; 4""#));
assert!(out.ends_with("</svg>"));
}
#[test]
fn rejects_active_content() {
let bad = [
r##"<svg><script>alert(1)</script></svg>"##,
r##"<svg onload="alert(1)"></svg>"##,
r##"<svg><foreignObject/></svg>"##,
r##"<svg><use href="https://evil/x.svg#a"/></svg>"##,
r##"<svg><use xlink:href="data:image/svg+xml,..."/></svg>"##,
r##"<svg><rect style="fill:url(https://evil)"/></svg>"##,
r##"<svg><rect fill="url( 'http://x' )"/></svg>"##,
r##"<!DOCTYPE svg [<!ENTITY x "y">]><svg/>"##,
r##"<svg><![CDATA[x]]></svg>"##,
r##"<svg><a href="#x"/></svg>"##,
r##"<svg><rect width="&xx;"/></svg>"##,
r##"<svg><rect width=10/></svg>"##,
r##"<svg><g></svg>"##,
r##"<svg/><svg/>"##,
r##"<g/>"##,
r##"hello"##,
r##"<svg><rect style="behavior:url(#x);x:expression(1)"/></svg>"##,
];
for src in bad {
assert!(sanitize_svg(src).is_err(), "accepted: {src}");
}
}
#[test]
fn theme_files_validate_and_produce_assets() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::create_dir_all(dir.join("icons")).unwrap();
std::fs::write(dir.join("icons/rust.svg"), r#"<svg viewBox="0 0 16 16"><path d="M0 0h16v16z"/></svg>"#)
.unwrap();
std::fs::write(dir.join("icons/file.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
.unwrap();
std::fs::write(
dir.join("theme.json"),
r#"{"iconDefinitions":{"rust":"icons/rust.svg","file":"icons/file.svg"},"file":"file",
"fileExtensions":{".RS":"rust"},"fileNames":{"Cargo.toml":"rust",".gitignore":"file"},
"folderNames":{".GitHub":"file","src":"rust"}}"#,
)
.unwrap();
let manifest = ide_extension::Manifest::parse(
"id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"",
)
.unwrap();
let pkg = Package::new(manifest, dir.to_path_buf());
let decl = |path: &str| {
let mut t = toml::Table::new();
t.insert("id".into(), "m".into());
t.insert("label".into(), "M".into());
t.insert("path".into(), path.into());
toml::Value::Table(t)
};
let d = icon_theme(&pkg, decl("theme.json")).unwrap();
assert_eq!(d.json["icons"], 2);
assert_eq!(d.files.len(), 3);
let map: serde_json::Value =
serde_json::from_str(&d.assets.iter().find(|(k, _)| *k == map_asset("m")).unwrap().1).unwrap();
assert_eq!(map["fileExtensions"]["rs"], "rust");
assert_eq!(map["fileNames"]["cargo.toml"], "rust");
// Names keep their leading dot (only extensions drop it).
assert_eq!(map["fileNames"][".gitignore"], "file");
assert_eq!(map["folderNames"][".github"], "file");
assert_eq!(map["folderNames"]["src"], "rust");
assert!(map["folderNames"].get("github").is_none());
assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust", svg_prefix("m"))));
std::fs::write(dir.join("bad.json"), r#"{"iconDefinitions":{},"fileExtensions":{"rs":"nope"}}"#).unwrap();
assert!(icon_theme(&pkg, decl("bad.json")).unwrap_err().contains("unknown icon"));
std::fs::write(dir.join("icons/evil.svg"), r#"<svg><script/></svg>"#).unwrap();
std::fs::write(dir.join("evil.json"), r#"{"iconDefinitions":{"e":"icons/evil.svg"}}"#).unwrap();
assert!(icon_theme(&pkg, decl("evil.json")).unwrap_err().contains("evil.svg"));
assert!(icon_theme(&pkg, decl("../x.json")).is_err());
}
}
+288
View File
@@ -0,0 +1,288 @@
//! Copying a package into the plugins directory without ever leaving the
//! plugin missing. Installed plugins are versioned:
//! `<plugins>/<id>/<version>/` plus an `active` pointer file (and `previous`,
//! the version to roll back to). A new version is written to a staging
//! directory, renamed into place, and only then made active by replacing the
//! pointer; a swap interrupted by a crash is undone on the next start
//! ([`recover`]). Older installs used `<plugins>/<id>/` directly; they are
//! moved into the versioned layout on their next update.
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Top-level files copied with a package besides those its declarations reference.
pub const LICENSE_FILES: &[&str] =
&["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING", "README.md"];
/// Written into an installed package: the package hash the grant ledger compares.
pub const HASH_FILE: &str = ".package-hash";
const STAGING: &str = ".staging-";
const OLD: &str = ".old-";
/// `<root>/active`: the version in use.
pub const ACTIVE_FILE: &str = "active";
/// `<root>/previous`: the version an update replaced (for rollback).
pub const PREVIOUS_FILE: &str = "previous";
fn nonce() -> String {
let n = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
format!("{n:x}-{}", std::process::id())
}
/// Copies `files` (plus manifest and licence files) from `src` into `target`
/// atomically, recording `hash`.
pub fn install_package(src: &Path, target: &Path, files: &[PathBuf], hash: &str) -> io::Result<()> {
let parent = target.parent().ok_or_else(|| io::Error::other("plugin target has no parent"))?;
let name = target.file_name().and_then(|n| n.to_str()).ok_or_else(|| io::Error::other("bad target"))?;
std::fs::create_dir_all(parent)?;
let staging = parent.join(format!("{STAGING}{name}-{}", nonce()));
let result = (|| {
std::fs::create_dir_all(&staging)?;
std::fs::copy(src.join(ide_extension::MANIFEST_FILE), staging.join(ide_extension::MANIFEST_FILE))?;
for file in files {
let to = staging.join(file);
if let Some(dir) = to.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::copy(src.join(file), to)?;
}
for lic in LICENSE_FILES {
if src.join(lic).is_file() {
std::fs::copy(src.join(lic), staging.join(lic))?;
}
}
std::fs::write(staging.join(HASH_FILE), hash)?;
swap(&staging, target, |from, to| std::fs::rename(from, to))
})();
if result.is_err() {
let _ = std::fs::remove_dir_all(&staging);
}
result
}
/// A version as a directory name (semver: `[0-9A-Za-z.+-]`).
fn version_dir(version: &str) -> io::Result<&str> {
let ok = !version.is_empty()
&& version.len() <= 64
&& !version.starts_with('.')
&& version.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-'));
if ok { Ok(version) } else { Err(io::Error::other(format!("version {version:?} cannot name a directory"))) }
}
fn read_pointer(root: &Path, name: &str) -> Option<String> {
let v = std::fs::read_to_string(root.join(name)).ok()?;
let v = v.trim();
(!v.is_empty() && version_dir(v).is_ok()).then(|| v.to_owned())
}
/// Replaces the pointer file `name` atomically.
fn write_pointer(root: &Path, name: &str, version: &str) -> io::Result<()> {
let tmp = root.join(format!(".{name}.tmp"));
std::fs::write(&tmp, version)?;
std::fs::rename(&tmp, root.join(name))
}
/// The directory of a plugin's active version under its `root`
/// (`<plugins>/<id>`): `<root>/<active>`, or `root` itself in the flat layout.
pub fn active_dir(root: &Path) -> Option<PathBuf> {
if let Some(v) = read_pointer(root, ACTIVE_FILE) {
let dir = root.join(v);
return dir.join(ide_extension::MANIFEST_FILE).is_file().then_some(dir);
}
root.join(ide_extension::MANIFEST_FILE).is_file().then(|| root.to_path_buf())
}
/// The version an update replaced, if it is still there.
pub fn previous_version(root: &Path) -> Option<String> {
read_pointer(root, PREVIOUS_FILE).filter(|v| root.join(v).join(ide_extension::MANIFEST_FILE).is_file())
}
/// Moves a flat install (`<root>/plugin.toml`) to `<root>/<version>/`.
fn migrate_flat(root: &Path, version_of: impl Fn(&Path) -> Option<String>) -> io::Result<()> {
if read_pointer(root, ACTIVE_FILE).is_some() || !root.join(ide_extension::MANIFEST_FILE).is_file() {
return Ok(());
}
let version = version_of(root).unwrap_or_else(|| "0.0.0".into());
let version = version_dir(&version).map(str::to_owned).unwrap_or_else(|_| "0.0.0".into());
let parent = root.parent().unwrap_or(Path::new("."));
let name = root.file_name().and_then(|n| n.to_str()).ok_or_else(|| io::Error::other("bad plugin directory"))?;
// `.old-<name>-…` is restored by `recover` if this is interrupted.
let old = parent.join(format!("{OLD}{name}-{}", nonce()));
std::fs::rename(root, &old)?;
std::fs::create_dir_all(root)?;
std::fs::rename(&old, root.join(&version))?;
write_pointer(root, ACTIVE_FILE, &version)
}
/// Installs `files` of `src` as `<root>/<version>` and makes it the active
/// version. Returns the version it replaced (now `previous`).
pub fn install_version(
src: &Path,
root: &Path,
version: &str,
files: &[PathBuf],
hash: &str,
version_of: impl Fn(&Path) -> Option<String>,
) -> io::Result<Option<String>> {
let version = version_dir(version)?;
std::fs::create_dir_all(root)?;
migrate_flat(root, version_of)?;
install_package(src, &root.join(version), files, hash)?;
let previous = read_pointer(root, ACTIVE_FILE).filter(|v| v != version);
if let Some(p) = &previous {
write_pointer(root, PREVIOUS_FILE, p)?;
}
write_pointer(root, ACTIVE_FILE, version)?;
Ok(previous)
}
/// Makes `version` active again (rollback). The replaced version stays until [`prune`].
pub fn activate_version(root: &Path, version: &str) -> io::Result<()> {
let version = version_dir(version)?;
if !root.join(version).join(ide_extension::MANIFEST_FILE).is_file() {
return Err(io::Error::other(format!("version {version} is not installed")));
}
let _ = std::fs::remove_file(root.join(PREVIOUS_FILE));
write_pointer(root, ACTIVE_FILE, version)
}
/// Removes versions other than the active and the previous one.
pub fn prune(root: &Path) {
let keep: Vec<String> = [ACTIVE_FILE, PREVIOUS_FILE].iter().filter_map(|f| read_pointer(root, f)).collect();
if keep.is_empty() {
return;
}
let Ok(entries) = std::fs::read_dir(root) else { return };
for e in entries.flatten() {
let path = e.path();
let name = e.file_name().to_string_lossy().into_owned();
if path.is_dir() && !keep.contains(&name) {
let _ = std::fs::remove_dir_all(&path);
}
}
}
/// Moves `staging` to `target`, keeping the old `target` until the new one is in place.
fn swap(staging: &Path, target: &Path, rename: impl Fn(&Path, &Path) -> io::Result<()>) -> io::Result<()> {
if !target.exists() {
return rename(staging, target);
}
let parent = target.parent().unwrap_or(Path::new("."));
let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("plugin");
let old = parent.join(format!("{OLD}{name}-{}", nonce()));
rename(target, &old)?;
if let Err(e) = rename(staging, target) {
// Put the previous version back.
rename(&old, target)?;
return Err(e);
}
if let Err(e) = std::fs::remove_dir_all(&old) {
tracing::warn!("removing the previous version {}: {e}", old.display());
}
Ok(())
}
/// Undoes interrupted installs in `dir`: restores `.old-<name>-*` when
/// `<name>` is missing, and removes leftover staging and old directories.
pub fn recover(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
// Inside a versioned plugin: staging and old versions of a version swap.
if path.is_dir() && read_pointer(&path, ACTIVE_FILE).is_some() {
recover(&path);
continue;
}
let Some(file) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue };
if file.starts_with(STAGING) {
let _ = std::fs::remove_dir_all(&path);
} else if let Some(rest) = file.strip_prefix(OLD) {
// `<name>-<nonce>-<pid>`: the name is everything before the last two parts.
let parts: Vec<&str> = rest.rsplitn(3, '-').collect();
let name = parts.get(2).copied().unwrap_or_default();
let target = dir.join(name);
// A layout migration interrupted after creating the new root: it is still empty.
if !name.is_empty() && target.is_dir() && active_dir(&target).is_none() {
let _ = std::fs::remove_dir_all(&target);
}
if !name.is_empty() && !target.exists() {
tracing::warn!("restoring {} after an interrupted install", target.display());
let _ = std::fs::rename(&path, &target);
} else {
let _ = std::fs::remove_dir_all(&path);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_swap_keeps_the_old_version() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("p");
let staging = tmp.path().join(".staging-p-1");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("v"), "1").unwrap();
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("v"), "2").unwrap();
let calls = std::cell::Cell::new(0);
let err = swap(&staging, &target, |a, b| {
calls.set(calls.get() + 1);
if calls.get() == 2 { Err(io::Error::other("disk full")) } else { std::fs::rename(a, b) }
});
assert!(err.is_err());
assert_eq!(std::fs::read_to_string(target.join("v")).unwrap(), "1");
swap(&staging, &target, |a, b| std::fs::rename(a, b)).unwrap();
assert_eq!(std::fs::read_to_string(target.join("v")).unwrap(), "2");
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1, "old version removed");
}
#[test]
fn versions_are_installed_side_by_side_and_rolled_back() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let root = tmp.path().join("plugins/acme.tools");
let version_of = |_: &Path| Some("0.9.0".to_owned());
// A flat install from before versioning.
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join(ide_extension::MANIFEST_FILE), "v0.9").unwrap();
assert_eq!(active_dir(&root), Some(root.clone()));
std::fs::write(src.join(ide_extension::MANIFEST_FILE), "v1").unwrap();
assert_eq!(install_version(&src, &root, "1.0.0", &[], "h1", version_of).unwrap().as_deref(), Some("0.9.0"));
assert_eq!(active_dir(&root), Some(root.join("1.0.0")));
std::fs::write(src.join(ide_extension::MANIFEST_FILE), "v2").unwrap();
assert_eq!(install_version(&src, &root, "2.0.0", &[], "h2", version_of).unwrap().as_deref(), Some("1.0.0"));
assert_eq!(previous_version(&root).as_deref(), Some("1.0.0"));
prune(&root);
assert!(!root.join("0.9.0").exists(), "older than previous: pruned");
activate_version(&root, "1.0.0").unwrap();
assert_eq!(
std::fs::read_to_string(active_dir(&root).unwrap().join(ide_extension::MANIFEST_FILE)).unwrap(),
"v1"
);
assert_eq!(previous_version(&root), None);
assert!(install_version(&src, &root, "../x", &[], "h", version_of).is_err());
}
#[test]
fn recover_restores_an_interrupted_swap() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".old-acme.tools-1a2b-77")).unwrap();
std::fs::write(tmp.path().join(".old-acme.tools-1a2b-77/v"), "1").unwrap();
std::fs::create_dir_all(tmp.path().join(".staging-acme.tools-1a2c-77")).unwrap();
std::fs::create_dir_all(tmp.path().join("x")).unwrap();
std::fs::create_dir_all(tmp.path().join(".old-x-1-2")).unwrap();
recover(tmp.path());
assert_eq!(std::fs::read_to_string(tmp.path().join("acme.tools/v")).unwrap(), "1");
let mut names: Vec<_> =
std::fs::read_dir(tmp.path()).unwrap().flatten().map(|e| e.file_name().into_string().unwrap()).collect();
names.sort();
assert_eq!(names, ["acme.tools", "x"]);
}
}
+314
View File
@@ -0,0 +1,314 @@
//! The marketplace: a static index (`index.json` +
//! packages, served from any URL or folder) lists publishers and their keys,
//! plugins, and revocations. The host keeps the last index it read
//! (`market-index.json`), checks signatures against it, and disables what it
//! revokes — at start-up and on every refresh (the desktop refreshes daily).
use super::{PluginHost, PluginSource, PluginStatus};
use ide_core::{IdeError, IdeResult};
use ide_extension::Manifest;
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// The official marketplace: the index the release workflow publishes with
/// the app, next to the signed packages of the official plugins.
pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json";
/// The last index read, in the plugins directory.
pub const INDEX_FILE: &str = "market-index.json";
const DOWNLOADS: &str = ".downloads";
const MAX_INDEX: usize = 16 << 20;
const MAX_PACKAGE: usize = 256 << 20;
/// A marketplace entry with what is installed.
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MarketPlugin {
#[serde(flatten)]
pub entry: IndexEntry,
/// Installed version, if any.
pub installed: Option<String>,
}
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
if url.starts_with("https://") || url.starts_with("http://") {
let client = ide_http::client::shared().map_err(|e| e.message)?;
let url = url.to_owned();
return super::api::net_runtime().block_on(async move {
let response = client.get(&url).send().await.map_err(|e| format!("{url}: {e}"))?;
if !response.status().is_success() {
return Err(format!("{url}: HTTP {}", response.status()));
}
let bytes = response.bytes().await.map_err(|e| format!("{url}: {e}"))?;
if bytes.len() > max {
return Err(format!("{url}: larger than {} MiB", max >> 20));
}
Ok(bytes.to_vec())
});
}
let path = url.strip_prefix("file://").unwrap_or(url);
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
if bytes.len() > max {
return Err(format!("{path}: larger than {} MiB", max >> 20));
}
Ok(bytes)
}
/// `href` relative to the index at `base`: a URL, or a local path (with the
/// platform's separators, e.g. `C:\\market\\index.json`).
pub fn resolve(base: &str, href: &str) -> String {
if href.contains("://") || href.starts_with('/') || Path::new(href).is_absolute() {
return href.to_owned();
}
if base.contains("://") {
return match base.rfind('/') {
Some(i) => format!("{}/{href}", &base[..i]),
None => href.to_owned(),
};
}
match Path::new(base).parent() {
Some(dir) => dir.join(href).to_string_lossy().into_owned(),
None => href.to_owned(),
}
}
impl PluginHost {
pub(super) fn load_market_index(dir: &Path) -> Option<Index> {
let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?;
Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()
}
/// The marketplace index URL: the user's, else the default (none: no marketplace).
pub fn market_url(&self) -> Option<String> {
self.state.lock().market_url.clone().or_else(|| self.default_market_url.read().clone())
}
/// The marketplace used while the user has set none.
pub fn set_default_market_url(&self, url: Option<String>) {
*self.default_market_url.write() = url.filter(|u| !u.trim().is_empty());
}
pub fn set_market_url(&self, url: Option<String>) {
self.state.lock().market_url = url.filter(|u| !u.trim().is_empty());
self.save_state();
}
/// Reads the index again, keeps it, and applies its revocations.
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?;
let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?;
let index = Index::parse(&text).map_err(IdeError::invalid)?;
let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), &text);
*self.market.write() = Some(index);
// Trust may have changed: who signed what, what is revoked.
let installed: Vec<_> =
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
for plugin in installed {
let publisher = self.verified_publisher(plugin.dir());
plugin.meta.lock().publisher = publisher;
}
self.apply_revocations();
Ok(self.market_plugins())
}
/// The plugins of the last index read.
pub fn market_plugins(&self) -> Vec<MarketPlugin> {
let Some(index) = self.market.read().clone() else { return Vec::new() };
let plugins = self.plugins.read();
index
.plugins
.iter()
.filter(|e| index.revocation(&e.id, &e.version).is_none())
.cloned()
.map(|entry| {
let installed = plugins
.get(&entry.id)
.filter(|p| p.source == PluginSource::Installed)
.map(|p| p.manifest().version.clone());
MarketPlugin { entry, installed }
})
.collect()
}
/// Downloads a marketplace plugin, checks it against the index (digest and
/// signature), and unpacks it for the install page. Returns the folder to
/// install from.
pub fn download_market_plugin(&self, id: &str) -> IdeResult<PathBuf> {
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
let index = self.market.read().clone().ok_or_else(|| IdeError::unavailable("refresh the marketplace first"))?;
let entry = index
.plugins
.iter()
.find(|e| e.id == id)
.cloned()
.ok_or_else(|| IdeError::not_found(format!("{id} is not in the marketplace")))?;
let bytes = read_url(&resolve(&url, &entry.download), MAX_PACKAGE).map_err(IdeError::unavailable)?;
let downloads = self.plugins_dir.join(DOWNLOADS);
std::fs::create_dir_all(&downloads)?;
let archive = downloads.join(format!("{}-{}.{}", entry.id, entry.version, package::PACKAGE_EXTENSION));
std::fs::write(&archive, bytes)?;
let dir = self.unpack_package(&archive)?;
let _ = std::fs::remove_file(&archive);
let manifest = Manifest::load(&dir).map_err(IdeError::invalid)?;
let files = self.package_files_in(&dir)?;
let digest = package::package_digest(&dir, &files)?;
if (manifest.id.as_str(), manifest.version.as_str(), digest.as_str())
!= (&entry.id, &entry.version, &entry.digest)
{
let _ = std::fs::remove_dir_all(&dir);
return Err(IdeError::invalid("the download does not match the marketplace's entry"));
}
if self.verified_publisher(&dir).is_none() {
let _ = std::fs::remove_dir_all(&dir);
return Err(IdeError::invalid("the marketplace package is not signed by a trusted publisher"));
}
Ok(dir)
}
/// Unpacks a package file (`.oiplugin`, or a `.zip` of a package) next to the downloads; returns the folder.
pub fn unpack_package(&self, archive: &Path) -> IdeResult<PathBuf> {
let stem = archive.file_stem().and_then(|s| s.to_str()).unwrap_or("package");
let dir = self.plugins_dir.join(DOWNLOADS).join(format!("{stem}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let unpacked = package::unpack(archive, &dir).map_err(IdeError::invalid)?;
// Kept with the files: install checks it and keeps it with the installed copy.
if let Some(sig) = unpacked.signature {
let json = serde_json::to_string_pretty(&sig).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(dir.join(SIGNATURE_FILE), json)?;
}
Ok(dir)
}
/// Files of the package in `dir` (what its declarations reference, and
/// the component): what a package file holds besides the manifest.
pub fn package_files_of(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
self.package_files_in(dir)
}
pub(super) fn package_files_in(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
let manifest = Manifest::load(dir).map_err(IdeError::invalid)?;
let pkg = ide_extension::Package::new(manifest, dir.to_path_buf());
let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?;
Ok(set.files.into_iter().chain(pkg.manifest.wasm.clone()).collect())
}
/// The publisher whose valid, trusted signature the package in `dir` carries.
pub(super) fn verified_publisher(&self, dir: &Path) -> Option<String> {
let sig = read_signature(dir)?;
let index = self.market.read().clone()?;
let manifest = Manifest::load(dir).ok()?;
let files = self.package_files_in(dir).ok()?;
let digest = package::package_digest(dir, &files).ok()?;
index
.verify(&sig, &manifest.id, &manifest.version, &digest)
.map_err(|e| tracing::warn!("{}: {e}", dir.display()))
.ok()
}
/// A signature that is present but doesn't verify (tampered, foreign prefix,
/// revoked or unknown key): installing is refused.
pub(super) fn signature_problem(&self, dir: &Path) -> Option<String> {
let sig = read_signature(dir)?;
let manifest = Manifest::load(dir).ok()?;
let files = self.package_files_in(dir).ok()?;
let digest = package::package_digest(dir, &files).ok()?;
if let Err(e) = sig.check(&manifest.id, &manifest.version, &digest) {
return Some(e);
}
let index = self.market.read().clone()?;
index.verify(&sig, &manifest.id, &manifest.version, &digest).err()
}
/// Why an installed plugin must not run (index revocations, revoked signing key).
pub(super) fn revoked_reason(&self, id: &str, version: &str, dir: &Path) -> Option<String> {
let market = self.market.read();
let index = market.as_ref()?;
if let Some(r) = index.revocation(id, version) {
return Some(r.reason.clone());
}
let sig = read_signature(dir)?;
index.key_revoked(&sig.key).then(|| "its publisher's signing key was revoked".to_owned())
}
/// Disables installed plugins the index revokes (telling the user once),
/// and starts again those it no longer revokes.
pub(super) fn apply_revocations(&self) {
let installed: Vec<_> =
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
let mut changed = false;
for plugin in installed {
let reason = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir());
let current = plugin.meta.lock().error.clone().filter(|e| e.starts_with("revoked: "));
match (reason, current) {
(Some(r), Some(c)) if c == format!("revoked: {r}") => {}
(Some(reason), was) => {
self.stop(&plugin);
{
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Failed;
meta.error = Some(format!("revoked: {reason}"));
}
if was.is_none() {
self.emit(
None,
super::PluginEvent::Notification {
plugin_id: plugin.id().to_owned(),
level: ide_plugin::NotificationLevel::Warning,
title: format!("{} was disabled", plugin.manifest().name),
body: format!("The marketplace revoked this plugin: {reason}"),
},
);
}
changed = true;
}
(None, Some(_)) => {
{
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Inactive;
meta.error = None;
}
if !self.state.lock().disabled.contains(plugin.id()) {
self.start(&plugin);
}
changed = true;
}
(None, None) => {}
}
}
if changed {
self.changed();
}
}
}
pub(super) fn read_signature(dir: &Path) -> Option<PackageSignature> {
let text = std::fs::read_to_string(dir.join(SIGNATURE_FILE)).ok()?;
serde_json::from_str(&text).ok()
}
/// Keeps the signature of a package with its installed copy.
pub(super) fn copy_signature(src: &Path, installed: &Path) {
if src.join(SIGNATURE_FILE).is_file() {
let _ = std::fs::copy(src.join(SIGNATURE_FILE), installed.join(SIGNATURE_FILE));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn downloads_resolve_against_the_index() {
assert_eq!(resolve("https://x.dev/market/index.json", "p/a.oiplugin"), "https://x.dev/market/p/a.oiplugin");
assert_eq!(
Path::new(&resolve("/srv/market/index.json", "a.oiplugin")),
Path::new("/srv/market").join("a.oiplugin")
);
#[cfg(windows)]
assert_eq!(resolve(r"C:\market\index.json", "a.oiplugin"), r"C:\market\a.oiplugin");
assert_eq!(resolve("https://x.dev/index.json", "https://cdn.dev/a"), "https://cdn.dev/a");
}
}
File diff suppressed because it is too large. Load diff
+71
View File
@@ -0,0 +1,71 @@
//! First-party extension points registered by the host, plus
//! [`register_builtin_points`], the one place that lists them. Feature crates
//! own their points (`ide_language::plugin_point`); adding a contribution type
//! means adding a point here or in its crate, and a `defineUiPoint` projection
//! on the client for `Ui` points — nothing else.
use ide_extension::{ExtensionRegistry, Package, UiDecl, UiPoint, UiStore, decode, validate_item_id, validate_when};
use ide_language::plugin_point::{LanguagesPoint, PluginLanguages};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub const COMMANDS: &str = "commands";
const MAX_TITLE: usize = 256;
/// `[[contributes.commands]]`: an action `plugin.<plugin-id>.<id>` in Search
/// Everywhere and the keymap editor; shown before the plugin's code runs.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CommandDecl {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Enablement, evaluated by the client against context keys (`editorLang == rust`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<String>,
}
fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let c: CommandDecl = decode(COMMANDS, decl)?;
validate_item_id(&c.id, "command id")?;
if c.title.trim().is_empty() {
return Err(format!("command {:?} has an empty title", c.id));
}
if c.title.len() > MAX_TITLE || c.category.as_ref().is_some_and(|x| x.len() > MAX_TITLE) {
return Err(format!("command {:?}: title or category longer than {MAX_TITLE} bytes", c.id));
}
if let Some(when) = &c.when {
validate_when(when).map_err(|e| format!("command {:?}: {e}", c.id))?;
}
let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c };
let json = serde_json::to_value(&c).map_err(|e| e.to_string())?;
Ok(UiDecl::new(c.id.clone(), c.title, json))
}
/// Registers every first-party extension point.
pub fn register_builtin_points(
registry: &ExtensionRegistry,
ui: &Arc<UiStore>,
languages: &Arc<PluginLanguages>,
run_detectors: &Arc<super::run_detectors::RunDetectors>,
providers: &Arc<super::providers::Providers>,
) -> Result<(), String> {
registry.register(Arc::new(UiPoint::new(COMMANDS, ui.clone(), command)))?;
registry.register(Arc::new(super::theme::ThemesPoint::new(ui.clone())))?;
registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?;
registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?;
registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?;
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
use super::ui_points as p;
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
registry.register(Arc::new(UiPoint::new(p::STATUS_BAR, ui.clone(), p::status_item)))?;
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
Ok(())
}
+342
View File
@@ -0,0 +1,342 @@
//! Editor providers of plugins: completion, inline
//! completion, code actions, go to definition and gutter line markers, for
//! the languages a plugin declares. The host calls only plugins whose
//! declaration matches, so the others stay unloaded.
//!
//! ```toml
//! [[contributes.providers]]
//! kinds = ["definition", "line-markers"]
//! languages = ["java", "xml"] # editor language ids; "*" for any
//! ```
//!
//! And `[[contributes.secrets]]`: secrets the user can store for the plugin
//! (shown in Settings › Plugins), used only through `net` request headers.
//!
//! ```toml
//! [[contributes.secrets]]
//! name = "api-key"
//! label = "API key"
//! description = "Create one at https://example.com/keys"
//! ```
use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
pub const POINT_ID: &str = "providers";
pub const SECRETS_POINT: &str = "secrets";
/// What a plugin can provide in the editor.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "kebab-case")]
pub enum ProviderKind {
Completion,
InlineCompletion,
CodeActions,
Definition,
LineMarkers,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
kinds: Vec<ProviderKind>,
languages: Vec<String>,
}
/// (sequence, plugin, kinds, languages) of one declaration.
type Declared = (u64, String, Vec<ProviderKind>, Vec<String>);
/// Provider declarations of enabled plugins.
#[derive(Default)]
pub struct Providers {
items: RwLock<Vec<Declared>>,
seq: AtomicU64,
}
impl Providers {
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, ..)| *s != seq);
}
})
}
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for (_, plugin, kinds, languages) in self.items.read().iter() {
if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) {
out.push(plugin.clone());
}
}
out
}
/// Whether any plugin provides `kind` (for any language).
pub fn any(&self, kind: ProviderKind) -> bool {
self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind))
}
}
pub struct ProvidersPoint {
store: Arc<Providers>,
}
impl ProvidersPoint {
pub fn new(store: Arc<Providers>) -> Self {
Self { store }
}
}
impl ExtensionPoint for ProvidersPoint {
fn id(&self) -> &'static str {
POINT_ID
}
fn placement(&self) -> Placement {
Placement::Workspace
}
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let d: Decl = decode(POINT_ID, decl)?;
if pkg.manifest.wasm.is_none() {
return Err("contributes.providers: providers need code (`wasm`)".into());
}
if d.kinds.is_empty() || d.languages.is_empty() || d.languages.len() > 64 {
return Err("contributes.providers: give `kinds` and 1–64 `languages`".into());
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
|| l.len() > 64
|| !l.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c)))
{
return Err(format!("contributes.providers: invalid language id {l:?}"));
}
}
let label = format!(
"{} for {}",
d.kinds
.iter()
.map(|k| serde_json::to_value(k).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default())
.collect::<Vec<_>>()
.join(", "),
d.languages.join(", ")
);
let (store, plugin) = (self.store.clone(), pkg.id().to_owned());
Ok(Prepared::new(label, move || Ok(store.add(plugin, d))))
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct SecretDecl {
name: String,
label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
/// `[[contributes.secrets]]` (a UI point: Settings › Plugins lists them).
pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: SecretDecl = decode(SECRETS_POINT, decl)?;
validate_item_id(&d.name, "secret name")?;
if d.label.trim().is_empty() || d.label.len() > 128 || d.description.as_ref().is_some_and(|x| x.len() > 1024) {
return Err(format!("secret {:?}: `label` 1–128 bytes, `description` up to 1 KiB", d.name));
}
let json = serde_json::to_value(&d).map_err(|e| e.to_string())?;
Ok(UiDecl::new(d.name.clone(), d.label, json))
}
// ---- dispatch ---------------------------------------------------------------
use super::{LoadedPlugin, PluginHost, PluginStatus};
use ide_core::CancellationToken;
use ide_plugin::{
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
ProviderDocument, Runtime,
};
use std::sync::Weak;
use std::time::{Duration, Instant};
/// How long a superseded inline completion may take to return after its
/// request was cancelled before it is interrupted.
const CANCEL_GRACE: Duration = Duration::from_millis(250);
static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
/// Makes `host`'s plugin providers part of completion, code actions, go to
/// definition and inline completion (`None` removes them).
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
}
/// The plugin host whose providers take part, when any declares `kind`.
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?;
host.providers.any(kind).then_some(host)
}
impl PluginHost {
/// Usable plugins declaring `kind` for `language`.
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
self.providers
.plugins_for(kind, language)
.into_iter()
.filter_map(|id| self.get(&id).ok())
.filter(|p| {
let m = p.meta.lock();
matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty()
})
.collect()
}
/// Edit-path calls (soft / hard deadline) to every usable plugin that
/// declares `kind` for `language`, in plugin order, leaving out those that
/// failed or were late. Each plugin runs on its own thread, so the calls
/// run at the same time: the wait is the slowest plugin's, not the sum.
fn edit_calls<R: Send + 'static>(
&self,
kind: ProviderKind,
language: &str,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard);
let queued: Vec<_> = self
.provider_plugins(kind, language)
.into_iter()
.filter_map(|plugin| {
let (actor, activated) = self.ensure_active(&plugin, None).ok()?;
if activated {
self.changed();
}
let f = f.clone();
let started = Instant::now();
let call = actor.submit(Some(soft), move |rt| f(rt, hard));
Some((plugin, actor, started, call))
})
.collect();
queued
.into_iter()
.filter_map(|(plugin, actor, started, call)| {
let result = call.and_then(Pending::wait);
self.finish_provider_call(&plugin, &actor, started, result).map(|r| (plugin, r))
})
.collect()
}
pub(super) fn finish_provider_call<R>(
&self,
plugin: &Arc<LoadedPlugin>,
actor: &Arc<ide_plugin::Actor>,
started: Instant,
result: Result<R, ActorError>,
) -> Option<R> {
let elapsed = started.elapsed();
match result {
Ok(r) => {
self.record_call(plugin, elapsed, true, true);
Some(r)
}
Err(ActorError::SoftTimeout) => {
plugin.meta.lock().stats.slow_calls += 1;
None
}
Err(ActorError::Cancelled) => None,
Err(ActorError::Call(CallError::Guest(e))) => {
self.record_call(plugin, elapsed, !e.is_failure(), true);
tracing::debug!(plugin = %plugin.id(), "provider: {e}");
None
}
Err(ActorError::Call(CallError::Trap(_)) | ActorError::Stopped) => {
self.record_call(plugin, elapsed, false, false);
self.reap(plugin, actor, None);
None
}
}
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
})
.into_iter()
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
.collect()
}
pub fn provide_code_actions(
&self,
doc: &ProviderDocument,
selection: ide_core::Range,
) -> Vec<(String, CodeAction)> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::CodeActions, &doc.language, move |rt, timeout| {
rt.provide_code_actions(&d, selection, timeout)
})
.into_iter()
.flat_map(|(p, actions)| actions.into_iter().map(move |a| (p.id().to_owned(), a)))
.collect()
}
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
rt.provide_definition(&d, at, timeout)
})
.into_iter()
.flat_map(|(_, locations)| locations)
.collect()
}
pub fn provide_line_markers(&self, doc: &ProviderDocument) -> Vec<(String, LineMarker)> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::LineMarkers, &doc.language, move |rt, timeout| {
rt.provide_line_markers(&d, timeout)
})
.into_iter()
.flat_map(|(p, markers)| markers.into_iter().map(move |m| (p.id().to_owned(), m)))
.collect()
}
/// Ghost text from the first plugin that has some. Not bound by the
/// edit-path soft deadline (it may need the network) but by `cancel`:
/// the editor cancels a request as soon as the user types on.
pub fn provide_inline_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
cancel: &CancellationToken,
) -> Option<(String, InlineSuggestion)> {
for p in self.provider_plugins(ProviderKind::InlineCompletion, &doc.language) {
if cancel.is_cancelled() {
return None;
}
let Ok((actor, activated)) = self.ensure_active(&p, None) else { continue };
if activated {
self.changed();
}
let (d, timeout) = (doc.clone(), self.limits.timeout);
let started = Instant::now();
let result =
actor.call_cancellable(cancel, CANCEL_GRACE, move |rt| rt.provide_inline_completion(&d, at, timeout));
if let Some(Some(s)) = self.finish_provider_call(&p, &actor, started, result) {
return Some((p.id().to_owned(), s));
}
}
None
}
}
+222
View File
@@ -0,0 +1,222 @@
//! Plugin languages of a remote project: `Workspace` contributions take effect where the project is, so the
//! desktop pushes its plugins' language definitions to `openidea-server`
//! (`plugin_sync_languages`) together with the `process:` programs the user
//! granted. The server resolves each granted program on its own `PATH`, pins
//! the file (path + hash, kept in `plugin-pins.json`) the first time and
//! starts only that file afterwards; a program missing from a sync is revoked.
//! Revoking and granting again on the desktop re-pins a replaced program.
use ide_extension::Capability;
use ide_extension::ledger::{self, BinaryGrant};
use ide_language::plugin_point::{PluginLanguages, validate_definition};
use ide_language::{LanguageConfig, ProgramPin};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
pub const PINS_FILE: &str = "plugin-pins.json";
/// The server command the desktop calls.
pub const SYNC_COMMAND: &str = "plugin_sync_languages";
/// One plugin language as the desktop sends it.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteLanguage {
pub plugin: String,
pub language: LanguageConfig,
/// Program names of the plugin's granted `process:` capabilities.
#[serde(default)]
pub granted: Vec<String>,
}
/// What the server made of a sync.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SyncReport {
pub languages: usize,
/// `plugin: program` → the file it starts.
pub pinned: BTreeMap<String, PathBuf>,
/// Granted programs not found on this machine's `PATH`.
pub unresolved: Vec<String>,
/// Definitions that failed validation (not applied).
pub rejected: Vec<String>,
}
#[derive(Default, Serialize, Deserialize)]
struct PinsFile {
/// `plugin\nprocess:program` → the approved file.
#[serde(default)]
pins: BTreeMap<String, BinaryGrant>,
}
type PathVar = Arc<dyn Fn() -> Option<std::ffi::OsString> + Send + Sync>;
/// The server's side: an overlay for its language registry, filled by syncs.
pub struct RemotePluginLanguages {
store: Arc<PluginLanguages>,
pins: Arc<Mutex<BTreeMap<String, BinaryGrant>>>,
file: Option<PathBuf>,
applied: Mutex<Vec<ide_extension::Disposable>>,
path_var: PathVar,
}
fn key(plugin: &str, cap: &Capability) -> String {
format!("{plugin}\n{cap}")
}
impl RemotePluginLanguages {
/// Pins persist in `data_dir` (none: in memory, for tests).
pub fn new(data_dir: Option<&std::path::Path>) -> Arc<Self> {
Self::with_path(data_dir, Arc::new(|| std::env::var_os("PATH")))
}
pub fn with_path(data_dir: Option<&std::path::Path>, path_var: PathVar) -> Arc<Self> {
let file = data_dir.map(|d| d.join(PINS_FILE));
let saved: PinsFile = file
.as_ref()
.and_then(|f| std::fs::read_to_string(f).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let pins = Arc::new(Mutex::new(saved.pins));
let store = PluginLanguages::new();
let policy_pins = pins.clone();
store.set_process_policy(Arc::new(move |plugin, cap| {
policy_pins.lock().get(&key(plugin, cap)).map(|b| ProgramPin { path: b.path.clone(), hash: b.hash.clone() })
}));
Arc::new(Self { store, pins, file, applied: Mutex::default(), path_var })
}
/// For [`ide_language::LanguageRegistry::attach_plugins`].
pub fn store(&self) -> &Arc<PluginLanguages> {
&self.store
}
/// Replaces every plugin language with `languages`.
pub fn sync(&self, languages: Vec<RemoteLanguage>) -> SyncReport {
let mut report = SyncReport::default();
let mut wanted: BTreeMap<String, (String, Capability)> = BTreeMap::new();
let mut accepted = Vec::new();
for l in languages {
if let Err(e) = validate_definition(&l.language) {
report.rejected.push(format!("{} ({}): {e}", l.language.id, l.plugin));
continue;
}
for program in &l.granted {
let cap = Capability::Process(program.clone());
// Only programs the definition starts: a grant is not a licence for anything else.
if ide_language::plugin_point::required_capabilities(&l.language).contains(&cap) {
wanted.insert(key(&l.plugin, &cap), (program.clone(), cap));
}
}
accepted.push(l);
}
{
let mut pins = self.pins.lock();
pins.retain(|k, _| wanted.contains_key(k));
for (k, (program, _)) in &wanted {
if pins.contains_key(k) {
continue;
}
let resolved = ledger::resolve_program(program, (self.path_var)().as_deref()).and_then(|path| {
ledger::file_hash(&path).map(|hash| BinaryGrant { path, hash }).map_err(|e| e.to_string())
});
match resolved {
Ok(bin) => {
pins.insert(k.clone(), bin);
}
Err(e) => {
tracing::info!("process:{program}: {e}");
report.unresolved.push(program.clone());
}
}
}
for (k, bin) in pins.iter() {
report.pinned.insert(k.replace("\nprocess:", ": "), bin.path.clone());
}
self.save(&pins);
}
let mut applied = self.applied.lock();
applied.clear(); // disposes the previous sync's languages
for l in accepted {
applied.push(self.store.add(l.plugin, l.language));
}
report.languages = applied.len();
self.store.invalidate();
report
}
fn save(&self, pins: &BTreeMap<String, BinaryGrant>) {
let Some(file) = &self.file else { return };
let json = serde_json::to_string_pretty(&PinsFile { pins: pins.clone() }).unwrap_or_default();
let tmp = file.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, json).and_then(|()| std::fs::rename(&tmp, file)) {
tracing::warn!("saving {}: {e}", file.display());
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use ide_core::LanguageId;
use ide_language::LanguageRegistry;
use std::os::unix::fs::PermissionsExt;
fn toy(granted: &[&str]) -> RemoteLanguage {
let language = ide_language::plugin_point::parse_definition(
"id = \"toy\"\nname = \"Toy\"\nextensions = [\"toy\"]\n\
[[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n",
)
.unwrap();
RemoteLanguage {
plugin: "acme.toy".into(),
language,
granted: granted.iter().map(|s| (*s).to_owned()).collect(),
}
}
#[test]
fn syncs_languages_and_pins_granted_programs_on_this_machine() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir(&bin).unwrap();
std::fs::write(bin.join("toylsp"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(bin.join("toylsp"), std::fs::Permissions::from_mode(0o755)).unwrap();
let path = bin.clone().into_os_string();
let remote = RemotePluginLanguages::with_path(Some(tmp.path()), Arc::new(move || Some(path.clone())));
let registry = LanguageRegistry::load(None).unwrap();
registry.attach_plugins(remote.store().clone());
let project = registry.fork();
let id = LanguageId::new("toy");
// Not granted: the language, no server.
let report = remote.sync(vec![toy(&[])]);
assert_eq!(report.languages, 1);
assert_eq!(project.detect(std::path::Path::new("/r/a.toy"), None), id);
assert!(project.get(&id).unwrap().language_servers.is_empty());
// Granted: resolved here and pinned; persisted.
let report = remote.sync(vec![toy(&["toylsp", "rm"])]);
assert_eq!(report.pinned.len(), 1, "only programs the definition starts: {report:?}");
let pin = project.get(&id).unwrap().language_servers[0].pinned.clone().unwrap();
assert!(pin.verify().is_ok());
assert!(tmp.path().join(PINS_FILE).is_file());
// Replaced: refused at start until revoked and granted again.
std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho x\n").unwrap();
remote.sync(vec![toy(&["toylsp"])]);
assert!(project.get(&id).unwrap().language_servers[0].pinned.as_ref().unwrap().verify().is_err());
remote.sync(vec![toy(&[])]);
remote.sync(vec![toy(&["toylsp"])]);
assert!(project.get(&id).unwrap().language_servers[0].pinned.as_ref().unwrap().verify().is_ok());
// Missing programs are reported; invalid definitions rejected; empty sync removes all.
let mut bad = toy(&[]);
bad.language.id = LanguageId::new("a b");
let report = remote.sync(vec![bad]);
assert_eq!(report.rejected.len(), 1);
assert_eq!(project.detect(std::path::Path::new("/r/a.toy"), None), LanguageId::plain_text());
}
}
+504
View File
@@ -0,0 +1,504 @@
//! Code plugins of remote projects:
//! Wasm runs where the project is, so `openidea-server` hosts the desktop's
//! code plugins and they read, search, run processes and publish diagnostics
//! on the remote machine.
//!
//! The desktop stays the authority over what is installed and granted. It
//! pushes its live code plugins with [`SYNC_PACKAGES`]: first their digests
//! and grants, then the files of those the server doesn't have (one package
//! per request); a package missing from a sync is uninstalled. The server
//! installs them under `<data>/remote-plugins` and records the desktop's
//! grants (a `process:` grant is pinned to the program on this machine's
//! `PATH`).
//!
//! The commands a window needs at run time (`plugin_run_command`,
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
//! `plugin_cancel_task`, `plugin_set_active_file`, `plugin_line_markers`,
//! `plugin_view_render`, `plugin_view_event`)
//! carry the desktop commands' names and arguments, so a remote window's UI
//! routes them here unchanged. Plugin results come back as `plugin`
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
//! keychain and are asked for with a `plugin_secret` request
//! ([`SecretRequest`]) when a request header needs one (rule 4).
use super::{
Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore,
};
use crate::rpc::Dispatcher;
use base64::Engine as _;
use ide_core::{IdeError, IdeResult};
use ide_extension::{MANIFEST_FILE, ledger};
use ide_jsonrpc::Peer;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;
/// The server command the desktop calls with its packages.
pub const SYNC_PACKAGES: &str = "plugin_sync_packages";
/// Server → client notification carrying a [`PluginEventMessage`].
pub const EVENT_NOTIFICATION: &str = "plugin";
/// Server → client request ([`SecretRequest`] → `Option<String>`).
pub const SECRET_REQUEST: &str = "plugin_secret";
/// Under the server's data directory.
pub const PLUGINS_DIR: &str = "remote-plugins";
const INCOMING_DIR: &str = "remote-plugins.incoming";
const SECRET_TIMEOUT: Duration = Duration::from_secs(10);
const EVENT_FLUSH: Duration = Duration::from_millis(250);
const TICKS_PER_SUPERVISION: u32 = 20;
/// One live code plugin of the desktop.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemotePackage {
pub id: String,
/// [`package_digest`] of the package.
pub digest: String,
/// Capabilities the user granted on the desktop.
pub granted: Vec<String>,
}
/// One file of a package (`path` relative to the package, `data` base64).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PackageFile {
pub path: String,
pub data: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageSyncReport {
/// Packages the server needs the files of (send them in another sync).
pub missing: Vec<String>,
pub installed: Vec<String>,
pub removed: Vec<String>,
/// `id: reason`.
pub failed: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PluginEventMessage {
/// The window that invoked the plugin (`None`: every window of the connection).
pub window: Option<String>,
pub event: PluginEvent,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SecretRequest {
pub plugin: String,
pub name: String,
}
/// Identifies a package's content: the manifest, the files its declarations
/// reference and the component.
pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
let mut all = files.to_vec();
all.push(PathBuf::from(MANIFEST_FILE));
Ok(ledger::package_hash(dir, &all)?)
}
/// Whether a plugin event means something to a remote window (the desktop
/// owns the plugin list, themes and consent).
pub fn forwarded(event: &PluginEvent) -> bool {
matches!(
event,
PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::TaskStarted { .. }
| PluginEvent::TaskProgress { .. }
| PluginEvent::TaskEnded { .. }
| PluginEvent::SubscriptionsChanged
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
)
}
// ---- desktop side -----------------------------------------------------------
impl PluginHost {
fn package_files(&self, plugin: &super::LoadedPlugin) -> IdeResult<Vec<PathBuf>> {
let set = self.registry.prepare(&plugin.pkg).map_err(IdeError::invalid)?;
Ok(set.files.into_iter().chain(plugin.manifest().wasm.clone()).collect())
}
/// The live code plugins (installed or linked) for a remote project's server.
pub fn remote_packages(&self) -> Vec<RemotePackage> {
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
plugins
.iter()
.filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some())
.filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive))
.filter_map(|p| {
let files = self.package_files(p).ok()?;
let digest = package_digest(p.dir(), &files).ok()?;
let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect();
Some(RemotePackage { id: p.id().to_owned(), digest, granted })
})
.collect()
}
/// The files of a package for [`SYNC_PACKAGES`], the manifest first.
pub fn remote_package_files(&self, id: &str) -> IdeResult<Vec<PackageFile>> {
let plugin = self.get(id)?;
let files = self.package_files(&plugin)?;
std::iter::once(PathBuf::from(MANIFEST_FILE))
.chain(files)
.map(|rel| {
let bytes = std::fs::read(plugin.dir().join(&rel))?;
let path = rel.to_str().ok_or_else(|| IdeError::invalid("non UTF-8 file name"))?.replace('\\', "/");
Ok(PackageFile { path, data: base64::engine::general_purpose::STANDARD.encode(bytes) })
})
.collect()
}
/// A secret a plugin declares, for its remote instance (`plugin_secret`).
pub fn declared_secret(&self, id: &str, name: &str) -> Option<String> {
let plugin = self.get(id).ok()?;
if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == name) {
return None;
}
let store = self.shared.secrets.read().clone();
store.get(plugin.id(), name)
}
}
// ---- server side ------------------------------------------------------------
/// The plugin host of `openidea-server`.
pub struct RemotePlugins {
host: Arc<PluginHost>,
incoming: PathBuf,
sync: Mutex<()>,
}
/// Secrets from the desktop's keychain, asked for when used.
struct ClientSecrets {
peer: Arc<OnceLock<Peer>>,
runtime: tokio::runtime::Handle,
}
impl SecretStore for ClientSecrets {
fn get(&self, plugin: &str, name: &str) -> Option<String> {
let peer = self.peer.get()?.clone();
let request = SecretRequest { plugin: plugin.to_owned(), name: name.to_owned() };
let ask = async move {
tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Option<String>>(SECRET_REQUEST, request, None)).await
};
// Plugin threads block on the runtime; a runtime worker (a host
// command asking) must leave its thread to the other tasks first.
let answer = if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(|| self.runtime.block_on(ask))
} else {
self.runtime.block_on(ask)
};
match answer {
Ok(Ok(value)) => value,
Ok(Err(e)) => {
tracing::warn!("plugin secret {plugin}/{name}: {e}");
None
}
Err(_) => None,
}
}
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
Err("secrets are stored on the desktop".into())
}
}
impl RemotePlugins {
/// Creates the host over `<data_dir>/remote-plugins`, loads what earlier
/// sessions installed and starts event delivery and supervision.
pub fn start(data_dir: &Path, projects: &Arc<crate::Host>, peer: Arc<OnceLock<Peer>>) -> IdeResult<Arc<Self>> {
let sink_peer = peer.clone();
let sink = move |client: Option<&str>, event: PluginEvent| {
if forwarded(&event)
&& let Some(p) = sink_peer.get()
{
let _ = p.notify(EVENT_NOTIFICATION, PluginEventMessage { window: client.map(str::to_owned), event });
}
};
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() }));
let trust = Arc::downgrade(projects);
host.set_project_policy(Arc::new(move |root| {
trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false))
}));
super::set_editor_providers(Some(&host));
ide_language::set_external_syntax(Some(host.syntax_provider()));
ide_run::set_external_detection(Some(host.run_detection()));
host.load_all();
host.startup_finished();
spawn_supervision(Arc::downgrade(&host))?;
Ok(Arc::new(Self { host, incoming: data_dir.join(INCOMING_DIR), sync: Mutex::new(()) }))
}
pub fn host(&self) -> &Arc<PluginHost> {
&self.host
}
/// Makes the installed packages match `packages`: uninstalls the others,
/// follows changed grants, installs those with `files` and reports the
/// rest as missing.
pub fn sync(
&self,
packages: Vec<RemotePackage>,
mut files: BTreeMap<String, Vec<PackageFile>>,
) -> PackageSyncReport {
let _guard = self.sync.lock();
let mut report = PackageSyncReport::default();
let wanted: BTreeSet<&str> = packages.iter().map(|p| p.id.as_str()).collect();
let installed: Vec<String> = self
.host
.plugins
.read()
.values()
.filter(|p| p.source == PluginSource::Installed)
.map(|p| p.id().to_owned())
.collect();
for id in installed.iter().filter(|id| !wanted.contains(id.as_str())) {
match self.host.uninstall(id) {
Ok(()) => report.removed.push(id.clone()),
Err(e) => report.failed.push(format!("{id}: {e}")),
}
}
for pkg in packages {
if !valid_id(&pkg.id) {
report.failed.push(format!("{}: invalid plugin id", pkg.id));
continue;
}
let consent = Consent { optional: pkg.granted.clone() };
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
if let Err(e) = self.follow_grants(&pkg, &consent) {
report.failed.push(format!("{}: {e}", pkg.id));
}
continue;
}
match files.remove(&pkg.id) {
None => report.missing.push(pkg.id),
Some(list) => match self.install(&pkg, list, &consent) {
Ok(()) => report.installed.push(pkg.id),
Err(e) => report.failed.push(format!("{}: {}", pkg.id, e.message)),
},
}
}
report
}
fn current_digest(&self, id: &str) -> Option<String> {
let plugin = self.host.plugins.read().get(id).cloned()?;
if plugin.source != PluginSource::Installed || plugin.invalid {
return None;
}
let files = self.host.package_files(&plugin).ok()?;
package_digest(plugin.dir(), &files).ok()
}
fn follow_grants(&self, pkg: &RemotePackage, consent: &Consent) -> IdeResult<()> {
let info = self.host.info_of(&pkg.id)?;
let have: BTreeSet<&str> = info.capabilities.iter().filter(|c| c.granted).map(|c| c.id.as_str()).collect();
let want: BTreeSet<&str> = pkg.granted.iter().map(String::as_str).collect();
if have != want || info.status == super::PluginStatus::NeedsConsent {
self.host.grant(&pkg.id, consent)?;
}
Ok(())
}
fn install(&self, pkg: &RemotePackage, files: Vec<PackageFile>, consent: &Consent) -> IdeResult<()> {
let dir = self.incoming.join(&pkg.id);
let _ = std::fs::remove_dir_all(&dir);
let result = (|| {
let mut rels = Vec::new();
for f in files {
let rel = safe_relative(&f.path)?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(f.data.as_bytes())
.map_err(|e| IdeError::invalid(format!("{}: {e}", f.path)))?;
let to = dir.join(&rel);
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&to, bytes)?;
if rel != Path::new(MANIFEST_FILE) {
rels.push(rel);
}
}
if package_digest(&dir, &rels)? != pkg.digest {
return Err(IdeError::invalid("the package arrived incomplete or changed"));
}
let info = self.host.install_from_dir(&dir, consent)?;
if info.id != pkg.id {
let _ = self.host.uninstall(&info.id);
return Err(IdeError::invalid(format!("the package is plugin {:?}", info.id)));
}
Ok(())
})();
let _ = std::fs::remove_dir_all(&dir);
result
}
/// The run-time commands of remote windows plus [`SYNC_PACKAGES`].
pub fn register(self: &Arc<Self>, d: &mut Dispatcher) {
let this = self.clone();
d.add(SYNC_PACKAGES, move |c| {
let this = this.clone();
async move {
let packages: Vec<RemotePackage> = c.arg("packages")?;
let files: Option<BTreeMap<String, Vec<PackageFile>>> = c.arg("files")?;
blocking(move || Ok(this.sync(packages, files.unwrap_or_default()))).await
}
});
let host = Arc::downgrade(&self.host);
let h = host.clone();
d.add("plugin_run_command", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let plugin_id: String = c.arg("pluginId")?;
let command_id: String = c.arg("commandId")?;
let arg: Option<String> = c.arg("arg")?;
let editor: Option<PluginEditor> = c.arg("editor")?;
let invocation = Invocation {
client: Some(c.window.clone()),
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
editor: editor.map(PluginEditor::context),
};
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
});
let h = host.clone();
d.add("plugin_post_events", move |c| {
let h = h.clone();
async move {
alive(&h)?.post_events(Some(&c.window), c.arg("events")?);
Ok(())
}
});
let h = host.clone();
d.add("plugin_event_kinds", move |_| {
let h = h.clone();
async move { Ok(alive(&h)?.event_kinds().into_iter().collect::<Vec<_>>()) }
});
let h = host.clone();
d.add("plugin_status_items", move |_| {
let h = h.clone();
async move {
Ok(alive(&h)?
.status_items()
.into_iter()
.map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item })
.collect::<Vec<_>>())
}
});
let h = host.clone();
d.add("plugin_cancel_task", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.cancel_task(&c.arg::<String>("taskId")?)) }
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
async move {
alive(&h)?.set_active_file(&c.window, c.arg("path")?);
Ok(())
}
});
let h = host.clone();
d.add("plugin_view_render", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
super::views::render(
&c.host,
plugins,
&c.window,
c.arg("pluginId")?,
c.arg("view")?,
c.arg("viewport")?,
c.arg("doc")?,
c.arg("editor")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_view_event", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
super::views::event(
&c.host,
plugins,
&c.window,
c.arg("pluginId")?,
c.arg("view")?,
c.arg("event")?,
c.arg("doc")?,
c.arg("editor")?,
)
.await
}
});
d.add("plugin_line_markers", |c| async move {
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
});
}
}
fn alive(host: &Weak<PluginHost>) -> IdeResult<Arc<PluginHost>> {
host.upgrade().ok_or_else(|| IdeError::unavailable("the plugin host has shut down"))
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
fn spawn_supervision(host: Weak<PluginHost>) -> IdeResult<()> {
std::thread::Builder::new().name("remote-plugins".into()).spawn(move || {
for n in 1u32.. {
std::thread::sleep(EVENT_FLUSH);
let Some(host) = host.upgrade() else { return };
host.flush_events();
if n % TICKS_PER_SUPERVISION == 0 {
host.tick();
}
}
})?;
Ok(())
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& !id.starts_with('.')
&& id.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
}
/// `path` as a relative path inside a package (no `..`, no root).
fn safe_relative(path: &str) -> IdeResult<PathBuf> {
let p = Path::new(path);
if path.is_empty() || !p.components().all(|c| matches!(c, Component::Normal(_))) {
return Err(IdeError::invalid(format!("invalid file path {path:?} in package")));
}
Ok(p.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_paths_stay_inside_the_package() {
assert!(safe_relative("dist/plugin.wasm").is_ok());
for bad in ["", "../x", "/etc/passwd", "a/../../b", "./x"] {
assert!(safe_relative(bad).is_err(), "{bad}");
}
assert!(valid_id("ai-completion"));
assert!(!valid_id("../x") && !valid_id(".data") && !valid_id(""));
}
}
@@ -0,0 +1,167 @@
//! The `runDetectors` extension point: run configurations a plugin offers in
//! projects that contain a marker file.
//!
//! ```toml
//! [[contributes.runDetectors]]
//! id = "forge-test"
//! name = "forge test"
//! files = ["foundry.toml"] # any of these at the project root
//! command = "forge"
//! args = ["test"]
//! test = true # results go to the test tree
//! ```
//!
//! The command is a `process:` capability like a language server's: the
//! configuration is offered only while it is granted, and starts the approved
//! file (checked right before it starts).
use ide_extension::{
Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, check_relative, decode, validate_item_id,
};
use ide_language::ProgramPin;
use ide_run::{EXTERNAL_PREFIX, ExternalDetection, RunConfig};
use parking_lot::RwLock;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
pub const POINT_ID: &str = "runDetectors";
const MAX_FILES: usize = 16;
const MAX_ARGS: usize = 64;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Detector {
pub id: String,
pub name: String,
pub files: Vec<PathBuf>,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub test: bool,
}
/// Detectors of enabled plugins.
#[derive(Default)]
pub struct RunDetectors {
items: RwLock<Vec<(u64, String, Detector)>>,
seq: AtomicU64,
}
impl RunDetectors {
fn add(self: &Arc<Self>, plugin: String, detector: Detector) -> Disposable {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, detector));
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, _, _)| *s != seq);
}
})
}
}
pub struct RunDetectorsPoint {
store: Arc<RunDetectors>,
}
impl RunDetectorsPoint {
pub fn new(store: Arc<RunDetectors>) -> Self {
Self { store }
}
}
impl ExtensionPoint for RunDetectorsPoint {
fn id(&self) -> &'static str {
POINT_ID
}
fn placement(&self) -> Placement {
Placement::Workspace
}
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let d: Detector = decode(POINT_ID, decl)?;
validate_item_id(&d.id, "run detector id")?;
if d.name.trim().is_empty() || d.name.len() > 128 {
return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id));
}
if d.files.is_empty() || d.files.len() > MAX_FILES {
return Err(format!("run detector {:?}: give 1–{MAX_FILES} marker `files`", d.id));
}
for f in &d.files {
check_relative(f, "files")?;
}
let program = d.command.trim();
if program.is_empty() || program.contains(char::is_whitespace) {
return Err(format!("run detector {:?}: `command` is one program name (arguments go in `args`)", d.id));
}
if d.args.len() > MAX_ARGS {
return Err(format!("run detector {:?}: at most {MAX_ARGS} arguments", d.id));
}
let cap = Capability::Process(program.to_owned());
let (store, plugin, label) = (self.store.clone(), pkg.id().to_owned(), d.name.clone());
let key = d.id.clone();
Ok(Prepared::new(label, move || Ok(store.add(plugin, d))).key(key).capability(cap))
}
}
type Pins = Arc<RwLock<HashMap<(String, String), ProgramPin>>>;
/// [`ExternalDetection`] over the detectors and the approved programs.
pub struct PluginRunDetection {
pub(crate) store: Arc<RunDetectors>,
pub(crate) pins: Pins,
}
fn config_id(plugin: &str, detector: &str) -> String {
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
}
impl PluginRunDetection {
fn pin(&self, plugin: &str, d: &Detector) -> Option<ProgramPin> {
let cap = Capability::Process(d.command.trim().to_owned());
self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
}
}
impl ExternalDetection for PluginRunDetection {
fn detect(&self, root: &Path) -> Vec<RunConfig> {
let items = self.store.items.read().clone();
items
.into_iter()
.filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists()))
.map(|(_, plugin, d)| RunConfig {
id: config_id(&plugin, &d.id),
name: d.name.clone(),
kind: "plugin".into(),
command: d.command.trim().to_owned(),
args: d.args.clone(),
cwd: None,
env: Default::default(),
detected: true,
is_test: d.test,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
})
.collect()
}
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String> {
let items = self.store.items.read();
let (plugin, d) = items
.iter()
.find(|(_, plugin, d)| config_id(plugin, &d.id) == config.id)
.map(|(_, p, d)| (p.clone(), d.clone()))
.ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?;
drop(items);
let pin = self.pin(&plugin, &d).ok_or_else(|| {
format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command)
})?;
pin.verify().map(Path::to_path_buf)
}
}
@@ -1,9 +1,21 @@
//! Theme contributions: design-token overrides applied as CSS variables on `:root`.
//! Theme and editor-scheme files are JSON that may contain `//` and `/* */` comments.
//! The `themes` extension point: design-token overrides applied as CSS
//! variables on `:root`. Theme and editor-scheme files are JSON that may
//! contain `//` and `/* */` comments.
//!
//! ```toml
//! [[contributes.themes]]
//! path = "themes/harbor-night.json"
//! editor-scheme = "themes/harbor-night.scheme.json" # optional
//! ```
use ide_extension::{ExtensionPoint, Package, Placement, Prepared, UiContribution, UiStore, decode};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
pub const POINT_ID: &str = "themes";
const MAX_TOKENS: usize = 512;
const MAX_VALUE_LEN: usize = 256;
@@ -48,7 +60,7 @@ impl ThemeDef {
}
pub fn validate(&self) -> Result<(), String> {
crate::manifest::validate_command_id(&self.id).map_err(|e| e.replace("command id", "theme id"))?;
ide_extension::validate_item_id(&self.id, "theme id")?;
if self.name.trim().is_empty() {
return Err("theme name must not be empty".into());
}
@@ -71,6 +83,55 @@ impl ThemeDef {
}
}
pub struct ThemesPoint {
store: Arc<UiStore>,
}
impl ThemesPoint {
pub fn new(store: Arc<UiStore>) -> Self {
Self { store }
}
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct Decl {
path: PathBuf,
#[serde(default)]
editor_scheme: Option<PathBuf>,
}
impl ExtensionPoint for ThemesPoint {
fn id(&self) -> &'static str {
POINT_ID
}
fn placement(&self) -> Placement {
Placement::Ui
}
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let decl: Decl = decode(POINT_ID, decl)?;
let path = &decl.path;
let mut theme = ThemeDef::parse(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
let mut files = vec![path.clone()];
if let Some(scheme) = &decl.editor_scheme {
files.push(scheme.clone());
theme.merge_editor_scheme(&pkg.read_text(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?;
}
let item = UiContribution {
plugin_id: pkg.id().to_owned(),
point: POINT_ID.into(),
key: theme.id.clone(),
decl: serde_json::to_value(&theme).map_err(|e| e.to_string())?,
};
let store = self.store.clone();
let mut prepared = Prepared::new(theme.name.clone(), move || Ok(store.insert(item))).key(theme.id);
prepared.files = files;
Ok(prepared)
}
}
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
/// parse errors still point at the right line.
fn strip_json_comments(src: &str) -> String {
+236
View File
@@ -0,0 +1,236 @@
//! UI-facing plugin types and the event stream.
use super::theme::ThemeDef;
use ide_extension::{CapabilityInfo, ContributionInfo, UiContribution};
pub use ide_plugin::{ActionPerformed, ActionSource, EventKind, NotificationLevel, StatusItem, WorkbenchEvent};
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PluginSource {
/// Installed under `<app-data>/plugins/<id>/`.
Installed,
/// Loaded from `<project>/.openidea/plugins/<dir>/` while that project is open.
Dev,
/// A development plugin linked from any folder (`openidea-plugin link`).
Linked,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PluginStatus {
/// Code instantiated and activated.
Active,
/// Has code that has not been needed yet: contributions are live, no Wasm instance.
Inactive,
/// No code; contributions only.
Static,
Disabled,
/// Waiting for the user to confirm its permissions (project and new plugins).
NeedsConsent,
/// Failed to load, or failed repeatedly and was stopped for this session (see `error`).
Failed,
}
impl PluginStatus {
/// Contributions are in effect.
pub fn is_live(self) -> bool {
matches!(self, Self::Active | Self::Inactive | Self::Static)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
pub source: PluginSource,
/// Package directory.
pub path: ide_core::IpcPath,
pub enabled: bool,
pub status: PluginStatus,
pub error: Option<String>,
pub has_code: bool,
/// Declared capabilities with risk and grant state.
pub capabilities: Vec<CapabilityInfo>,
/// What it contributes while live (point + label).
pub contributions: Vec<ContributionInfo>,
/// `activation` events of the manifest.
pub activation: Vec<String>,
/// Non-fatal problems (unsupported contribution points).
pub warnings: Vec<String>,
/// Statistics of this session.
pub stats: PluginStats,
/// The marketplace publisher whose signature the package carries (none: unsigned or untrusted).
pub publisher: Option<String>,
/// The version an update replaced, to roll back to.
pub previous_version: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginStats {
pub activations: u32,
/// Last activation (instantiate + `activate`), ms.
pub activation_ms: f64,
pub calls: u32,
pub errors: u32,
/// Slowest call, ms.
pub max_call_ms: f64,
/// 95th percentile of the recent calls, ms.
pub p95_call_ms: f64,
/// Edit-path calls whose result came too late and was dropped.
pub slow_calls: u32,
/// Traps (panics, budgets, permission violations); each drops the instance.
pub crashes: u32,
/// Instances reclaimed after being idle.
pub reclaims: u32,
}
/// Number of declarations of one extension point.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContributionCount {
pub point: String,
pub count: u32,
}
/// A plugin package available for installation from a local catalog
/// directory (the samples shipped with the IDE). Not installed by listing it.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CatalogPlugin {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
/// Package directory; install with `install_from_dir(path)`.
pub path: ide_core::IpcPath,
pub has_code: bool,
pub capabilities: Vec<CapabilityInfo>,
pub contributions: Vec<ContributionCount>,
/// Why the package cannot be installed as is (e.g. its WASM is not built).
pub problem: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ActiveTheme {
pub plugin_id: String,
pub theme: ThemeDef,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginsSnapshot {
pub plugins: Vec<PluginInfo>,
pub active_theme: Option<ActiveTheme>,
/// UI contributions of live plugins, projected by the client.
pub ui: Vec<UiContribution>,
}
/// Backend → UI notifications (Tauri event `ide://plugin`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PluginEvent {
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
CommandsChanged,
#[serde(rename_all = "camelCase")]
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
/// A plugin's status bar item was set (`item`) or removed.
#[serde(rename_all = "camelCase")]
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
/// A plugin began a task: show it with the background tasks.
#[serde(rename_all = "camelCase")]
TaskStarted { plugin_id: String, task_id: String, title: String, cancellable: bool },
#[serde(rename_all = "camelCase")]
TaskProgress { plugin_id: String, task_id: String, done: u32, total: Option<u32>, message: Option<String> },
#[serde(rename_all = "camelCase")]
TaskEnded { plugin_id: String, task_id: String },
/// The kinds of workbench events plugins want changed: re-fetch
/// `plugin_event_kinds` (the UI reports only those).
SubscriptionsChanged,
#[serde(rename_all = "camelCase")]
ThemeChanged { theme: Option<ActiveTheme> },
/// A plugin needs capabilities the user has not granted yet (first use
/// after migrating from 0.1): show its consent dialog.
#[serde(rename_all = "camelCase")]
ConsentRequested { plugin_id: String, capabilities: Vec<CapabilityInfo> },
/// A view (or the custom editors) of a plugin must be rendered again.
#[serde(rename_all = "camelCase")]
ViewChanged { plugin_id: String, view: String },
/// For the script of a view's `html` body.
#[serde(rename_all = "camelCase")]
ViewMessage { plugin_id: String, view: String, message: String },
/// Open a terminal running `program` (already approved and verified).
#[serde(rename_all = "camelCase")]
OpenTerminal {
plugin_id: String,
title: String,
program: ide_core::IpcPath,
args: Vec<String>,
cwd: ide_core::IpcPath,
},
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
/// (`None` = broadcast, e.g. during activation).
pub trait PluginEventSink: Send + Sync {
fn emit(&self, client: Option<&str>, event: PluginEvent);
}
impl<F: Fn(Option<&str>, PluginEvent) + Send + Sync> PluginEventSink for F {
fn emit(&self, client: Option<&str>, event: PluginEvent) {
self(client, event)
}
}
/// The focused editor when a plugin command is invoked.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginEditor {
pub path: String,
pub language: String,
pub selections: Vec<ide_core::Range>,
}
impl PluginEditor {
pub fn context(self) -> ide_plugin::EditorContext {
ide_plugin::EditorContext { path: self.path.into(), language: self.language, selections: self.selections }
}
}
/// A plugin's status bar item.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginStatusItem {
pub plugin_id: String,
pub id: String,
pub item: StatusItem,
}
/// A gutter marker from a plugin's `line-markers` provider.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LineMarkerInfo {
pub plugin_id: String,
/// 0-based.
pub line: u32,
/// `navigate` (default), `implemented`, `implementing`, `run`.
pub icon: Option<String>,
pub tooltip: String,
/// Opened on click.
pub target: Option<ide_core::Location>,
}
+544
View File
@@ -0,0 +1,544 @@
//! Declarative UI contributions: menus, key
//! bindings, status bar items and settings (L0, shown before any code runs),
//! and the views and custom editors whose content plugin code renders (L2).
//! Everything is validated here; the client only projects the JSON.
//!
//! ```toml
//! [[contributes.menus]]
//! menu = "editor.context" # projectView.context, editorTab.context, tools
//! command = "check"
//! when = "editorLang == java"
//!
//! [[contributes.keymaps]]
//! command = "check"
//! key = "Ctrl+Alt+C" # `mac` for a different binding on macOS (`Meta+Alt+C`)
//!
//! [[contributes.statusBar]]
//! id = "branch"
//! text = "…" # code replaces it with `set-status-item("branch", …)`
//! command = "show"
//!
//! [contributes.settings]
//! schema = "settings.schema.json" # {"properties": {"key": {"type": "boolean", "default": true, …}}}
//!
//! [[contributes.views]]
//! id = "jobs"
//! title = "Jobs"
//! icon = "server"
//! location = "right" # left, right, bottom
//!
//! [[contributes.customEditors]]
//! id = "table"
//! title = "Table"
//! patterns = ["*.csv", "*.tsv"]
//! priority = "default" # or "option": only through Open With
//! layout = "tab" # or "split" / "preview": beside the text editor
//! web = "web/index.html" # a web page from the package instead of rendered content
//!
//! [[contributes.fileIcons]]
//! id = "drawing"
//! patterns = ["*.excalidraw"]
//! icon = "icons/drawing.svg"
//! iconDark = "icons/drawing_dark.svg"
//!
//! [[contributes.fileTemplates]]
//! id = "drawing"
//! name = "Excalidraw Drawing"
//! extension = "excalidraw"
//! path = "templates/drawing.excalidraw"
//! ```
use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::path::PathBuf;
pub const MENUS: &str = "menus";
pub const KEYMAPS: &str = "keymaps";
pub const STATUS_BAR: &str = "statusBar";
pub const SETTINGS: &str = "settings";
pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
/// Command ids the package declares.
fn commands(pkg: &Package) -> BTreeSet<String> {
pkg.manifest
.declarations(super::COMMANDS)
.iter()
.filter_map(|d| d.get("id").and_then(|v| v.as_str()).map(str::to_owned))
.collect()
}
fn declared_command(pkg: &Package, point: &str, command: &str) -> Result<(), String> {
if commands(pkg).contains(command) {
Ok(())
} else {
Err(format!("contributes.{point}: command {command:?} is not declared under [[contributes.commands]]"))
}
}
fn title(point: &str, t: &str) -> Result<(), String> {
if t.trim().is_empty() || t.len() > MAX_TITLE {
return Err(format!("contributes.{point}: titles are 1–{MAX_TITLE} bytes"));
}
Ok(())
}
fn json<T: Serialize>(v: &T) -> Result<Value, String> {
serde_json::to_value(v).map_err(|e| e.to_string())
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct MenuDecl {
menu: String,
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
}
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: MenuDecl = decode(MENUS, decl)?;
if !MENU_IDS.contains(&d.menu.as_str()) {
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
}
declared_command(pkg, MENUS, &d.command)?;
if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
}
Ok(UiDecl::new(format!("{}/{}", d.menu, d.command), d.command.clone(), json(&d)?))
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct KeymapDecl {
command: String,
key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
mac: Option<String>,
}
const MODIFIERS: [&str; 4] = ["Meta", "Ctrl", "Alt", "Shift"];
const NAMED_KEYS: [&str; 22] = [
"Enter",
"Escape",
"Tab",
"Space",
"Backspace",
"Delete",
"Insert",
"Home",
"End",
"PageUp",
"PageDown",
"Up",
"Down",
"Left",
"Right",
"Minus",
"Equal",
"Comma",
"Period",
"Slash",
"Semicolon",
"Quote",
];
/// `Ctrl+Alt+C`, `Meta+K Meta+C` (a chord): at least one modifier per
/// stroke but for function keys, so plugins can't take plain typing.
pub fn validate_binding(binding: &str) -> Result<(), String> {
let strokes: Vec<&str> = binding.split(' ').collect();
if strokes.is_empty() || strokes.len() > 2 {
return Err(format!("{binding:?}: one stroke, or two for a chord"));
}
for stroke in strokes {
let parts: Vec<&str> = stroke.split('+').collect();
let (key, mods) = parts.split_last().ok_or_else(|| format!("{binding:?}: empty stroke"))?;
let mut seen = BTreeSet::new();
for m in mods {
if !MODIFIERS.contains(m) || !seen.insert(*m) {
return Err(format!("{binding:?}: unknown or repeated modifier {m:?}"));
}
}
let function = key.strip_prefix('F').and_then(|n| n.parse::<u8>().ok()).is_some_and(|n| (1..=24).contains(&n));
let plain = key.len() == 1 && key.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());
if !(function || plain || NAMED_KEYS.contains(key)) {
return Err(format!("{binding:?}: unknown key {key:?}"));
}
if !function && seen.iter().all(|m| *m == "Shift") {
return Err(format!("{binding:?}: needs Ctrl, Alt or Meta (plain keys are for typing)"));
}
}
Ok(())
}
pub(super) fn keymap(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: KeymapDecl = decode(KEYMAPS, decl)?;
declared_command(pkg, KEYMAPS, &d.command)?;
validate_binding(&d.key).map_err(|e| format!("contributes.keymaps: {e}"))?;
if let Some(m) = &d.mac {
validate_binding(m).map_err(|e| format!("contributes.keymaps: {e}"))?;
}
Ok(UiDecl::new(d.command.clone(), d.key.clone(), json(&d)?))
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct StatusDecl {
id: String,
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
tooltip: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default = "right")]
align: String,
#[serde(default)]
priority: u32,
}
fn right() -> String {
"right".into()
}
pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: StatusDecl = decode(STATUS_BAR, decl)?;
validate_item_id(&d.id, "status bar item id")?;
if d.text.is_empty() || d.text.len() > 128 || d.tooltip.as_ref().is_some_and(|t| t.len() > 1024) {
return Err(format!("status bar item {:?}: `text` 1–128 bytes, `tooltip` up to 1 KiB", d.id));
}
if d.align != "left" && d.align != "right" {
return Err(format!("status bar item {:?}: `align` is left or right", d.id));
}
if d.priority > 1000 {
return Err(format!("status bar item {:?}: `priority` is 0–1000", d.id));
}
if let Some(c) = &d.command {
declared_command(pkg, STATUS_BAR, c)?;
}
Ok(UiDecl::new(d.id.clone(), d.text.clone(), json(&d)?))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SettingsDecl {
#[serde(default)]
title: Option<String>,
schema: PathBuf,
}
#[derive(Debug, Deserialize)]
struct SchemaFile {
properties: serde_json::Map<String, Value>,
}
/// One setting of a plugin.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingSchema {
pub key: String,
/// `boolean`, `string`, `number` or `integer`.
#[serde(rename = "type")]
pub kind: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub default: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<String>>,
#[serde(default)]
pub multiline: bool,
}
impl SettingSchema {
/// Whether `v` is a valid value of this setting.
pub fn accepts(&self, v: &Value) -> bool {
match self.kind.as_str() {
"boolean" => v.is_boolean(),
"integer" => v.is_i64() || v.is_u64(),
"number" => v.is_number(),
_ => v
.as_str()
.is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))),
}
}
}
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) {
return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]"));
}
let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
let kind = o.get("type").and_then(Value::as_str).unwrap_or("");
let default = match kind {
"boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)),
"string" => o.get("default").cloned().unwrap_or(Value::String(String::new())),
"number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)),
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
};
let options = match o.get("enum") {
None => None,
Some(Value::Array(a)) if kind == "string" && !a.is_empty() && a.len() <= 100 => Some(
a.iter()
.map(|v| v.as_str().map(str::to_owned).ok_or_else(|| format!("setting {key:?}: `enum` lists strings")))
.collect::<Result<Vec<_>, _>>()?,
),
Some(_) => return Err(format!("setting {key:?}: `enum` is 1–100 strings of a string setting")),
};
let text = |name: &str, max: usize| -> Result<Option<String>, String> {
match o.get(name) {
None => Ok(None),
Some(Value::String(s)) if s.len() <= max => Ok(Some(s.clone())),
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
}
};
let s = SettingSchema {
key: key.to_owned(),
kind: kind.to_owned(),
title: text("title", MAX_TITLE)?.unwrap_or_else(|| key.to_owned()),
description: text("description", 4096)?,
default,
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
};
if !s.accepts(&s.default) {
return Err(format!("setting {key:?}: the default does not match its type"));
}
Ok(s)
}
/// The validated settings of a `[contributes.settings]` schema file.
pub fn parse_settings_schema(text: &str) -> Result<Vec<SettingSchema>, String> {
let file: SchemaFile = serde_json::from_str(text).map_err(|e| e.to_string())?;
if file.properties.len() > MAX_SETTINGS {
return Err(format!("more than {MAX_SETTINGS} settings"));
}
file.properties.iter().map(|(k, v)| setting(k, v)).collect()
}
pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: SettingsDecl = decode(SETTINGS, decl)?;
let text = pkg.read_text(&d.schema)?;
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
title_ok(&title)?;
let mut decl =
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
decl.files.push(d.schema);
Ok(decl)
}
fn title_ok(t: &str) -> Result<(), String> {
title(SETTINGS, t)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct ViewDecl {
id: String,
title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
icon: Option<String>,
#[serde(default = "right")]
location: String,
/// A web page of the package (`web/index.html`) instead of content the code renders.
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
}
/// The files of a `web` entry, or a check that the code renders the view.
fn web_or_code(pkg: &Package, web: Option<&str>, what: &str) -> Result<Vec<PathBuf>, String> {
match web {
Some(entry) => super::web::web_files(pkg, entry, what),
None if pkg.manifest.wasm.is_none() => {
Err(format!("{what}: give a web page (`web`) or the code that renders it (`wasm`)"))
}
None => Ok(Vec::new()),
}
}
pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: ViewDecl = decode(VIEWS, decl)?;
validate_item_id(&d.id, "view id")?;
title(VIEWS, &d.title)?;
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
if !["left", "right", "bottom"].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right or bottom", d.id));
}
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
Ok(out)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
id: String,
title: String,
patterns: Vec<String>,
#[serde(default = "default_priority")]
priority: String,
/// `tab`: the file opens in this editor's own tab. `split` / `preview`:
/// the file opens in the text editor with this editor beside it (Editor /
/// Editor and Preview / Preview); the value is the layout files start in.
#[serde(default = "default_layout")]
layout: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
}
fn default_priority() -> String {
"default".into()
}
fn default_layout() -> String {
"tab".into()
}
/// File name globs (`*.csv`, `{a,b}.json`): no directories.
fn file_patterns(patterns: &[String], what: &str) -> Result<(), String> {
if patterns.is_empty() || patterns.len() > MAX_PATTERNS {
return Err(format!("{what}: 1–{MAX_PATTERNS} file name `patterns`"));
}
for p in patterns {
if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() {
return Err(format!("{what}: invalid file name pattern {p:?}"));
}
}
Ok(())
}
pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: CustomEditorDecl = decode(CUSTOM_EDITORS, decl)?;
validate_item_id(&d.id, "custom editor id")?;
title(CUSTOM_EDITORS, &d.title)?;
let what = format!("custom editor {:?}", d.id);
let files = web_or_code(pkg, d.web.as_deref(), &what)?;
file_patterns(&d.patterns, &what)?;
if d.priority != "default" && d.priority != "option" {
return Err(format!("{what}: `priority` is default or option"));
}
if !["tab", "split", "preview"].contains(&d.layout.as_str()) {
return Err(format!("{what}: `layout` is tab, split or preview"));
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
}
/// Asset id of a file icon's SVG (`dark`: its dark-theme variant).
pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
file_patterns(&d.patterns, &what)?;
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path)?;
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
out.files = files;
out.assets = assets;
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileTemplateDecl {
id: String,
name: String,
/// Without the dot.
extension: String,
/// The new file's content (`$NAME$`, `$END$` and the other template variables work).
path: PathBuf,
}
const MAX_TEMPLATE: usize = 256 << 10;
/// `[[contributes.fileTemplates]]`: an entry of the New menu.
pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileTemplateDecl = decode(FILE_TEMPLATES, decl)?;
validate_item_id(&d.id, "file template id")?;
title(FILE_TEMPLATES, &d.name)?;
let what = format!("file template {:?}", d.id);
let ext = d.extension.trim_start_matches('.');
if ext.is_empty()
|| ext.len() > 32
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
return Err(format!("{what}: `extension` is a file extension like \"md\" ({:?})", d.extension));
}
let body = pkg.read_text(&d.path)?;
if body.len() > MAX_TEMPLATE {
return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10));
}
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body });
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
out.files = vec![d.path.clone()];
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bindings_need_a_modifier() {
for ok in ["Ctrl+Alt+C", "Meta+Shift+K", "F5", "Shift+F6", "Meta+K Meta+C", "Alt+Enter"] {
assert!(validate_binding(ok).is_ok(), "{ok}");
}
for bad in ["C", "Shift+C", "Hyper+C", "Ctrl+Ctrl+C", "Ctrl+é", "Ctrl+A B C", ""] {
assert!(validate_binding(bad).is_err(), "{bad}");
}
}
#[test]
fn settings_schemas_are_typed() {
let s = parse_settings_schema(
r#"{"properties": {
"context": {"type": "string", "title": "Context", "default": ""},
"watch": {"type": "boolean", "default": true},
"mode": {"type": "string", "enum": ["a", "b"], "default": "b"},
"limit": {"type": "integer", "default": 100}
}}"#,
)
.unwrap();
assert_eq!(s.len(), 4);
assert!(s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("a")));
assert!(!s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("c")));
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err());
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Declarative views and custom editors as the
//! window commands `plugin_view_render` / `plugin_view_event` serve them —
//! the desktop for local projects, `openidea-server` for remote ones.
use super::{PluginEditor, PluginHost};
use ide_core::{DocId, IdeError, IdeResult};
use ide_plugin::{EditorContext, Invocation, View, ViewEvent, Viewport};
use std::sync::Arc;
/// The window's project, plus the editor the call is about: the document a
/// custom editor shows, else (a tool window) the window's active editor, as
/// a command gets it, so a view's buttons can act on what the user is editing.
pub fn view_invocation(
host: &crate::Host,
window: &str,
doc: Option<DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<Invocation> {
let project = host.project(window).ok();
let editor = match doc {
None => editor.map(PluginEditor::context),
Some(doc) => {
let project = project.as_ref().ok_or_else(|| IdeError::not_found("no project is open in this window"))?;
let info = project.documents.snapshot(doc)?;
Some(EditorContext { path: info.path.0, language: info.language.0, selections: Vec::new() })
}
};
Ok(Invocation { client: Some(window.to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor })
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
#[allow(clippy::too_many_arguments)]
pub async fn render(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
view: String,
viewport: Option<Viewport>,
doc: Option<DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<View> {
let invocation = view_invocation(host, window, doc, editor)?;
blocking(move || plugins.render_view(&plugin_id, &view, viewport, invocation)).await
}
#[allow(clippy::too_many_arguments)]
pub async fn event(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
view: String,
event: ViewEvent,
doc: Option<DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<()> {
let invocation = view_invocation(host, window, doc, editor)?;
blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Host, HostConfig};
#[tokio::test]
async fn a_tool_window_call_carries_the_active_editor() {
let tmp = tempfile::tempdir().unwrap();
let host = Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let range = ide_core::Range::new(ide_core::Pos::new(1, 0), ide_core::Pos::new(1, 4));
let active = PluginEditor { path: "/p/a.rs".into(), language: "rust".into(), selections: vec![range] };
let inv = view_invocation(&host, "main", None, Some(active)).unwrap();
let editor = inv.editor.expect("the active editor");
assert_eq!((editor.path.to_string_lossy().as_ref(), editor.selections.as_slice()), ("/p/a.rs", &[range][..]));
assert_eq!(inv.client.as_deref(), Some("main"));
assert!(view_invocation(&host, "main", None, None).unwrap().editor.is_none());
}
}
+245
View File
@@ -0,0 +1,245 @@
//! Web views: a view or custom editor whose UI is a web page shipped in the
//! package (`web = "web/index.html"`) instead of content the plugin's code
//! renders.
//!
//! The entry's directory is the page's root. Every file under it is part of
//! the declaration: it is checked when the package is prepared (count, size,
//! no links leaving the package), copied on install and covered by the
//! package digest and signature. The client loads the page in a sandboxed
//! frame (an opaque origin with no network and no IDE API) and fetches its
//! files through [`PluginHost::web_file`] (the desktop's `openidea-plugin`
//! URL scheme); the page talks to the IDE only through the frame bridge.
use super::PluginHost;
use ide_core::{IdeError, IdeResult};
use ide_extension::{Package, check_relative};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Component, Path, PathBuf};
/// Most files under one web root.
const MAX_FILES: usize = 4096;
/// Most bytes under one web root.
const MAX_BYTES: u64 = 256 << 20;
/// Deepest directory nesting under a web root.
const MAX_DEPTH: usize = 16;
/// Checks a declaration's `web` entry: an `.html` file inside a directory of
/// the package (the directory is what the page can load, so it can't be the
/// package root), and everything under that directory. Returns those files,
/// package-relative.
pub(super) fn web_files(pkg: &Package, entry: &str, what: &str) -> Result<Vec<PathBuf>, String> {
let path = Path::new(entry);
check_relative(path, "web")?;
if entry.contains('\\') || !entry.to_ascii_lowercase().ends_with(".html") {
return Err(format!("{what}: `web` names an .html file, `/`-separated ({entry:?})"));
}
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| {
format!("{what}: put the page in a directory of its own (`web/index.html`): the directory is what it can load")
})?;
let real_entry = pkg.resolve(path)?;
if !real_entry.is_file() {
return Err(format!("{what}: {entry} is not a file"));
}
let root = pkg.resolve(dir)?;
let mut files = Vec::new();
let mut total = 0u64;
let mut stack = vec![(root.clone(), dir.to_path_buf(), 0usize)];
while let Some((real, rel, depth)) = stack.pop() {
let mut entries: Vec<_> =
std::fs::read_dir(&real).map_err(|e| format!("{}: {e}", rel.display()))?.filter_map(Result::ok).collect();
entries.sort_by_key(|e| e.file_name());
for e in entries {
let name = e.file_name();
let rel = rel.join(&name);
// Links could point anywhere: only what is really in the package counts.
let ty = e.file_type().map_err(|err| format!("{}: {err}", rel.display()))?;
if ty.is_symlink() {
return Err(format!("{what}: {} is a link; web files must be real files", rel.display()));
}
if ty.is_dir() {
if depth + 1 > MAX_DEPTH {
return Err(format!("{what}: {} is nested deeper than {MAX_DEPTH} directories", rel.display()));
}
stack.push((e.path(), rel, depth + 1));
continue;
}
if name.to_string_lossy() == ".DS_Store" {
continue;
}
let len = e.metadata().map_err(|err| format!("{}: {err}", rel.display()))?.len();
if len > ide_extension::MAX_PACKAGE_FILE {
return Err(format!(
"{what}: {} is larger than {} MiB",
rel.display(),
ide_extension::MAX_PACKAGE_FILE >> 20
));
}
total += len;
files.push(rel);
if files.len() > MAX_FILES || total > MAX_BYTES {
return Err(format!(
"{what}: {} holds more than {MAX_FILES} files or {} MiB",
dir.display(),
MAX_BYTES >> 20
));
}
}
}
files.sort();
Ok(files)
}
/// The page of a web view, for the client to load in its frame.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebPage {
/// `<plugin id>/<revision>/<entry>` under the `openidea-plugin` scheme. The
/// revision changes with the installed package, so a cached file is never
/// stale; relative URLs in the page resolve to the package's files.
pub path: String,
}
/// A file of a web view.
pub struct WebFile {
pub bytes: Vec<u8>,
pub mime: &'static str,
}
impl PluginHost {
/// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated).
fn web_dirs(&self, plugin: &str) -> Vec<String> {
[super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
.iter()
.flat_map(|point| self.ui.items(point, Some(plugin)))
.filter_map(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned))
.filter_map(|entry| entry.rsplit_once('/').map(|(dir, _)| format!("{dir}/")))
.collect()
}
/// Changes whenever the installed package does (cache key of its web files).
fn web_revision(&self, plugin: &super::LoadedPlugin) -> String {
let digest = plugin.pkg.digest.as_deref().unwrap_or_default();
let short: String = digest.chars().filter(char::is_ascii_alphanumeric).take(16).collect();
if short.is_empty() { format!("v{}", plugin.pkg.manifest.version) } else { short }
}
/// The page of `plugin`'s view or custom editor `view`.
pub fn web_page(&self, plugin_id: &str, view: &str) -> IdeResult<WebPage> {
let plugin = self.get(plugin_id)?;
if !plugin.status().is_live() {
return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled")));
}
let entry = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
.iter()
.flat_map(|point| self.ui.items(point, Some(plugin_id)))
.find(|item| item.key == view)
.and_then(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned))
.ok_or_else(|| IdeError::not_found(format!("plugin {plugin_id:?} has no web view {view:?}")))?;
Ok(WebPage { path: format!("{plugin_id}/{}/{entry}", self.web_revision(&plugin)) })
}
/// A file of one of `plugin`'s web views: `path` is `<revision>/<package path>`
/// as [`WebPage::path`] makes them. Only files under a declared web root
/// of a live plugin are served; the revision must be the installed one.
pub fn web_file(&self, plugin_id: &str, path: &str) -> IdeResult<WebFile> {
let plugin = self.get(plugin_id)?;
if !plugin.status().is_live() {
return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled")));
}
let (revision, rel) = path.split_once('/').ok_or_else(|| IdeError::not_found(path.to_owned()))?;
if revision != self.web_revision(&plugin) {
return Err(IdeError::not_found(format!("{path}: not the installed version")));
}
let rel_path = Path::new(rel);
if !rel_path.components().all(|c| matches!(c, Component::Normal(_))) {
return Err(IdeError::invalid(format!("{rel}: not a plain relative path")));
}
if !self.web_dirs(plugin_id).iter().any(|dir| rel.starts_with(dir.as_str())) {
return Err(IdeError::not_found(format!("{rel}: not part of a web view")));
}
let bytes = plugin.pkg.read_bytes(rel_path).map_err(IdeError::not_found)?;
Ok(WebFile { bytes, mime: mime(rel) })
}
}
/// Content type by extension; unknown files are opaque bytes (never sniffed as script).
pub fn mime(path: &str) -> &'static str {
let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"js" | "mjs" | "cjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" | "map" => "application/json",
"wasm" => "application/wasm",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"woff2" => "font/woff2",
"woff" => "font/woff",
"ttf" => "font/ttf",
"otf" => "font/otf",
"txt" | "md" => "text/plain; charset=utf-8",
"mp3" => "audio/mpeg",
"mp4" => "video/mp4",
"webm" => "video/webm",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_extension::Manifest;
fn pkg(dir: &Path) -> Package {
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n";
Package::new(Manifest::parse(src).unwrap(), dir.to_path_buf())
}
#[test]
fn a_web_root_is_its_directory_and_nothing_else() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
std::fs::create_dir_all(d.join("web/assets/fonts")).unwrap();
std::fs::write(d.join("web/index.html"), "<p>hi</p>").unwrap();
std::fs::write(d.join("web/assets/app.js"), "1").unwrap();
std::fs::write(d.join("web/assets/fonts/a.woff2"), "f").unwrap();
std::fs::write(d.join("web/.DS_Store"), "x").unwrap();
std::fs::write(d.join("secret.txt"), "no").unwrap();
let files = web_files(&pkg(d), "web/index.html", "v").unwrap();
let files: Vec<String> = files.iter().map(|f| f.to_string_lossy().replace('\\', "/")).collect();
assert_eq!(files, ["web/assets/app.js", "web/assets/fonts/a.woff2", "web/index.html"]);
std::fs::write(d.join("index.html"), "x").unwrap();
let err = |entry: &str| web_files(&pkg(d), entry, "v").err().unwrap_or_default();
assert!(err("index.html").contains("directory of its own"), "{}", err("index.html"));
assert!(err("web/app.js").contains(".html"));
assert!(err("../web/index.html").contains("relative"));
assert!(err("web/missing.html").contains("no such file"));
}
#[cfg(unix)]
#[test]
fn links_out_of_a_web_root_are_refused() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
std::fs::create_dir_all(d.join("web")).unwrap();
std::fs::write(d.join("web/index.html"), "x").unwrap();
std::os::unix::fs::symlink("/etc/hosts", d.join("web/hosts")).unwrap();
assert!(web_files(&pkg(d), "web/index.html", "v").unwrap_err().contains("is a link"));
}
#[test]
fn content_types_never_make_unknown_files_scripts() {
assert_eq!(mime("a/b.JS"), "text/javascript; charset=utf-8");
assert_eq!(mime("x.woff2"), "font/woff2");
assert_eq!(mime("x.wasm"), "application/wasm");
assert_eq!(mime("x.bin"), "application/octet-stream");
assert_eq!(mime("noext"), "application/octet-stream");
}
}
+189 -23
View File
@@ -91,7 +91,8 @@ impl Host {
let settings = SettingsStore::load(&self.config_dir, Some(&root));
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read());
let extra_roots = content_roots(&settings.effective(), &root);
let build_cache = self.local_data_dir.join("cache").join(project_key(&root)).join("build");
let extra_roots = content_roots(&settings.effective(), &root, &build_cache);
mark("roots");
// The project's own registry: File Types associations may be project-scoped.
@@ -129,6 +130,7 @@ impl Host {
let root = root.clone();
let data_dir = self.local_data_dir.clone();
let config_dir = self.config_dir.clone();
let build_cache = self.local_data_dir.join("cache").join(&project_key).join("build");
let sink = sink.clone();
let formatter_problem = std::sync::Mutex::new(None);
lsp.set_config_hook(Box::new(move |cfg, server_root| {
@@ -142,6 +144,15 @@ impl Host {
let root = member.as_ref().unwrap_or(&root);
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
if cfg.name == "jdtls" {
// A workspace member keeps its build model under its own key (Build Project).
let cache = match &member {
Some(member) => build_cache.join(crate::project::project_key(member)),
None => build_cache.clone(),
};
let profiles = ide_build::maven_profiles(&cache);
ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache);
}
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
if cfg.name == "jdtls" {
let problem = ide_lsp::jdtls::configure_formatter(
@@ -207,8 +218,10 @@ impl Host {
ide_sdk::jdtls::launch_directly(cfg);
}
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
let excluded_poms = unlinked_maven_poms(root, &effective);
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms);
if cfg.name == "jdtls" {
let (excluded_poms, build_files) = jdtls_maven_import(root, &effective);
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref());
}
}));
}
{
@@ -255,7 +268,7 @@ impl Host {
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
root.clone(),
self.local_data_dir.join("cache").join(&project_key).join("build"),
build_cache.clone(),
&self.local_data_dir,
settings.clone(),
lsp.clone(),
@@ -282,6 +295,7 @@ impl Host {
let runtime = self.runtime.clone();
let lsp = lsp.clone();
let trusted = trusted.clone();
let build_cache = build_cache.clone();
Watcher::start(
&root.clone(),
excludes.clone(),
@@ -318,7 +332,7 @@ impl Host {
// Detected excluded folders (`target`, `build`) leave the index too.
let effective = settings.effective();
index.set_scope(
index_roots(&root, content_roots(&effective, &root)),
index_roots(&root, content_roots(&effective, &root, &build_cache)),
exclude_globs(&root, &effective, &fresh),
);
*detected_roots.write() = fresh;
@@ -356,7 +370,7 @@ impl Host {
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink);
scan_repositories(&root, &repos, &settings, &sink, &build_cache);
}
let vcs_moved = batch.vcs.iter().any(|p| vcs_path_changed(p));
if git_dirs || vcs_moved {
@@ -403,9 +417,10 @@ impl Host {
mark("watcher");
{
let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone());
let build_cache = build_cache.clone();
std::thread::Builder::new()
.name("openidea-vcs-roots".into())
.spawn(move || scan_repositories(&root, &repos, &settings, &sink))?;
.spawn(move || scan_repositories(&root, &repos, &settings, &sink, &build_cache))?;
}
mark("git");
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
@@ -484,12 +499,14 @@ impl Host {
ide_agent::AgentPaths {
project_root: root.to_path_buf(),
store_dir: self.data_dir.join("agents").join(project_key),
// Outside the app data dir: its path has spaces on macOS, which trips up build scripts.
worktrees_dir: dirs::home_dir()
.unwrap_or_else(|| self.data_dir.clone())
.join(".openidea")
.join("worktrees")
.join(project_key),
worktrees_dir: agent_worktrees_dir(root, || {
// Outside the app data dir: its path has spaces on macOS, which trips up build scripts.
dirs::home_dir()
.unwrap_or_else(|| self.data_dir.clone())
.join(".openidea")
.join("worktrees")
.join(project_key)
}),
models_file: self.data_dir.join("agents").join("models.json"),
custom_agents_dir: Some(self.config_dir.join("agents")),
home_dir: dirs::home_dir(),
@@ -638,11 +655,17 @@ fn light_watcher(
/// Looks for repositories nested below the project (and the one containing it);
/// emits `VcsChanged` when the set of roots changed.
fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStore, sink: &SharedSink) {
fn scan_repositories(
root: &Path,
repos: &RwLock<Repos>,
settings: &SettingsStore,
sink: &SharedSink,
build_cache: &Path,
) {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root));
roots.extend(content_roots(&settings.effective(), root, build_cache));
match Repos::discover_roots(&roots, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
@@ -656,7 +679,7 @@ fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStor
impl Project {
pub fn refresh_repositories(&self) {
scan_repositories(&self.root, &self.repos, &self.settings, &self.sink);
scan_repositories(&self.root, &self.repos, &self.settings, &self.sink, &self.build_cache_dir());
}
/// The project's git repositories.
@@ -762,7 +785,8 @@ impl Project {
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
pub fn roots(&self) -> ProjectRoots {
linked_roots(&self.root, &self.settings.effective(), &self.detected_roots.read())
let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
linked_roots(&self.root, &self.settings.effective(), &detected)
}
/// Where the Maven / Gradle tool windows cache what the build tools reported.
@@ -829,7 +853,7 @@ impl Project {
/// Project Structure content roots besides the project folder (existing directories).
pub fn content_roots(&self) -> Vec<PathBuf> {
content_roots(&self.settings.effective(), &self.root)
content_roots(&self.settings.effective(), &self.root, &self.build_cache_dir())
}
/// Applies Settings › File Types; open documents whose language changed are
@@ -948,14 +972,15 @@ fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders }
}
/// Workspace members in manifest order, or extra Project Structure directories.
fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec<PathBuf> {
/// Workspace members in manifest order, or extra Project Structure directories and the
/// Gradle project folders outside the project as of the last sync (`build_cache`).
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
return workspace.projects;
}
let mut out: Vec<PathBuf> = Vec::new();
for r in &settings.project_structure.content_roots {
let p = PathBuf::from(r.trim());
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
for p in user.chain(ide_build::gradle_external_roots(root, build_cache)) {
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
}
@@ -994,10 +1019,76 @@ fn content_root_watcher(
.ok()
}
/// Where agent tasks create their worktrees: next to the project, `<parent>/<name>.worktrees`,
/// so they sit on the project's drive and outside its tree (not indexed, watched or shown in
/// its git status); `fallback` for a project at a filesystem root.
fn agent_worktrees_dir(root: &Path, fallback: impl FnOnce() -> PathBuf) -> PathBuf {
match (root.parent(), root.file_name()) {
(Some(parent), Some(name)) => {
let mut dir = name.to_os_string();
dir.push(".worktrees");
parent.join(dir)
}
_ => fallback(),
}
}
pub(crate) fn unlinked_maven_poms(root: &Path, settings: &ide_core::settings::Settings) -> Vec<PathBuf> {
ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven).poms().map(PathBuf::from).collect()
}
/// The unlinked Maven POMs jdtls leaves out, and the build files (`file:` URIs) it is to
/// import when that takes more than exclusions: an unlinked module of a linked reactor.
/// Those then include the Gradle build, which jdtls would otherwise find by itself.
pub(crate) fn jdtls_maven_import(
root: &Path,
settings: &ide_core::settings::Settings,
) -> (Vec<PathBuf>, Option<Vec<String>>) {
let maven = &settings.languages.java.maven;
if maven.unlinked_projects.is_empty() {
return (Vec::new(), None);
}
let model = ide_build::maven::load(root);
let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven);
let excluded = unlinked.poms().map(PathBuf::from).collect();
let build_files = unlinked.import_poms(&model).map(|poms| {
let gradle = ide_build::gradle::load(root).and_then(|g| {
let dir = root.join(&g.dir);
let names = ide_build::gradle::SETTINGS_FILES.iter().chain(ide_build::gradle::BUILD_FILES);
names.map(|n| dir.join(n)).find(|f| f.is_file())
});
poms.iter().map(|pom| root.join(pom)).chain(gradle).map(|f| ide_lsp::path_to_uri_string(&f)).collect()
});
(excluded, build_files)
}
/// The detected roots with the last Gradle sync's source sets first: Gradle evaluated what
/// static detection guesses. Folders outside the project keep their absolute path, which
/// `normalize` turns into the same form the UI compares (`Users/me/svc/src/main/java`).
fn with_gradle_roots(root: &Path, build_cache: &Path, detected: &[SourceFolder]) -> Vec<SourceFolder> {
use ide_build::gradle::ReportedRoot;
use ide_workspace::roots::RootKind;
let mut out: Vec<SourceFolder> = Vec::new();
for (dir, kind) in ide_build::gradle_source_roots(build_cache) {
if !dir.is_dir() {
continue;
}
let path = ide_workspace::roots::normalize(&dir.strip_prefix(root).unwrap_or(&dir).to_string_lossy());
let kind = match kind {
ReportedRoot::Source => RootKind::Source,
ReportedRoot::Test => RootKind::Test,
ReportedRoot::Resources => RootKind::Resources,
ReportedRoot::TestResources => RootKind::TestResources,
ReportedRoot::Excluded => RootKind::Excluded,
};
if !out.iter().any(|f| f.path == path) {
out.push(SourceFolder { path, kind, detected_from: Some("Gradle".into()) });
}
}
out.extend(detected.iter().filter(|d| !out.iter().any(|o| o.path == d.path)).cloned().collect::<Vec<_>>());
out
}
fn linked_roots(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> ProjectRoots {
let unlinked = ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven);
let detected = detected
@@ -1148,6 +1239,27 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
#[cfg(test)]
mod maven_linkage_tests {
#[test]
fn jdtls_imports_the_linked_modules_and_the_gradle_build_once_a_reactor_module_is_unlinked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (dir, body) in [("", "<modules><module>x</module><module>y</module></modules>"), ("x", ""), ("y", "")] {
std::fs::create_dir_all(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("pom.xml"), format!("<project>{body}</project>")).unwrap();
}
std::fs::write(root.join("settings.gradle"), "rootProject.name = 'g'").unwrap();
let mut settings = ide_core::settings::Settings::default();
assert_eq!(super::jdtls_maven_import(root, &settings), (Vec::new(), None));
settings.languages.java.maven.unlinked_projects.push("y/pom.xml".into());
let (excluded, files) = super::jdtls_maven_import(root, &settings);
assert_eq!(excluded, vec![std::path::PathBuf::from("y/pom.xml")]);
let uri = |f: &str| ide_lsp::path_to_uri_string(&root.join(f));
assert_eq!(files, Some(vec![uri("x/pom.xml"), uri("settings.gradle")]));
// The whole reactor unlinked: the import exclusions are enough.
settings.languages.java.maven.unlinked_projects = vec!["pom.xml".into()];
assert_eq!(super::jdtls_maven_import(root, &settings).1, None);
}
#[test]
fn unlinked_roots_remain_plain_files() {
let tmp = tempfile::tempdir().unwrap();
@@ -1176,7 +1288,7 @@ mod maven_linkage_tests {
serde_json::json!({"name": "jdtls", "command": "jdtls"}),
)
.unwrap();
ide_sdk::apply::configure_maven_import(&mut server, tmp.path(), &excluded_poms);
ide_sdk::apply::configure_maven_import(&mut server, tmp.path(), &excluded_poms, None);
let options = serde_json::to_value(server.initialization_options).unwrap();
let exclusions = options.pointer("/settings/java/import/exclusions").unwrap().as_array().unwrap();
assert!(exclusions.contains(&serde_json::json!(tmp.path().join("a").to_string_lossy().replace('\\', "/"))));
@@ -1199,6 +1311,46 @@ fn vcs_path_changed(path: &Path) -> bool {
!path.ends_with(".git/index") || !ide_vcs::is_own_index_write(path)
}
#[cfg(test)]
mod gradle_roots_tests {
use super::*;
use ide_workspace::RootKind;
#[test]
fn gradle_sync_roots_win_and_reach_outside_the_project() {
let tmp = tempfile::tempdir().unwrap();
let ws = dunce::canonicalize(tmp.path()).unwrap();
let (root, svc, cache) = (ws.join("p"), ws.join("svc"), ws.join("cache"));
for d in [root.join("src/it/java"), svc.join("src/main/java"), svc.join("build")] {
std::fs::create_dir_all(d).unwrap();
}
let s = |p: PathBuf| p.to_string_lossy().into_owned();
let json = serde_json::json!({ "hash": "", "at_ms": 1.0, "error": null, "tasks": [], "projects": [
{ "path": ":", "dir": s(root.clone()), "sourceSets": [
{ "name": "integrationTest", "test": true, "src": [s(root.join("src/it/java"))], "res": [] } ] },
{ "path": ":svc", "dir": s(svc.clone()), "buildDir": s(svc.join("build")), "sourceSets": [
{ "name": "main", "test": false,
"src": [s(svc.join("src/main/java")), s(svc.join("src/main/kotlin"))], "res": [] } ] },
]});
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("gradle.json"), json.to_string()).unwrap();
let guessed = SourceFolder { path: "src/it/java".into(), kind: RootKind::Source, detected_from: None };
let other = SourceFolder { path: "web".into(), kind: RootKind::Source, detected_from: None };
let out = with_gradle_roots(&root, &cache, &[guessed, other.clone()]);
let abs = |p: PathBuf| ide_workspace::roots::normalize(&s(p));
let got: Vec<(String, RootKind)> = out.iter().map(|f| (f.path.clone(), f.kind)).collect();
assert_eq!(
got,
[
("src/it/java".into(), RootKind::Test),
(abs(svc.join("src/main/java")), RootKind::Source),
(abs(svc.join("build")), RootKind::Excluded),
("web".into(), RootKind::Source),
]
);
}
}
#[cfg(test)]
mod index_event_tests {
use super::vcs_path_changed;
@@ -1269,3 +1421,17 @@ mod workspace_edit_tests {
assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n");
}
}
#[cfg(test)]
mod agent_worktree_tests {
use super::agent_worktrees_dir;
use std::path::{Path, PathBuf};
#[test]
fn agent_worktrees_sit_next_to_the_project() {
let fallback = || PathBuf::from("/home/me/.openidea/worktrees/key");
let project = Path::new("/work").join("acme-api");
assert_eq!(agent_worktrees_dir(&project, fallback), Path::new("/work").join("acme-api.worktrees"));
assert_eq!(agent_worktrees_dir(Path::new("/"), fallback), fallback());
}
}
+4 -1
View File
@@ -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};
+1
View File
@@ -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"
+262
View File
@@ -0,0 +1,262 @@
//! Versioned installs with automatic rollback, signed packages from
//! a static marketplace index, and revocation.
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, Publisher, PublisherKey, Revocation};
use ide_host::plugins::*;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::Arc;
type Events = Arc<Mutex<Vec<PluginEvent>>>;
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_name() == "target" {
continue;
}
if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() }
}
}
/// A copy of `from` (under `dir/name`) whose manifest is rewritten by `f`.
fn variant(from: &Path, dir: &Path, name: &str, f: impl Fn(&str) -> String) -> PathBuf {
let pkg = dir.join(name);
copy_dir(from, &pkg);
let manifest = pkg.join("plugin.toml");
std::fs::write(&manifest, f(&std::fs::read_to_string(&manifest).unwrap())).unwrap();
pkg
}
struct Fixture {
host: PluginHost,
plugins_dir: PathBuf,
events: Events,
_tmp: tempfile::TempDir,
work: PathBuf,
}
fn fixture() -> Fixture {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let events: Events = Arc::default();
let sink = {
let events = events.clone();
move |_: Option<&str>, e: PluginEvent| events.lock().push(e)
};
let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), Limits::default()).unwrap();
host.load_all();
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
Fixture { host, plugins_dir, events, work, _tmp: tmp }
}
fn inv() -> Invocation {
Invocation { client: Some("main".into()), workspace_root: None, editor: None }
}
fn misbehave() -> PathBuf {
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
}
/// P8 acceptance: an update that fails to start rolls back to the version it replaced.
#[test]
fn a_failing_update_rolls_back_automatically() {
let f = fixture();
let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\""));
f.host.install_from_dir(&v1, &Consent::all()).unwrap();
assert_eq!(f.host.run_command("misbehave", "echo", Some("hi"), inv()).unwrap().as_deref(), Some("hi"));
let broken =
variant(&misbehave(), &f.work, "v2", |t| t.replace("version = \"0.0.2\"", "version = \"2.0.0-broken\""));
let info = f.host.install_from_dir(&broken, &Consent::all()).unwrap();
assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("2.0.0-broken", Some("1.0.0")));
let root = f.plugins_dir.join("misbehave");
assert!(root.join("2.0.0-broken").is_dir() && root.join("1.0.0").is_dir(), "versions side by side");
// Its first start fails: back to 1.0.0, without the user doing anything.
f.events.lock().clear();
let err = f.host.run_command("misbehave", "echo", Some("x"), inv()).unwrap_err();
assert!(err.message.contains("version 1.0.0 was restored"), "{}", err.message);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.version.as_str(), info.status), ("1.0.0", PluginStatus::Inactive));
assert!(!root.join("2.0.0-broken").exists(), "the failed version is removed");
assert!(
f.events
.lock()
.iter()
.any(|e| matches!(e, PluginEvent::Notification { title, .. } if title.contains("rolled back")))
);
assert_eq!(f.host.run_command("misbehave", "echo", Some("ok"), inv()).unwrap().as_deref(), Some("ok"));
// A working update is confirmed by its first start; the user can still roll it back.
let v3 = variant(&misbehave(), &f.work, "v3", |t| t.replace("version = \"0.0.2\"", "version = \"3.0.0\""));
f.host.install_from_dir(&v3, &Consent::all()).unwrap();
f.host.run_command("misbehave", "echo", None, inv()).unwrap();
assert_eq!(f.host.info_of("misbehave").unwrap().version, "3.0.0");
let info = f.host.roll_back_update("misbehave").unwrap();
assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", None));
assert!(f.host.roll_back_update("misbehave").is_err(), "nothing older to go back to");
// A restart loads the active version.
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
assert_eq!(again.info_of("misbehave").unwrap().version, "1.0.0");
f.host.uninstall("misbehave").unwrap();
assert!(!root.exists(), "every version goes");
}
#[test]
fn installs_from_before_versioning_keep_working_and_move_on_update() {
let f = fixture();
// The flat layout of earlier releases.
copy_dir(&misbehave(), &f.plugins_dir.join("misbehave"));
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
assert_eq!(again.info_of("misbehave").unwrap().version, "0.0.2");
let v1 = variant(&misbehave(), &f.work, "v1", |t| t.replace("version = \"0.0.2\"", "version = \"1.0.0\""));
let info = again.install_from_dir(&v1, &Consent::all()).unwrap();
assert_eq!((info.version.as_str(), info.previous_version.as_deref()), ("1.0.0", Some("0.0.2")));
assert!(f.plugins_dir.join("misbehave/0.0.2/plugin.toml").is_file());
assert!(f.plugins_dir.join("misbehave/1.0.0/plugin.toml").is_file());
}
// ---- marketplace ------------------------------------------------------------------
/// `hello` as `acme.hello` at `version`, packed and signed with `key`.
fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost) -> (PathBuf, IndexEntry) {
let src = variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{version}"), |t| {
t.replace("id = \"hello\"", "id = \"acme.hello\"")
.replace("version = \"0.3.0\"", &format!("version = \"{version}\""))
});
let files = host.package_files_of(&src).unwrap();
let digest = package::package_digest(&src, &files).unwrap();
let sig = key.sign("acme", "acme.hello", version, &digest);
let out = dir.join(format!("acme.hello-{version}.oiplugin"));
package::pack(&src, &files, &out, Some(&sig)).unwrap();
let entry = IndexEntry {
id: "acme.hello".into(),
name: "Hello".into(),
version: version.into(),
description: String::new(),
authors: vec![],
publisher: "acme".into(),
download: out.file_name().unwrap().to_string_lossy().into_owned(),
digest,
capabilities: vec![],
};
(out, entry)
}
fn write_index(
dir: &Path,
key: &PublisherKey,
plugins: Vec<IndexEntry>,
revoked: Vec<Revocation>,
key_revoked: bool,
) -> String {
let index = Index {
format: package::INDEX_FORMAT,
publishers: vec![Publisher {
id: "acme".into(),
name: "ACME".into(),
keys: vec![KeyEntry { key: key.public_base64(), revoked: key_revoked }],
}],
plugins,
revoked,
};
let path = dir.join("index.json");
std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let url = write_index(&market, &key, vec![entry.clone()], vec![], false);
assert!(f.host.refresh_market().is_err(), "no marketplace configured yet");
f.host.set_market_url(Some(url.clone()));
let listed = f.host.refresh_market().unwrap();
assert_eq!((listed[0].entry.id.as_str(), listed[0].installed.as_deref()), ("acme.hello", None));
let dir = f.host.download_market_plugin("acme.hello").unwrap();
let preview = f.host.preview_install(&dir).unwrap();
assert_eq!((preview.unsigned, preview.publisher.as_deref()), (false, Some("acme")));
let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!(info.publisher.as_deref(), Some("acme"));
assert_eq!(f.host.market_plugins()[0].installed.as_deref(), Some("1.0.0"));
// A changed file breaks the signature: refused.
let tampered = f.host.download_market_plugin("acme.hello").unwrap();
let manifest = std::fs::read_to_string(tampered.join("plugin.toml")).unwrap();
std::fs::write(tampered.join("plugin.toml"), manifest.replace("Sample plugin", "Totally safe plugin")).unwrap();
let err = f.host.preview_install(&tampered).unwrap_err();
assert!(err.message.contains("changed after signing"), "{}", err.message);
// Someone else's key, or another publisher's prefix: not trusted.
let stranger = PublisherKey::from_seed([4; 32]);
let (foreign, _) = signed_hello(&f.work, "1.0.1", &stranger, &f.host);
let unpacked = f.host.unpack_package(&foreign).unwrap();
assert!(f.host.preview_install(&unpacked).unwrap_err().message.contains("not one of"));
// The index revokes the installed version: disabled, the user told.
f.events.lock().clear();
let revoked =
vec![Revocation { id: "acme.hello".into(), versions: vec!["1.0.0".into()], reason: "steals tokens".into() }];
write_index(&market, &key, vec![entry.clone()], revoked, false);
f.host.refresh_market().unwrap();
let info = f.host.info_of("acme.hello").unwrap();
assert_eq!((info.status, info.error.as_deref()), (PluginStatus::Failed, Some("revoked: steals tokens")));
assert!(
f.events
.lock()
.iter()
.any(|e| matches!(e, PluginEvent::Notification { body, .. } if body.contains("steals tokens")))
);
assert!(f.host.market_plugins().is_empty(), "revoked versions are not offered");
assert!(f.host.install_from_dir(&dir, &Consent::all()).is_err(), "nor installable");
f.host.set_enabled("acme.hello", true).unwrap();
assert_eq!(f.host.info_of("acme.hello").unwrap().status, PluginStatus::Failed, "enabling doesn't bring it back");
// Revocations hold across restarts (the index is kept).
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
assert_eq!(again.info_of("acme.hello").unwrap().status, PluginStatus::Failed);
// A revoked signing key disables what it signed.
write_index(&market, &key, vec![entry.clone()], vec![], true);
f.host.refresh_market().unwrap();
let info = f.host.info_of("acme.hello").unwrap();
assert!(info.error.as_deref().unwrap_or("").contains("signing key was revoked"), "{:?}", info.error);
// Lifted: it runs again.
write_index(&market, &key, vec![entry], vec![], false);
f.host.refresh_market().unwrap();
let info = f.host.info_of("acme.hello").unwrap();
assert_eq!((info.status, info.error), (PluginStatus::Inactive, None));
}
#[test]
fn package_files_install_from_disk_unsigned() {
let f = fixture();
let src = variant(&misbehave(), &f.work, "pkg", |t| t.to_owned());
let files = f.host.package_files_of(&src).unwrap();
let out = f.work.join("misbehave-0.0.2.oiplugin");
package::pack(&src, &files, &out, None).unwrap();
let dir = f.host.unpack_package(&out).unwrap();
let preview = f.host.preview_install(&dir).unwrap();
assert_eq!((preview.unsigned, preview.publisher), (true, None));
let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!((info.status, info.publisher), (PluginStatus::Inactive, None));
}
+1 -1
View File
@@ -116,7 +116,7 @@ async fn light_windows_stay_restricted_when_folder_trust_changes() {
"main",
doc.id,
None,
ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false },
ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None },
)
.await
.unwrap_err();
File diff suppressed because it is too large. Load diff
+304
View File
@@ -0,0 +1,304 @@
//! Code plugins of a remote project run on the
//! server. The desktop's plugin host syncs its live
//! code plugins over the remote protocol; Checkstyle then checks the file on
//! the server's disk when it is saved, and secrets come from the desktop.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::plugins::remote_host::{
EVENT_NOTIFICATION, PackageSyncReport, PluginEventMessage, RemotePlugins, SECRET_REQUEST, SYNC_PACKAGES,
SecretRequest,
};
use ide_host::plugins::{Consent, PluginEvent, PluginHost, PluginStatus, WorkbenchEvent};
use ide_host::rpc::{self, Dispatcher, EventMessage, Invoke};
use ide_host::{Host, HostConfig};
use ide_jsonrpc::{Handler, Peer};
use ide_plugin::Limits;
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
#[derive(Default)]
struct Client {
events: Mutex<Vec<EventMessage>>,
plugin_events: Mutex<Vec<PluginEventMessage>>,
secret_requests: Mutex<Vec<SecretRequest>>,
}
impl Handler for Client {
fn on_notification(&self, method: &str, params: Value) {
match method {
"event" => self.events.lock().push(serde_json::from_value(params).unwrap()),
EVENT_NOTIFICATION => self.plugin_events.lock().push(serde_json::from_value(params).unwrap()),
_ => {}
}
}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
if method == SECRET_REQUEST {
let r: SecretRequest = serde_json::from_value(params).unwrap();
let value = (r.name == "api-key").then(|| "sk-desktop".to_owned());
self.secret_requests.lock().push(r);
return Ok(serde_json::to_value(value).unwrap());
}
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
}
}
async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result<Value, ide_core::IdeError> {
let call = Invoke { window: "main".into(), cmd: cmd.into(), args };
peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply)
}
async fn sync(peer: &Peer, desktop: &PluginHost, files: &[&str]) -> PackageSyncReport {
let files: serde_json::Map<String, Value> = files
.iter()
.map(|id| ((*id).to_owned(), serde_json::to_value(desktop.remote_package_files(id).unwrap()).unwrap()))
.collect();
let args = json!({ "packages": desktop.remote_packages(), "files": files });
serde_json::from_value(invoke(peer, SYNC_PACKAGES, args).await.unwrap()).unwrap()
}
fn official(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official").join(name)
}
/// A test fixture plugin (`crates/ide-plugin/tests/fixtures/<name>`).
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures").join(name)
}
/// A `checkstyle` that reports a tab on line 2 of the file it is given.
fn fake_checkstyle(bin: &Path) {
std::fs::create_dir_all(bin).unwrap();
std::fs::write(
bin.join("checkstyle"),
"#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q '\t' \"$file\" || { echo '<checkstyle/>'; exit 0; }\n\
echo '<?xml version=\"1.0\"?><checkstyle><file name=\"'\"$file\"'\">'\n\
echo '<error line=\"2\" column=\"1\" severity=\"warning\" message=\"Line contains a tab character.\" source=\"com.puppycrawl.tools.checkstyle.checks.whitespace.FileTabCharacterCheck\"/>'\n\
echo '</file></checkstyle>'\nexit 1\n",
)
.unwrap();
std::fs::set_permissions(bin.join("checkstyle"), std::fs::Permissions::from_mode(0o755)).unwrap();
}
async fn until(what: &str, secs: u64, mut done: impl FnMut() -> bool) {
for _ in 0..secs * 50 {
if done() {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("timed out waiting for {what}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
fake_checkstyle(&bin);
let path_var = {
let bin = bin.clone();
Arc::new(move || Some(bin.clone().into_os_string()))
};
// The desktop: Checkstyle and AI Completion installed and granted.
let desktop =
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.unwrap();
desktop.set_program_path(path_var.clone());
for name in ["checkstyle", "ai-completion"] {
let info = desktop.install_from_dir(&official(name), &Consent::all()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
}
// The server, with the project on "its" disk.
// Canonical, as the UI reports paths (macOS: /var → /private/var).
let project = tmp.path().join("remote/demo");
std::fs::create_dir_all(project.join("src")).unwrap();
let project = std::fs::canonicalize(project).unwrap();
let base = tmp.path().join("server");
let peer_slot = Arc::new(OnceLock::new());
let sink_peer = peer_slot.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
plugins.host().set_program_path(path_var);
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client::default());
let peer = Peer::connect(cr, cw, client.clone());
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
// Digests first; then the files of what the server lacks; then nothing to do.
let first = sync(&peer, &desktop, &[]).await;
assert_eq!(first.missing, ["openidea.ai-completion", "openidea.checkstyle"], "{first:?}");
let second = sync(&peer, &desktop, &["openidea.ai-completion", "openidea.checkstyle"]).await;
assert_eq!(second.installed.len(), 2, "{second:?}");
assert!(second.failed.is_empty(), "{second:?}");
assert_eq!(sync(&peer, &desktop, &[]).await, PackageSyncReport::default());
let remote = plugins.host().info_of("openidea.checkstyle").unwrap();
assert_eq!(remote.status, PluginStatus::Inactive, "{:?}", remote.error);
assert!(remote.capabilities.iter().any(|c| c.id == "process:checkstyle" && c.granted));
// The remote window's UI routes the run-time commands to the server.
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
// Saving a file that exists only on the server: Checkstyle reads that file.
let java = project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
let saved = WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned());
invoke(&peer, "plugin_post_events", json!({ "events": [saved] })).await.unwrap();
let published = || {
client.events.lock().iter().find_map(|m| match &m.event {
IdeEvent::Diagnostics { path, source, items } if source == "plugin:openidea.checkstyle" => {
Some((path.0.clone(), items.clone()))
}
_ => None,
})
};
until("remote checkstyle diagnostics", 20, || published().is_some()).await;
let (path, items) = published().unwrap();
assert_eq!(path, java);
assert_eq!((items.len(), items[0].range.start.line), (1, 1));
// Secrets stay on the desktop: the server asks for them when used.
let secrets = plugins.host().secrets("openidea.ai-completion").unwrap();
assert_eq!(secrets, [("api-key".to_owned(), true)]);
assert_eq!(client.secret_requests.lock()[0].plugin, "openidea.ai-completion");
// A command of a remote plugin, with this window as its client.
invoke(
&peer,
"plugin_run_command",
json!({ "pluginId": "openidea.ai-completion", "commandId": "toggle", "arg": null, "editor": null }),
)
.await
.unwrap();
until("the command's notification", 5, || {
client
.plugin_events
.lock()
.iter()
.any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::Notification { .. }))
})
.await;
// Disabled on the desktop: gone from the server.
desktop.set_enabled("openidea.checkstyle", false).unwrap();
let removed = sync(&peer, &desktop, &[]).await;
assert_eq!(removed.removed, ["openidea.checkstyle"]);
assert!(plugins.host().info_of("openidea.checkstyle").is_err());
plugins.host().shutdown();
}
/// CSV Editor and a tool window with a terminal (the `jobs` fixture) in a
/// remote project. The table edits the document on the server; the jobs are
/// the server's `jobs` program's, and the terminal opens in the remote window.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn remote_projects_get_plugin_views_and_custom_editors() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("jobs"), "#!/bin/sh\n[ \"$1\" = list ] && echo remote-job\n").unwrap();
std::fs::set_permissions(bin.join("jobs"), std::fs::Permissions::from_mode(0o755)).unwrap();
let path_var = {
let bin = bin.clone();
Arc::new(move || Some(bin.clone().into_os_string()))
};
let desktop =
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.unwrap();
desktop.set_program_path(path_var.clone());
for dir in [official("csv-editor"), fixture("jobs")] {
let info = desktop.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
}
let project = tmp.path().join("remote/demo");
std::fs::create_dir_all(&project).unwrap();
let project = std::fs::canonicalize(project).unwrap();
std::fs::write(project.join("people.csv"), "name,city\nAda,London\n").unwrap();
let base = tmp.path().join("server");
let peer_slot = Arc::new(OnceLock::new());
let sink_peer = peer_slot.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
plugins.host().set_program_path(path_var);
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client::default());
let peer = Peer::connect(cr, cw, client.clone());
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs"]).await;
assert_eq!(report.installed.len(), 2, "{report:?}");
// CSV Editor: the document is the server's.
let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap();
let doc = opened["id"].as_u64().expect("doc id");
let args = |extra: Value| {
let mut a = json!({ "pluginId": "openidea.csv-editor", "view": "table", "doc": doc });
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
a
};
let view =
invoke(&peer, "plugin_view_render", args(json!({ "viewport": { "firstRow": 0, "rows": 50 } }))).await.unwrap();
assert_eq!(view["body"]["kind"], "table");
assert_eq!(view["body"]["value"]["rows"], json!([["Ada", "London"]]));
let cell = json!({ "event": { "kind": "cell", "row": 0, "column": 1, "value": "Oslo" } });
invoke(&peer, "plugin_view_event", args(cell)).await.unwrap();
let snap = invoke(&peer, "doc_snapshot", json!({ "doc": doc })).await.unwrap();
assert_eq!(snap["text"], "name,city\nAda,Oslo\n");
// Jobs: the server's program; the terminal opens in this window.
let jobs = |event: Value| json!({ "pluginId": "jobs", "view": "jobs", "doc": null, "event": event });
let view =
invoke(&peer, "plugin_view_render", json!({ "pluginId": "jobs", "view": "jobs", "doc": null })).await.unwrap();
assert_eq!(view["body"]["value"][0]["label"], "remote-job", "{view}");
invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "select", "key": "remote-job" }))).await.unwrap();
invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "button", "id": "follow" }))).await.unwrap();
until("the job's terminal", 5, || {
client.plugin_events.lock().iter().any(|m| {
m.window.as_deref() == Some("main")
&& matches!(&m.event, PluginEvent::OpenTerminal { program, .. } if program.0.starts_with(bin.canonicalize().unwrap()))
})
})
.await;
plugins.host().shutdown();
}
+27
View File
@@ -321,6 +321,33 @@ async fn local_deployment_runs_on_the_project_host() {
"remote_delete",
"remote_mkdir",
"remote_rename",
"db_sources",
"db_save_sources",
"db_set_password",
"db_test",
"db_set_ssh_secret",
"db_trust_ssh_host",
"db_connected",
"db_connect",
"db_set_manual_commit",
"db_finish_transaction",
"db_disconnect",
"db_databases",
"db_schemas",
"db_tables",
"db_table_details",
"db_ddl",
"db_schema_objects",
"db_object_ddl",
"db_explain",
"db_history",
"db_remove_history",
"db_completion",
"db_statements",
"db_execute",
"db_table_page",
"db_apply_edits",
"db_console_path",
] {
assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}");
}
+28 -1
View File
@@ -35,8 +35,16 @@ pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
.with_platform_verifier()
.map_err(|e| fail(e.to_string()))?
.with_no_client_auth();
// `localhost` is tried on IPv4 first: local servers (Ollama, dev servers)
// usually listen there, and on Windows a refused `::1` costs ~2 s before
// the system resolver's order falls back. Ports come from the URL.
let loopback = [
std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0)),
std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 0)),
];
let builder = reqwest::Client::builder()
.tls_backend_preconfigured(tls)
.resolve_to_addrs("localhost", &loopback)
.connect_timeout(Duration::from_secs(30))
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")));
with_proxy(builder, proxy).build().map_err(|e| fail(e.to_string()))
@@ -113,7 +121,11 @@ mod tests {
/// A one-shot HTTP server on loopback: answers `200` and hands back the request head.
fn server() -> (u16, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
server_on("127.0.0.1:0")
}
fn server_on(addr: &str) -> (u16, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind(addr).unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
@@ -141,6 +153,21 @@ mod tests {
check_connection(proxy, url, &CancellationToken::new()).await
}
/// `localhost` reaches a server on either loopback address.
#[tokio::test]
async fn localhost_reaches_ipv4_and_ipv6_servers() {
let direct = ProxySettings::default();
let (port, v4) = server();
assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK");
v4.join().unwrap();
// Hosts without IPv6 loopback skip this half.
if TcpListener::bind("[::1]:0").is_ok() {
let (port, v6) = server_on("[::1]:0");
assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK");
v6.join().unwrap();
}
}
#[tokio::test]
async fn builds_for_every_mode() {
for mode in [ProxyMode::None, ProxyMode::Auto, ProxyMode::Manual] {
+6
View File
@@ -384,6 +384,9 @@ impl Peer {
let resolved = resolve_spawn_program(command, env);
let mut cmd = tokio::process::Command::new(&resolved);
ide_core::process::prepare(cmd.as_std_mut());
// Wrappers (`npx`, `codex.cmd`, launcher scripts) start the real server as a child:
// stopping the connection stops the whole tree.
ide_core::process::own_group(cmd.as_std_mut());
cmd.args(args)
.current_dir(cwd)
.envs(ide_core::process::shell_env())
@@ -422,6 +425,9 @@ impl Peer {
let (status, killed) = tokio::select! {
status = child.wait() => (status.ok(), false),
_ = inner.kill.cancelled() => {
if let Some(pid) = child.id() {
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
}
let _ = child.start_kill();
(child.wait().await.ok(), true)
}
+1
View File
@@ -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
+45 -3
View File
@@ -8,16 +8,20 @@ use ide_core::{LanguageId, Range, SymbolKind};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
mod markers;
pub mod plugin_point;
mod registry;
mod syntax;
pub use markers::marked_root;
pub use plugin_point::PluginLanguages;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES,
MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats,
SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
WebReferenceKind, merge_visibility, set_external_syntax,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
@@ -138,6 +142,9 @@ pub struct LanguageServerConfig {
/// Register source files with tsserver when no project configuration exists.
#[serde(default)]
pub tsserver_project: bool,
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -157,6 +164,41 @@ pub struct DebuggerConfig {
pub connect: Option<String>,
#[serde(default)]
pub install_hint: Option<String>,
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
}
/// The program a plugin's `process:` grant approved:
/// a plugin language's server or debug adapter starts from this exact path,
/// and only while the file still has the approved content. Set by the plugin
/// host, never read from a definition.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProgramPin {
pub path: PathBuf,
/// blake3 of the file when approved.
pub hash: String,
}
impl ProgramPin {
/// Approves `path` as it is now.
pub fn approve(path: impl Into<PathBuf>) -> std::io::Result<Self> {
let path = path.into();
let hash = ide_extension::ledger::file_hash(&path)?;
Ok(Self { path, hash })
}
/// Checks the file right before it is started; the error tells the user what to do.
pub fn verify(&self) -> Result<&Path, String> {
match ide_extension::ledger::file_hash(&self.path) {
Ok(hash) if hash == self.hash => Ok(&self.path),
Ok(_) => Err(format!(
"{} changed since it was approved; review the plugin's permissions in Settings › Plugins",
self.path.display()
)),
Err(e) => Err(format!("{}: {e}", self.path.display())),
}
}
}
fn default_transport() -> String {
+294
View File
@@ -0,0 +1,294 @@
//! The `languages` extension point: language definitions contributed by
//! plugins, in the same format as `languages/<id>.toml`.
//!
//! ```toml
//! [[contributes.languages]]
//! path = "languages/solidity.toml"
//! ```
//!
//! A definition that starts programs (`language-servers.command`,
//! `debugger.command`) needs `process:<program>` for each: a declaration is a
//! capability too, or registering languages would bypass the sandbox.
//! Committed definitions live in [`PluginLanguages`], an overlay over the
//! built-in registry (built-in definitions win on id clashes): see
//! [`crate::LanguageRegistry::attach_plugins`]. A server or debugger whose
//! program is not granted is left out; the language itself still works.
use crate::{LanguageConfig, ProgramPin};
use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode};
use parking_lot::RwLock;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
pub const POINT_ID: &str = "languages";
/// Decides whether a plugin may start a program: the approved file for a
/// granted `process:` capability, or `None`.
pub type ProcessPolicy = dyn Fn(&str, &Capability) -> Option<ProgramPin> + Send + Sync;
/// Languages of enabled plugins, in commit order.
#[derive(Default)]
pub struct PluginLanguages {
items: RwLock<Vec<(u64, String, LanguageConfig)>>,
seq: AtomicU64,
revision: AtomicU64,
policy: RwLock<Option<Arc<ProcessPolicy>>>,
}
impl PluginLanguages {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn all(&self) -> Vec<LanguageConfig> {
self.items.read().iter().map(|(_, _, c)| c.clone()).collect()
}
/// `(plugin id, definition)` pairs.
pub fn with_owners(&self) -> Vec<(String, LanguageConfig)> {
self.items.read().iter().map(|(_, p, c)| (p.clone(), c.clone())).collect()
}
/// The plugin that contributes language `id`.
pub fn owner_of(&self, id: &ide_core::LanguageId) -> Option<String> {
self.items.read().iter().find(|(_, _, c)| &c.id == id).map(|(_, p, _)| p.clone())
}
/// Changes whenever [`effective`](Self::effective) may give something else.
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
/// Who may start what. Without a policy no plugin program starts.
pub fn set_process_policy(&self, policy: Arc<ProcessPolicy>) {
*self.policy.write() = Some(policy);
self.invalidate();
}
/// Grants changed: registries recompute [`effective`](Self::effective).
pub fn invalidate(&self) {
self.revision.fetch_add(1, Ordering::Release);
}
/// The definitions as they take effect: servers and the debugger whose
/// program is granted are pinned to the approved file, the others are
/// left out.
pub fn effective(&self) -> Vec<LanguageConfig> {
let policy = self.policy.read().clone();
let pin = |plugin: &str, program: &str| {
policy.as_ref().and_then(|p| p(plugin, &Capability::Process(program.to_owned())))
};
self.items
.read()
.iter()
.map(|(_, plugin, cfg)| {
let mut cfg = cfg.clone();
cfg.language_servers.retain_mut(|s| {
s.pinned = pin(plugin, &s.command);
s.pinned.is_some()
});
let debugger_pin = cfg.debugger.as_ref().and_then(|d| pin(plugin, &d.command));
match (&mut cfg.debugger, debugger_pin) {
(Some(d), Some(p)) => d.pinned = Some(p),
(debugger, _) => *debugger = None,
}
cfg
})
.collect()
}
/// Adds a definition until the returned handle is dropped.
pub fn add(self: &Arc<Self>, plugin: String, cfg: LanguageConfig) -> Disposable {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, cfg));
self.revision.fetch_add(1, Ordering::Release);
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, _, _)| *s != seq);
this.revision.fetch_add(1, Ordering::Release);
}
})
}
}
pub struct LanguagesPoint {
store: Arc<PluginLanguages>,
}
impl LanguagesPoint {
pub fn new(store: Arc<PluginLanguages>) -> Self {
Self { store }
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
/// A `languages/<id>.toml`-style file in the package.
path: PathBuf,
}
/// Parses and checks one language definition.
pub fn parse_definition(src: &str) -> Result<LanguageConfig, String> {
let cfg: LanguageConfig = toml::from_str(src).map_err(|e| format!("invalid language definition: {e}"))?;
validate_definition(&cfg)?;
Ok(cfg)
}
/// Checks a definition that did not come from a package file (a remote sync).
pub fn validate_definition(cfg: &LanguageConfig) -> Result<(), String> {
let id = cfg.id.as_str();
if id.is_empty() || cfg.name.trim().is_empty() {
return Err("language `id` and `name` must not be empty".into());
}
if !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+')) || id.len() > 64 {
return Err(format!("invalid language id {id:?}"));
}
if let Some(first) = &cfg.first_line {
regex_lite::Regex::new(first).map_err(|e| format!("invalid first-line pattern: {e}"))?;
}
Ok(())
}
/// The programs a definition starts, as `process:` capabilities.
pub fn required_capabilities(cfg: &LanguageConfig) -> Vec<Capability> {
let mut caps: Vec<Capability> = Vec::new();
let servers = cfg.language_servers.iter().map(|s| s.command.as_str());
for program in servers.chain(cfg.debugger.iter().map(|d| d.command.as_str())) {
let cap = Capability::Process(program.to_owned());
if !program.trim().is_empty() && !caps.contains(&cap) {
caps.push(cap);
}
}
caps
}
impl ExtensionPoint for LanguagesPoint {
fn id(&self) -> &'static str {
POINT_ID
}
fn placement(&self) -> Placement {
Placement::Workspace
}
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let decl: Decl = decode(POINT_ID, decl)?;
let src = pkg.read_text(&decl.path)?;
let cfg = parse_definition(&src).map_err(|e| format!("{}: {e}", decl.path.display()))?;
let caps = required_capabilities(&cfg);
let (store, plugin, key, label) = (
self.store.clone(),
pkg.id().to_owned(),
cfg.id.to_string(),
format!("{} (.{})", cfg.name, cfg.extensions.join(", .")),
);
let mut prepared = Prepared::new(label, move || Ok(store.add(plugin, cfg))).key(key).file(decl.path);
for cap in caps {
prepared = prepared.capability(cap);
}
Ok(prepared)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_extension::{ExtensionRegistry, Manifest};
#[test]
fn languages_point_registers_and_requires_process_capabilities() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("l")).unwrap();
std::fs::write(
tmp.path().join("l/sol.toml"),
"id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\
[[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\nargs = [\"--lsp\"]\n",
)
.unwrap();
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.languages]]\npath = \"l/sol.toml\"";
let pkg = Package::new(Manifest::parse(src).unwrap(), tmp.path().to_path_buf());
let store = PluginLanguages::new();
let reg = ExtensionRegistry::new();
reg.register(Arc::new(LanguagesPoint::new(store.clone()))).unwrap();
let set = reg.prepare(&pkg).unwrap();
assert_eq!(set.capabilities, [Capability::Process("solc".into())]);
assert_eq!(set.files, [PathBuf::from("l/sol.toml")]);
reg.commit(set).unwrap();
assert_eq!(store.all()[0].id.as_str(), "solidity");
reg.dispose_plugin("p");
assert!(store.all().is_empty());
let bad = |def: &str| parse_definition(def).unwrap_err();
assert!(bad("not toml [").contains("invalid language"));
assert!(bad("id = \"\"\nname = \"x\"").contains("must not be empty"));
assert!(bad("id = \"a b\"\nname = \"x\"").contains("invalid language id"));
}
#[test]
fn registry_overlay_follows_the_store_and_the_grants() {
use crate::LanguageRegistry;
use ide_core::LanguageId;
use std::path::Path;
let base = LanguageRegistry::load(None).unwrap();
let store = PluginLanguages::new();
base.attach_plugins(store.clone());
// Projects fork before plugins load: the fork follows the store anyway.
let project = base.fork();
assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text());
let sol = parse_definition(
"id = \"solidity\"\nname = \"Solidity\"\nextensions = [\"sol\"]\n\
[[language-servers]]\nname = \"solc\"\ncommand = \"solc\"\n\
[debugger]\nadapter = \"soldbg\"\ncommand = \"soldbg\"\n",
)
.unwrap();
let rust_clash = parse_definition("id = \"rust\"\nname = \"Evil Rust\"\nextensions = [\"rs\"]").unwrap();
let ext_clash = parse_definition(
"id = \"rusty\"\nname = \"Rusty\"\nextensions = [\"rs\", \"rsy\"]\nfilenames = [\"Cargo.lock\"]",
)
.unwrap();
let _sol = store.add("acme.sol".into(), sol);
let _clash = store.add("acme.evil".into(), rust_clash);
let _ext = store.add("acme.evil".into(), ext_clash);
assert_eq!(project.detect(Path::new("a.sol"), None).as_str(), "solidity");
let rust = project.get(&LanguageId::new("rust")).unwrap();
assert_eq!(rust.name, "Rust", "built-in definitions win on id clashes");
assert_eq!(project.detect(Path::new("main.rs"), None).as_str(), "rust", "and keep their extensions");
assert_ne!(project.detect(Path::new("Cargo.lock"), None).as_str(), "rusty", "and file names");
assert_eq!(project.detect(Path::new("x.rsy"), None).as_str(), "rusty");
// No policy: the language works, its programs do not start.
let cfg = project.get(&LanguageId::new("solidity")).unwrap();
assert!(cfg.language_servers.is_empty() && cfg.debugger.is_none());
let pin = ProgramPin { path: "/opt/solc".into(), hash: "h".into() };
let granted = pin.clone();
store.set_process_policy(Arc::new(move |plugin, cap| {
(plugin == "acme.sol" && *cap == Capability::Process("solc".into())).then(|| granted.clone())
}));
let cfg = project.get(&LanguageId::new("solidity")).unwrap();
assert_eq!(cfg.language_servers[0].pinned.as_ref(), Some(&pin));
assert!(cfg.debugger.is_none(), "soldbg was not granted");
drop(_sol);
assert_eq!(project.detect(Path::new("a.sol"), None), LanguageId::plain_text());
assert!(base.get(&LanguageId::new("solidity")).is_none());
}
#[test]
fn program_pins_check_the_file() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("tool");
std::fs::write(&bin, "v1").unwrap();
let pin = ProgramPin { path: bin.clone(), hash: ide_extension::ledger::file_hash(&bin).unwrap() };
assert_eq!(pin.verify().unwrap(), bin);
std::fs::write(&bin, "v2").unwrap();
assert!(pin.verify().unwrap_err().contains("changed since it was approved"));
}
}
Loaded 100 of 814 files, more files were not shown because too many files have changed in this diff. Show more